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>
132 lines
4.8 KiB
PHP
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;
|
|
}
|
|
}
|