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

+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);