Files
feuerwerk/api/lib/SnapshotStore.php
T
adminandClaude Opus 5.5 94749b4f3e
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 9m38s
Let a project keep named snapshots and restore them (#231)
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>
2026-09-25 18:21:52 +02:00

132 lines
4.8 KiB
PHP

<?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;
}
}