Let a project keep named snapshots and restore them (#231)
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 9m38s

Autosave only keeps the latest revision. Before a risky change there
was no way to put a known good state aside and go back to it later.
The main menu now has "Snapshots..." to create, restore and delete
named copies of the open project.

- Server: SnapshotStore.php keeps each snapshot as a copy of the
  project JSON under data/snapshots/<project>/ (git-ignored). Four
  routes under projects/{id}/snapshots use the same owner check as the
  project, so an admin also sees foreign snapshots.
- Create writes the autosave first. With unsaved edits (play with open
  edits, failed save, conflict) no snapshot is made and a message says
  why. The rule is the pure function in src/editor/snapshots.ts.
- An empty name falls back to the local date and time.
- Restore asks first, then keeps the current state as "Before
  restoring <name>". It writes the snapshot back as a new revision and
  reloads the project, so the undo list is empty. A restore can itself
  be undone by restoring that snapshot.
- Deleting a project deletes its snapshots too. Images, sounds, fonts
  and pixel art are content-addressed and never deleted, so a snapshot
  can always reach them.
- Client: SnapshotStore and HttpSnapshotStore in src/core/store, the
  SnapshotsDialog, and the flows in ProjectSession. A stale revision
  (409) becomes StaleRevisionError.
- CI runs the new PHP test in the gate job next to accounts.

Not covered by e2e: the lock during play and the conflict case. Both
are unit tests only.

Tests:
- check.sh: 164 files, 2401 tests, CHECK EXIT 0.
- tests/php/snapshots.test.php: 14 checks, all passed.
- tests/editor/snapshots.test.ts: 5 tests for the time format, the
  blocker rule, the routes and 409.
- New e2e module 50 on a throwaway project: create, rename, restore,
  undo the restore, delete, then delete the project and get 404 on
  its snapshots. 46b expects the new menu entry. e2e 46,50: 20 checks
  passed. The check sum goes from 1385 to 1393 (computed).
- Counter-checks, originals restored and compared with cmp: blocker
  always allowed fails 1 unit test. No sorting fails 2 PHP checks.
  Restore without keeping the old state fails 50c and aborts 50d.
- The first sort test with 2 snapshots passed by chance half the time
  because of random ids. It now uses 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 18:21:52 +02:00
1 parent 17a6fd0339
commit 94749b4f3e
18 files changed
+849 -7

No files matched your search

+5 -3
View File
@@ -33,10 +33,12 @@ jobs:
env: env:
GM_TEST_WORKERS: 2 GM_TEST_WORKERS: 2
# The API's own rules (#180, #179). Not part of check.sh: the host the editor is built on # The API's own rules (#180, #179, #231). Not part of check.sh: the host the editor is built
# has no PHP, the image does. # on has no PHP, the image does.
- name: PHP tests - name: PHP tests
run: php tests/php/accounts.test.php run: |
php tests/php/accounts.test.php
php tests/php/snapshots.test.php
suite: suite:
runs-on: game-maker-ci runs-on: game-maker-ci
+1
View File
@@ -3,6 +3,7 @@
/data/e2e-password.txt /data/e2e-password.txt
/data/e2e-admin-password.txt /data/e2e-admin-password.txt
/data/sessions/ /data/sessions/
/data/snapshots/
/data/users.json /data/users.json
/data/textures/ /data/textures/
/data/fonts/ /data/fonts/
+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1385 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1393 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+99 -2
View File
@@ -31,7 +31,14 @@ declare(strict_types=1);
* POST ?r=projects {name} → {id,name,revision,updatedAt,project} * POST ?r=projects {name} → {id,name,revision,updatedAt,project}
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project} * GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision} * PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
* DELETE ?r=projects/{id} → 204 * DELETE ?r=projects/{id} → 204 (its snapshots go with it)
*
* GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first
* POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt}
* DELETE ?r=projects/{id}/snapshots/{sid} → 204
* POST ?r=projects/{id}/snapshots/{sid}/restore {revision} → {revision,updatedAt,before} | 409
* (a snapshot copies the project as stored; restoring keeps the
* stand it replaces as the snapshot `before`, #231)
* *
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}] * GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
@@ -65,6 +72,7 @@ require __DIR__ . '/lib/PixelStore.php';
require __DIR__ . '/lib/PluginStore.php'; require __DIR__ . '/lib/PluginStore.php';
require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/SessionStore.php'; require __DIR__ . '/lib/SessionStore.php';
require __DIR__ . '/lib/SnapshotStore.php';
require __DIR__ . '/lib/SoundStore.php'; require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.php'; require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.php'; require __DIR__ . '/lib/TextureStore.php';
@@ -168,6 +176,14 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
Response::error('Invalid project id', 400); Response::error('Invalid project id', 400);
} }
$snapshots = new SnapshotStore(dirname(__DIR__) . '/data/snapshots');
if (($parts[2] ?? null) === 'snapshots') {
routeSnapshots($parts, $method, $store, $snapshots, $id);
}
if (count($parts) > 2) {
Response::error('Not found', 404);
}
if ($method === 'GET') { if ($method === 'GET') {
$doc = $store->load($id); $doc = $store->load($id);
if ($doc === null) { if ($doc === null) {
@@ -196,12 +212,92 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
if (!$store->delete($id)) { if (!$store->delete($id)) {
Response::error('Project not found', 404); Response::error('Project not found', 404);
} }
$snapshots->deleteAll($id);
Response::noContent(); Response::noContent();
} }
Response::error('Method not allowed', 405); Response::error('Method not allowed', 405);
} }
/**
* The snapshots of one project (#231). Whoever may load the project may see, make, restore and
* delete its snapshots; for everybody else the project, and with it this route, is not there.
*
* @param list<string> $parts
*/
function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id): never
{
$doc = $store->load($id);
if ($doc === null) {
Response::error('Project not found', 404);
}
if (count($parts) === 3) {
if ($method === 'GET') {
Response::json($snapshots->list($id));
}
if ($method === 'POST') {
$name = snapshotName(Response::body());
Response::json($snapshots->create($id, $doc, $name === '' ? gmdate('Y-m-d H:i') . ' UTC' : $name), 201);
}
Response::error('Method not allowed', 405);
}
$snapshotId = $parts[3];
if (!Store::isValidId($snapshotId) || count($parts) > 5) {
Response::error('Not found', 404);
}
$snapshot = $snapshots->load($id, $snapshotId);
if ($snapshot === null) {
Response::error('Snapshot not found', 404);
}
if (count($parts) === 4) {
if ($method !== 'DELETE') {
Response::error('Method not allowed', 405);
}
$snapshots->delete($id, $snapshotId);
Response::noContent();
}
if ($parts[4] !== 'restore') {
Response::error('Not found', 404);
}
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
$body = Response::body();
if (!isset($body['revision']) || !is_int($body['revision'])) {
Response::error('Body must contain integer "revision"', 422);
}
// The same guard a save has: a tab that is behind must not throw away what another one saved.
if ($body['revision'] !== $doc['revision']) {
Response::error('Project was modified elsewhere', 409, ['revision' => $doc['revision']]);
}
// The stand being replaced is kept first, so a restore can itself be taken back.
$before = $snapshots->create($id, $doc, mb_substr('Before restoring ' . $snapshot['name'], 0, 120));
try {
$result = $store->save($id, $doc['revision'], $snapshot['project']);
} catch (StaleRevisionException $e) {
$snapshots->delete($id, $before['id']);
Response::error('Project was modified elsewhere', 409, ['revision' => $e->currentRevision]);
}
if ($result === null) {
$snapshots->delete($id, $before['id']);
Response::error('Project not found', 404);
}
Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt'], 'before' => $before]);
}
function snapshotName(array $body): string
{
$name = trim((string) ($body['name'] ?? ''));
if (mb_strlen($name) > 120) {
Response::error('Snapshot name must be at most 120 characters', 422);
}
return $name;
}
/** @param list<string> $parts */ /** @param list<string> $parts */
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
{ {
@@ -472,7 +568,8 @@ try {
$route = trim((string) ($_GET['r'] ?? ''), '/'); $route = trim((string) ($_GET['r'] ?? ''), '/');
$parts = $route === '' ? [] : explode('/', $route); $parts = $route === '' ? [] : explode('/', $route);
if (count($parts) < 1 || count($parts) > 2) { // Only a project reaches deeper than two parts: `projects/{id}/snapshots/{snapshotId}/restore`.
if (count($parts) < 1 || count($parts) > ($parts[0] === 'projects' ? 5 : 2)) {
Response::error('Not found', 404); Response::error('Not found', 404);
} }
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* Snapshots of projects (#231): `data/snapshots/{projectId}/{snapshotId}.json`, each a copy of the
* project as it was stored, with { id, name, revision, createdAt, project }.
*
* A copy of the project document is all a snapshot needs: images, fonts, sounds and cels are
* content-addressed and never removed, so whatever the copy names is still there to load.
*
* The store checks nothing about who asks. The router reaches it only after `Store::load` found
* the project for that user, so the snapshots of a project are seen by exactly those who see it.
*/
final class SnapshotStore
{
public function __construct(private readonly string $dir)
{
}
/** @return list<array{id:string,name:string,revision:int,createdAt:string}>, newest first */
public function list(string $projectId): array
{
$items = [];
foreach (glob($this->folder($projectId) . '/*.json') ?: [] as $file) {
$snapshot = $this->readFile($file);
if ($snapshot !== null) {
$items[] = self::summary($snapshot);
}
}
usort($items, static fn(array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt']));
return $items;
}
/**
* Copies a stored project document into a new snapshot.
*
* @param array{revision:int,project:array} $doc
* @return array{id:string,name:string,revision:int,createdAt:string}
*/
public function create(string $projectId, array $doc, string $name): array
{
$folder = $this->folder($projectId);
if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) {
throw new RuntimeException("Cannot create snapshot directory {$folder}");
}
do {
$id = Store::newId();
} while (file_exists($this->path($projectId, $id)));
$snapshot = [
'id' => $id,
'name' => $name,
'revision' => $doc['revision'],
// Microseconds, so two snapshots made within one second still list in the order made.
'createdAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.uP'),
'project' => $doc['project'],
];
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('Cannot encode snapshot');
}
$path = $this->path($projectId, $id);
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write snapshot file');
}
return self::summary($snapshot);
}
/** @return array{id:string,name:string,revision:int,createdAt:string,project:array}|null */
public function load(string $projectId, string $id): ?array
{
return $this->readFile($this->path($projectId, $id));
}
public function delete(string $projectId, string $id): bool
{
$path = $this->path($projectId, $id);
return is_file($path) && unlink($path);
}
/** What deleting the project does to its snapshots. */
public function deleteAll(string $projectId): void
{
$folder = $this->folder($projectId);
foreach (glob($folder . '/*.json') ?: [] as $file) {
unlink($file);
}
if (is_dir($folder)) {
@rmdir($folder);
}
}
/** @return array{id:string,name:string,revision:int,createdAt:string} */
private static function summary(array $snapshot): array
{
return [
'id' => $snapshot['id'],
'name' => $snapshot['name'],
'revision' => $snapshot['revision'],
'createdAt' => $snapshot['createdAt'],
];
}
private function folder(string $projectId): string
{
return $this->dir . '/' . $projectId;
}
private function path(string $projectId, string $id): string
{
return $this->folder($projectId) . '/' . $id . '.json';
}
private function readFile(string $path): ?array
{
if (!is_file($path)) {
return null;
}
$raw = file_get_contents($path);
$snapshot = $raw === false ? null : json_decode($raw, true);
if (!is_array($snapshot) || !isset($snapshot['id'], $snapshot['project']) || !is_array($snapshot['project'])) {
return null;
}
$snapshot['name'] = is_string($snapshot['name'] ?? null) ? $snapshot['name'] : '';
$snapshot['revision'] = (int) ($snapshot['revision'] ?? 0);
$snapshot['createdAt'] = is_string($snapshot['createdAt'] ?? null) ? $snapshot['createdAt'] : '';
return $snapshot;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { apiRequest } from './http';
import { StaleRevisionError } from './ProjectStore';
import type { SnapshotStore, SnapshotSummary } from './SnapshotStore';
export class HttpSnapshotStore implements SnapshotStore {
constructor(private readonly endpoint: string) {}
list(projectId: string): Promise<SnapshotSummary[]> {
return this.request('GET', route(projectId));
}
create(projectId: string, name: string): Promise<SnapshotSummary> {
return this.request('POST', route(projectId), { name });
}
restore(
projectId: string,
snapshotId: string,
revision: number,
): Promise<{ revision: number; before: SnapshotSummary }> {
return this.request('POST', `${route(projectId, snapshotId)}/restore`, { revision });
}
delete(projectId: string, snapshotId: string): Promise<void> {
return this.request('DELETE', route(projectId, snapshotId));
}
private request<T>(method: string, path: string, payload?: unknown): Promise<T> {
return apiRequest<T>(
this.endpoint,
method,
path,
{
headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {},
body: payload !== undefined ? JSON.stringify(payload) : undefined,
},
(status, data) =>
status === 409
? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1)
: undefined,
);
}
}
function route(projectId: string, snapshotId?: string): string {
const base = `projects/${encodeURIComponent(projectId)}/snapshots`;
return snapshotId === undefined ? base : `${base}/${encodeURIComponent(snapshotId)}`;
}
+26
View File
@@ -0,0 +1,26 @@
/** One snapshot of a project as the list shows it (#231). */
export interface SnapshotSummary {
id: string;
name: string;
/** The project revision the snapshot copied. */
revision: number;
/** ISO time, UTC. */
createdAt: string;
}
/**
* The snapshots of a project: copies of the project as the server stored it. A restore writes the
* copy back as a new revision and keeps the stand it replaced as a snapshot of its own, `before`.
*/
export interface SnapshotStore {
list(projectId: string): Promise<SnapshotSummary[]>;
/** An empty name lets the server name it by the time. */
create(projectId: string, name: string): Promise<SnapshotSummary>;
/** Rejects with `StaleRevisionError` when `revision` is not the stored one. */
restore(
projectId: string,
snapshotId: string,
revision: number,
): Promise<{ revision: number; before: SnapshotSummary }>;
delete(projectId: string, snapshotId: string): Promise<void>;
}
+2
View File
@@ -3,6 +3,7 @@ import type { AuthStore, SignedIn } from '../core/store/AuthStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore'; import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore } from '../core/store/HttpFileStore'; import { HttpFileStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
import { HttpUsersStore } from '../core/store/HttpUsersStore'; import { HttpUsersStore } from '../core/store/HttpUsersStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
import { loadPhysics } from '../runtime/physics/engine'; import { loadPhysics } from '../runtime/physics/engine';
@@ -108,6 +109,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'), new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'), new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'), new HttpFileStore(API_ENDPOINT, 'sounds'),
new HttpSnapshotStore(API_ENDPOINT),
{ {
user, user,
role, role,
+9
View File
@@ -5,6 +5,7 @@ import type { Role } from '../core/store/AuthStore';
import type { ProjectStore } from '../core/store/ProjectStore'; import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins'; import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore'; import type { FontStore } from '../core/store/FontStore';
import type { SnapshotStore } from '../core/store/SnapshotStore';
import type { SoundStore } from '../core/store/SoundStore'; import type { SoundStore } from '../core/store/SoundStore';
import type { TextureStore } from '../core/store/TextureStore'; import type { TextureStore } from '../core/store/TextureStore';
import type { UsersStore } from '../core/store/UsersStore'; import type { UsersStore } from '../core/store/UsersStore';
@@ -35,6 +36,7 @@ import { TAB_OF_ASSET } from './assetTabs';
import { attachAutosave } from './autosave'; import { attachAutosave } from './autosave';
import { PasswordDialog } from './auth/PasswordDialog'; import { PasswordDialog } from './auth/PasswordDialog';
import type { OpenProjectDialog } from './OpenProjectDialog'; import type { OpenProjectDialog } from './OpenProjectDialog';
import type { SnapshotsDialog } from './SnapshotsDialog';
import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
import { dialogSlot } from './FormDialog'; import { dialogSlot } from './FormDialog';
import { ContainerEditor } from './container/ContainerEditor'; import { ContainerEditor } from './container/ContainerEditor';
@@ -256,6 +258,7 @@ export class Editor implements PlayHost {
textures: TextureStore, textures: TextureStore,
fonts: FontStore, fonts: FontStore,
sounds: SoundStore, sounds: SoundStore,
snapshots: SnapshotStore,
account: EditorAccount, account: EditorAccount,
) { ) {
this.viewport = new SceneViewport(state); this.viewport = new SceneViewport(state);
@@ -291,6 +294,7 @@ export class Editor implements PlayHost {
() => { () => {
if (this.play.isPlaying) this.play.stop(); if (this.play.isPlaying) this.play.stop();
}, },
{ store: snapshots, flush: () => this.autosave.flush() },
// Every project brings its own plugins (#119); until `startPlugins` has run there is no // Every project brings its own plugins (#119); until `startPlugins` has run there is no
// manager, and a project with plugins simply has none of them. // manager, and a project with plugins simply has none of them.
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))), async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
@@ -524,6 +528,11 @@ export class Editor implements PlayHost {
return this.projects.projectPicker(); return this.projects.projectPicker();
} }
/** The open "Snapshots" dialog (#231), for the check scripts. */
snapshotsDialog(): SnapshotsDialog | null {
return this.projects.snapshotList();
}
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */ /** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
hasTab(id: string): boolean { hasTab(id: string): boolean {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`); return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
+94 -1
View File
@@ -7,17 +7,21 @@ import {
SetPlayFromCommand, SetPlayFromCommand,
SetSymbolScaleCommand, SetSymbolScaleCommand,
} from '../core/commands/projectCommands'; } from '../core/commands/projectCommands';
import type { ProjectStore } from '../core/store/ProjectStore'; import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
import { dialogSlot } from './FormDialog'; import { dialogSlot } from './FormDialog';
import { OpenProjectDialog } from './OpenProjectDialog'; import { OpenProjectDialog } from './OpenProjectDialog';
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'; import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
import { clearSaved, hasSaved } from './saves'; import { clearSaved, hasSaved } from './saves';
import { snapshotBlocker, snapshotTime } from './snapshots';
import { SnapshotsDialog } from './SnapshotsDialog';
import type { EditorState } from './state'; import type { EditorState } from './state';
export class ProjectSession { export class ProjectSession {
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>(); private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
private readonly pickerDialog = dialogSlot<OpenProjectDialog>(); private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
/** /**
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a * `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
@@ -27,6 +31,8 @@ export class ProjectSession {
private readonly state: EditorState, private readonly state: EditorState,
private readonly store: ProjectStore, private readonly store: ProjectStore,
private readonly beforeSwitch: () => void, private readonly beforeSwitch: () => void,
/** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */
private readonly snapshots: { store: SnapshotStore; flush: () => Promise<void> },
/** /**
* Runs on the project as it came off the server, before it is read into the model: the plugins * Runs on the project as it came off the server, before it is read into the model: the plugins
* this project switches on are loaded there (#119), so that what they register is in place * this project switches on are loaded there (#119), so that what they register is in place
@@ -94,6 +100,7 @@ export class ProjectSession {
{ label: 'New project', onSelect: () => void this.run(this.createNext()) }, { label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() }, { label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true }, { separator: true },
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() }, { label: 'Project settings…', onSelect: () => this.openProjectSettings() },
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() }, { label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more, ...more,
@@ -141,6 +148,92 @@ export class ProjectSession {
]).openCentered(); ]).openCentered();
} }
/** The snapshots of the open project (#231). */
openSnapshots(): void {
const projectId = this.state.project.id;
void this.run(
(async () => {
const list = await this.snapshots.store.list(projectId);
this.snapshotsDialog.open(
new SnapshotsDialog(list, {
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
onRestore: (snapshot) => this.confirmRestore(projectId, snapshot),
onDelete: (snapshot) => this.confirmDeleteSnapshot(projectId, snapshot),
}),
);
})(),
);
}
/** The open snapshots dialog, for the check scripts. */
snapshotList(): SnapshotsDialog | null {
return this.snapshotsDialog.current();
}
private async createSnapshot(projectId: string, name: string): Promise<void> {
await this.snapshots.flush();
const blocker = snapshotBlocker(this.state.saveStatus);
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
const made = await this.snapshots.store.create(projectId, name || snapshotTime(new Date()));
this.state.setNotice(`Snapshot "${made.name}" created`);
this.openSnapshots();
}
private confirmRestore(projectId: string, snapshot: SnapshotSummary): void {
new Dialog(
`Restore snapshot "${snapshot.name}"?`,
'The project as it is now is kept as a snapshot of its own first, so this can be taken back.',
[
{ label: 'Cancel' },
{
label: 'Restore',
primary: true,
onClick: () => void this.run(this.restoreSnapshot(projectId, snapshot)),
},
],
).openCentered();
}
/** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
this.beforeSwitch();
await this.snapshots.flush();
const blocker = snapshotBlocker(this.state.saveStatus);
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
try {
const { before } = await this.snapshots.store.restore(projectId, snapshot.id, this.state.revision);
await this.open(projectId);
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
} catch (error) {
if (!(error instanceof StaleRevisionError)) throw error;
this.state.setSaveStatus('conflict');
this.state.setNotice('This project was changed elsewhere. Reload to continue.', 'error');
}
}
private confirmDeleteSnapshot(projectId: string, snapshot: SnapshotSummary): void {
new Dialog(`Delete snapshot "${snapshot.name}"?`, 'The snapshot is removed permanently.', [
{ label: 'Cancel', onClick: () => this.openSnapshots() },
{
label: 'Delete',
danger: true,
onClick: () =>
void this.run(
(async () => {
await this.snapshots.store.delete(projectId, snapshot.id);
this.openSnapshots();
})(),
),
},
]).openCentered();
}
/** /**
* Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog * Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog
* (#183). Each is an undo step of its own – one each, and only for what actually changed. * (#183). Each is an undo step of its own – one each, and only for what actually changed.
+85
View File
@@ -0,0 +1,85 @@
import type { SnapshotSummary } from '../core/store/SnapshotStore';
import { Button, HStack, Label, ListView, Popup, ScrollContainer, TextField, Theme } from '../ui';
import { dialogButtons } from './FormDialog';
import { snapshotTime } from './snapshots';
const LIST_WIDTH = 360;
const LIST_HEIGHT = 220;
/**
* "Snapshots…" (#231): the snapshots of the open project, newest first, a field to name the next
* one, and Restore and Delete on the one picked.
*
* Like the project picker, Restore and Delete close the dialog and hand the choice on: the caller
* asks for confirmation, and a question over a list it is no longer about would only confuse.
* Create keeps it open – the caller opens it again with the new snapshot in the list.
*/
export class SnapshotsDialog extends Popup {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
private readonly nameField: TextField;
private readonly restoreButton: Button;
private readonly deleteButton: Button;
private picked: string | null;
private readonly rows: { name: string; time: string }[];
constructor(
snapshots: readonly SnapshotSummary[],
actions: {
onCreate(name: string): void;
onRestore(snapshot: SnapshotSummary): void;
onDelete(snapshot: SnapshotSummary): void;
},
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.picked = snapshots[0]?.id ?? null;
this.rows = snapshots.map((s) => ({ name: s.name, time: snapshotTime(new Date(s.createdAt)) }));
this.content.add(new Label('Snapshots', { style: 'bodyBold' }));
const create = (): void => actions.onCreate(this.nameField.value.trim());
this.nameField = new TextField('', { placeholder: 'Name (optional)', width: 220, onSubmit: create });
const createButton = new Button('Create snapshot', { variant: 'primary', onClick: create });
const createRow = new HStack({ gap: Theme.gap, align: 'center' });
createRow.add(this.nameField, 1);
createRow.add(createButton);
this.content.add(createRow);
this.list.minWidth = LIST_WIDTH;
this.list.setItems(snapshots.map((s, i) => ({ id: s.id, label: s.name, hint: this.rows[i]?.time })));
this.list.select(this.picked, true);
this.list.onSelect = (id) => {
this.picked = id;
this.refresh();
};
this.scroll.minWidth = LIST_WIDTH;
this.scroll.minHeight = LIST_HEIGHT;
this.scroll.setContent(this.list);
this.content.add(this.scroll);
if (snapshots.length === 0) this.content.add(new Label('No snapshots yet', { style: 'muted' }));
const withPicked = (action: (snapshot: SnapshotSummary) => void) => () => {
const snapshot = snapshots.find((s) => s.id === this.picked);
if (snapshot) this.closeThen(() => action(snapshot))();
};
this.deleteButton = new Button('Delete', { variant: 'danger', onClick: withPicked(actions.onDelete) });
this.restoreButton = new Button('Restore', { onClick: withPicked(actions.onRestore) });
this.content.add(dialogButtons(this.closeThen(), this.restoreButton, this.deleteButton));
this.refresh();
}
/** What the list shows and which row is picked (test hook). */
info(): { rows: { name: string; time: string }[]; picked: string | null } {
return { rows: this.rows.map((r) => ({ ...r })), picked: this.picked };
}
/** The name field, for the check scripts to type into. */
get nameControl(): TextField {
return this.nameField;
}
private refresh(): void {
this.restoreButton.setEnabled(this.picked !== null);
this.deleteButton.setEnabled(this.picked !== null);
}
}
+5
View File
@@ -234,6 +234,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
}, },
/** The "Open project" dialog (#183): the projects it lists and the row picked. */ /** The "Open project" dialog (#183): the projects it lists and the row picked. */
projectPicker: () => editor.projectPickerDialog()?.info() ?? null, projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
/** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */
snapshots: () => {
const dialog = editor.snapshotsDialog();
return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null;
},
/** The dividers left and right of the centre column – dragging them narrows the tab row. */ /** The dividers left and right of the centre column – dragging them narrows the tab row. */
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }), panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
/** The left column: asset panel on top, scene objects below, and the divider between them. */ /** The left column: asset panel on top, scene objects below, and the divider between them. */
+25
View File
@@ -0,0 +1,25 @@
import type { SaveStatus } from './state';
/** When a snapshot was made, in the local time of the browser: `2026-09-25 17:39`. */
export function snapshotTime(at: Date): string {
const two = (n: number) => String(n).padStart(2, '0');
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
/**
* Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies
* what it has stored, so the editor has to have saved everything first: a snapshot without the
* last edits would be a stand nobody ever saw.
*/
export function snapshotBlocker(status: SaveStatus): string | null {
switch (status) {
case 'saved':
return null;
case 'conflict':
return 'This project was changed elsewhere. Reload to continue.';
case 'error':
return 'The project could not be saved, so there is nothing current to take a snapshot of';
default:
return 'The latest changes are not saved yet – stop the game or wait a moment';
}
}
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpSnapshotStore } from '../../src/core/store/HttpSnapshotStore';
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
import { snapshotBlocker, snapshotTime } from '../../src/editor/snapshots';
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('snapshot rules (#231)', () => {
it('shows the time in local time, minutes padded', () => {
expect(snapshotTime(new Date(2026, 0, 5, 7, 3))).toBe('2026-01-05 07:03');
});
it('allows a snapshot only when everything is saved', () => {
expect(snapshotBlocker('saved')).toBeNull();
for (const status of ['dirty', 'saving', 'error', 'conflict'] as const) {
expect(snapshotBlocker(status)).toEqual(expect.any(String));
}
expect(snapshotBlocker('conflict')).toContain('changed elsewhere');
});
});
describe('HttpSnapshotStore (#231)', () => {
it('lists, creates and deletes under the project', async () => {
const mock = stubFetch(JSON.stringify([]), { status: 200 });
const store = new HttpSnapshotStore('/api');
await store.list('p1');
await store.create('p1', 'before the boss');
stubFetch(null, { status: 204 });
await store.delete('p1', 's1');
const calls = [...mock.mock.calls, ...vi.mocked(fetch).mock.calls].map(([url, init]) => [
decodeURIComponent((url as string).split('r=')[1] ?? ''),
init?.method,
init?.body,
]);
expect(calls).toEqual([
['projects/p1/snapshots', 'GET', undefined],
['projects/p1/snapshots', 'POST', JSON.stringify({ name: 'before the boss' })],
['projects/p1/snapshots/s1', 'DELETE', undefined],
]);
});
it('restores with the revision the editor holds and hands back the kept stand', async () => {
const before = { id: 's2', name: 'Before restoring a', revision: 7, createdAt: '' };
const mock = stubFetch(JSON.stringify({ revision: 8, before }), { status: 200 });
const result = await new HttpSnapshotStore('/api').restore('p1', 's1', 7);
expect(result).toEqual({ revision: 8, before });
expect(decodeURIComponent(mock.mock.calls[0]?.[0] as string)).toContain(
'r=projects/p1/snapshots/s1/restore',
);
expect(mock.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ revision: 7 }));
});
it('turns a 409 into a stale revision', async () => {
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 9 }), { status: 409 });
const failure = await new HttpSnapshotStore('/api').restore('p1', 's1', 7).catch((e: unknown) => e);
expect(failure).toBeInstanceOf(StaleRevisionError);
expect((failure as StaleRevisionError).serverRevision).toBe(9);
});
});
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* The snapshot store (#231) on a scratch directory. Plain PHP like accounts.test.php; CI runs it
* with `php tests/php/snapshots.test.php` and reads the last line.
*/
require __DIR__ . '/../../api/lib/Store.php';
require __DIR__ . '/../../api/lib/SnapshotStore.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
}
$dir = sys_get_temp_dir() . '/game-maker-php-snapshots-' . bin2hex(random_bytes(4));
mkdir($dir);
$snapshots = new SnapshotStore($dir);
check('a project without snapshots lists none', $snapshots->list('p1') === []);
$doc = ['revision' => 4, 'project' => ['id' => 'p1', 'name' => 'Game', 'assets' => [['id' => 'a']]]];
$first = $snapshots->create('p1', $doc, 'first');
$second = $snapshots->create('p1', ['revision' => 5] + $doc, 'second');
check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first);
check('two snapshots get two ids', $first['id'] !== $second['id']);
check('the summary carries no project', !isset($first['project']));
$listed = $snapshots->list('p1');
check(
'the list is newest first, also within one second',
array_column($listed, 'name') === ['second', 'first'],
array_column($listed, 'name'),
);
// The ids are random, so two files come back from the folder in the right order half the time
// anyway. With eight, the folder's own order matching by chance is one in 40320.
$more = new SnapshotStore("{$dir}/order");
foreach (range(1, 8) as $n) {
$more->create('p', $doc, "n{$n}");
}
check(
'eight made in a row list in reverse order of making',
array_column($more->list('p'), 'name') === ['n8', 'n7', 'n6', 'n5', 'n4', 'n3', 'n2', 'n1'],
array_column($more->list('p'), 'name'),
);
$loaded = $snapshots->load('p1', $first['id']);
check('a snapshot loads the project as it was copied', $loaded !== null && $loaded['project'] === $doc['project'], $loaded);
check('a snapshot of another project is not found', $snapshots->load('p2', $first['id']) === null);
file_put_contents("{$dir}/p1/broken.json", '{"id":"broken"');
check('a broken file is left out of the list', count($snapshots->list('p1')) === 2, $snapshots->list('p1'));
unlink("{$dir}/p1/broken.json");
check('deleting a snapshot says so', $snapshots->delete('p1', $first['id']));
check('it is gone from the list', array_column($snapshots->list('p1'), 'name') === ['second']);
check('deleting it again says it was not there', !$snapshots->delete('p1', $first['id']));
$snapshots->create('p2', $doc, 'other');
$snapshots->deleteAll('p1');
check('deleting all removes the folder of that project', !is_dir("{$dir}/p1"));
check('and leaves the snapshots of other projects', count($snapshots->list('p2')) === 1);
exec('rm -rf ' . escapeshellarg($dir));
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);
+1
View File
@@ -55,6 +55,7 @@ export async function topbar(t) {
'New project', 'New project',
'Open project…', 'Open project…',
null, null,
'Snapshots…',
'Project settings…', 'Project settings…',
'Clear saved data', 'Clear saved data',
null, null,
+170
View File
@@ -0,0 +1,170 @@
import { authHeaders, forgetSession } from '../lib/auth.mjs';
/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */
const TEMP_NAME = 'Snapshots 50';
const RENAMED = 'Renamed by 50';
/**
* Snapshots of a project (#231): made from the menu, restored over a later stand, the replaced
* stand kept as a snapshot of its own, and deleted with the project.
*
* Works on a throwaway project of its own, because a restore reloads the project it is done on.
* The last thing it does is delete that project and open the fixture again.
*/
export async function snapshots(t) {
const { check, cdp, json, click, press, typeText, locate, sleep, waitUntil, settle } = t;
const { clickToolbar, clickText, shot, saved, baseUrl, navigate } = t;
const send = async (method, route, body) =>
fetch(`${baseUrl}api/index.php?r=${route}`, {
method,
headers: { ...(await authHeaders(baseUrl)), 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
// Step 45 changes the password of `e2e`, which ends the session the tools had cached.
const api = async (method, route, body) => {
const first = await send(method, route, body);
if (first.status !== 401) return first;
forgetSession(baseUrl);
return send(method, route, body);
};
const sweep = async () => {
for (const project of await (await api('GET', 'projects')).json()) {
if (project.name === TEMP_NAME || project.name === RENAMED)
await api('DELETE', `projects/${project.id}`);
}
};
/** The last-drawn `text` – the dialog's, over a row or a menu that says the same. */
const clickLast = async (text) => {
await settle();
const at = await locate(text, true);
await click(at.x, at.y);
};
const openSnapshots = async () => {
await clickToolbar('menu');
await clickText('Snapshots…');
await waitUntil('__gm.snapshots() !== null', { label: 'the snapshots dialog opened' });
};
const notice = () => json('__gm.state.notice');
await sweep();
const made = await (await api('POST', 'projects', { name: TEMP_NAME })).json();
const stored = async () => (await (await api('GET', `projects/${made.id}`)).json()).project;
try {
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });
await sleep(4000);
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, {
timeout: 15000,
label: 'the throwaway project is open',
});
// 50a. A new project has no snapshots; a named one is made from what is saved and listed.
await openSnapshots();
const empty = await json('__gm.snapshots()');
const field = empty?.nameAt;
await click(field.x, field.y);
await typeText('Clean start');
await press('Enter');
await waitUntil('__gm.snapshots()?.rows.length === 1', { label: 'the dialog shows the new snapshot' });
const one = await json('__gm.snapshots()');
const listed = await (await api('GET', `projects/${made.id}/snapshots`)).json();
check(
'Create snapshot copies the saved project under the name typed, and the list shows it',
empty.rows.length === 0 &&
one.rows[0]?.name === 'Clean start' &&
/^\d{4}-\d\d-\d\d \d\d:\d\d$/.test(one.rows[0]?.time ?? '') &&
listed.length === 1 &&
listed[0].revision === (await json('__gm.state.revision')) &&
(await notice()) === 'Snapshot "Clean start" created',
JSON.stringify({ empty, one, listed, notice: await notice() }),
);
await shot('50-snapshots');
await clickLast('Cancel');
// 50b. A later stand: the project renamed through the settings, and saved.
await clickToolbar('menu');
await clickText('Project settings…');
const nameField = await locate(TEMP_NAME, true);
await t.doubleClick(nameField.x, nameField.y);
await press('a', { ctrl: true });
await typeText(RENAMED);
await press('Enter');
await clickText('Apply');
await saved();
const later = {
name: await json('__gm.state.project.name'),
revision: await json('__gm.state.revision'),
};
// 50c. Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads
// the project with an empty undo history, and tells so.
await openSnapshots();
await clickLast('Clean start');
await clickLast('Restore');
await clickLast('Restore');
await waitUntil('__gm.state.notice.startsWith("Restored")', { label: 'the restore finished' });
const restored = {
name: await json('__gm.state.project.name'),
revision: await json('__gm.state.revision'),
canUndo: await json('__gm.state.commands.canUndo'),
notice: await notice(),
stored: (await stored()).name,
list: (await (await api('GET', `projects/${made.id}/snapshots`)).json()).map((s) => s.name),
};
check(
'Restore brings the snapshot back as a new revision and keeps the replaced stand as a snapshot',
later.name === RENAMED &&
restored.name === TEMP_NAME &&
restored.stored === TEMP_NAME &&
restored.revision === later.revision + 1 &&
restored.canUndo === false &&
JSON.stringify(restored.list) === JSON.stringify(['Before restoring Clean start', 'Clean start']) &&
restored.notice ===
'Restored "Clean start" – the previous stand is kept as "Before restoring Clean start"',
JSON.stringify({ later, restored }),
);
// 50d. That kept stand restores like any other: the restore is taken back.
await openSnapshots();
await clickLast('Before restoring Clean start');
await clickLast('Restore');
await clickLast('Restore');
await waitUntil('__gm.state.notice.startsWith("Restored \\"Before")', {
label: 'the second restore finished',
});
check(
'restoring the kept stand takes the restore back',
(await json('__gm.state.project.name')) === RENAMED && (await stored()).name === RENAMED,
JSON.stringify({ name: await json('__gm.state.project.name'), stored: (await stored()).name }),
);
// 50e. Delete asks, then takes the one picked out of the list.
await openSnapshots();
const before = (await json('__gm.snapshots()')).rows.map((r) => r.name);
await clickLast('Clean start');
await clickLast('Delete');
await clickLast('Delete');
await waitUntil(`__gm.snapshots()?.rows.length === ${before.length - 1}`, {
label: 'the dialog shows one snapshot fewer',
});
const after = (await json('__gm.snapshots()')).rows.map((r) => r.name);
check(
'Delete removes the snapshot picked and nothing else',
before.includes('Clean start') &&
!after.includes('Clean start') &&
after.length === before.length - 1 &&
after.every((name) => before.includes(name)),
JSON.stringify({ before, after }),
);
await clickLast('Cancel');
// 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards.
await api('DELETE', `projects/${made.id}`);
const gone = await api('GET', `projects/${made.id}/snapshots`);
check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status));
} finally {
await sweep();
await navigate();
}
}
+2
View File
@@ -52,6 +52,7 @@ import { topbar } from './46-topbar.mjs';
import { vectorArt } from './47-vectorart.mjs'; import { vectorArt } from './47-vectorart.mjs';
import { pixelArt } from './48-pixelart.mjs'; import { pixelArt } from './48-pixelart.mjs';
import { users } from './49-users.mjs'; import { users } from './49-users.mjs';
import { snapshots } from './50-snapshots.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -103,4 +104,5 @@ export const STEPS = [
vectorArt, vectorArt,
pixelArt, pixelArt,
users, users,
snapshots,
]; ];