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

+99 -2
View File
@@ -31,7 +31,14 @@ declare(strict_types=1);
* POST ?r=projects {name} → {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}
* 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}]
* 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/Response.php';
require __DIR__ . '/lib/SessionStore.php';
require __DIR__ . '/lib/SnapshotStore.php';
require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.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);
}
$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') {
$doc = $store->load($id);
if ($doc === null) {
@@ -196,12 +212,92 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
if (!$store->delete($id)) {
Response::error('Project not found', 404);
}
$snapshots->deleteAll($id);
Response::noContent();
}
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 */
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
{
@@ -472,7 +568,8 @@ try {
$route = trim((string) ($_GET['r'] ?? ''), '/');
$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);
}
+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;
}
}