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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user