Pin a restore point and keep a note on it

Both live in the catalog on the location, so a new server sees them; the database holds
a local copy for the lists. The retention policy leaves a pinned restore point alone and
does not count it. Available on the restore page and as occ backup:pin and backup:note.
This commit is contained in:
2026-11-06 23:49:50 +01:00
parent 6540a9314a
commit 193f65940b
23 changed files with 464 additions and 9 deletions
+10 -1
View File
@@ -30,6 +30,10 @@ final class Catalog {
public array $trash = [];
/** @var array<string, true> manifest path => true, for a per-user export (users/<uid>/<exportId>) */
public array $userExports = [];
/** @var array<string, true> snapshots the retention policy leaves alone */
public array $pinned = [];
/** @var array<string, string> snapshot id => note, also while the snapshot is in the trash */
public array $notes = [];
public string $hash = '';
public function __construct(
@@ -82,6 +86,8 @@ final class Catalog {
// Catalogs written before user exports existed have no such key: default to empty rather
// than adopting the location's current users/ listing (that would skip verification).
$this->userExports = array_fill_keys($data['userExports'] ?? [], true);
$this->pinned = array_fill_keys($data['pinned'] ?? [], true);
$this->notes = $data['notes'] ?? [];
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
}
@@ -93,9 +99,12 @@ final class Catalog {
if ($this->backend->exists($this->path($next))) {
throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again");
}
// A pin ends when its snapshot leaves the list, a note when the snapshot is gone for good.
$this->pinned = array_intersect_key($this->pinned, $this->snapshots);
$this->notes = array_intersect_key($this->notes, $this->snapshots + $this->trash);
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR);
'userExports' => array_keys($this->userExports), 'pinned' => array_keys($this->pinned), 'notes' => $this->notes], JSON_THROW_ON_ERROR);
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
$stream = fopen('php://memory', 'w+b');
fwrite($stream, $encrypted);
+43
View File
@@ -134,6 +134,49 @@ final class Repository {
return $this->catalog->gen;
}
/** @return list<string> snapshots the retention policy leaves alone */
public function pinnedSnapshots(): array {
return array_map('strval', array_keys($this->catalog->pinned));
}
/** @return array<string, string> snapshot id => note */
public function snapshotNotes(): array {
return $this->catalog->notes;
}
public function setPinned(string $snapshotId, bool $pinned): void {
$this->requireSnapshot($snapshotId);
if (isset($this->catalog->pinned[$snapshotId]) === $pinned) {
return;
}
if ($pinned) {
$this->catalog->pinned[$snapshotId] = true;
} else {
unset($this->catalog->pinned[$snapshotId]);
}
$this->catalog->write();
}
/** A note kept in the catalog: unlike the label inside the snapshot, it can change afterwards. */
public function setSnapshotNote(string $snapshotId, string $note): void {
$this->requireSnapshot($snapshotId);
if (($this->catalog->notes[$snapshotId] ?? '') === $note) {
return;
}
if ($note === '') {
unset($this->catalog->notes[$snapshotId]);
} else {
$this->catalog->notes[$snapshotId] = $note;
}
$this->catalog->write();
}
private function requireSnapshot(string $snapshotId): void {
if (!isset($this->catalog->snapshots[$snapshotId])) {
throw new \InvalidArgumentException('There is no snapshot ' . $snapshotId . ' on this location');
}
}
/** Index files downloaded when this repository was opened (0 when the local cache was complete). */
public function indexDownloads(): int {
return $this->index->downloaded;