, 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'); } AtomicFile::write($this->path($projectId, $id), $json, AtomicFile::HIDDEN, '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; } }