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>
This commit is contained in:
1 parent
17a6fd0339
commit
94749b4f3e
18 files changed
+849
-7
No files matched your search
+99
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user