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

+94 -1
View File
@@ -7,17 +7,21 @@ import {
SetPlayFromCommand,
SetSymbolScaleCommand,
} 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 { dialogSlot } from './FormDialog';
import { OpenProjectDialog } from './OpenProjectDialog';
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
import { clearSaved, hasSaved } from './saves';
import { snapshotBlocker, snapshotTime } from './snapshots';
import { SnapshotsDialog } from './SnapshotsDialog';
import type { EditorState } from './state';
export class ProjectSession {
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
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
@@ -27,6 +31,8 @@ export class ProjectSession {
private readonly state: EditorState,
private readonly store: ProjectStore,
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
* 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: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true },
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more,
@@ -141,6 +148,92 @@ export class ProjectSession {
]).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
* (#183). Each is an undo step of its own – one each, and only for what actually changed.