Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix review findings: tighten error matching, remove dead code

- Tighten isNotFoundError to match on error.name only (NoSuchKey,
NotFound) instead of loose string matching on error messages
- Update mock S3 to throw with name="NoSuchKey" matching real SDK
- Remove dead createManifestStore (local file store) — migration
reads files directly, never used this function
- Add best-effort manifest save on first SIGINT
- Add "S3 takes precedence over local" migration test

+73 -124
+1
cli/src/commands/backup.ts
··· 155 155 if (interruptCount === 1) { 156 156 // First Ctrl+C: graceful — cancel in-flight operations, save manifest 157 157 abortController.abort(); 158 + manifestStore.save(manifest).catch(() => {}); 158 159 } else { 159 160 // Second Ctrl+C: force exit. Manifest was last saved to S3 160 161 // at most saveInterval assets ago. Any unsaved progress will
+66 -79
cli/src/manifest/manifest.test.ts
··· 1 1 import { assertEquals, assertRejects } from "@std/assert"; 2 2 import type { Manifest } from "./manifest.ts"; 3 3 import { 4 - createManifestStore, 5 4 createS3ManifestStore, 6 5 isBackedUp, 7 6 loadManifestWithMigration, ··· 9 8 } from "./manifest.ts"; 10 9 import { createMockS3Provider } from "../storage/s3-client.mock.ts"; 11 10 12 - // --- Local manifest store (legacy, kept for migration) --- 11 + // --- Core functions --- 13 12 14 - Deno.test("local store: load returns empty manifest when file missing", async () => { 15 - const dir = await Deno.makeTempDir(); 16 - try { 17 - const store = createManifestStore(dir); 18 - const manifest = await store.load(); 19 - assertEquals(manifest.entries, {}); 20 - } finally { 21 - await Deno.remove(dir, { recursive: true }); 22 - } 23 - }); 13 + Deno.test("isBackedUp checks correctly", () => { 14 + const manifest: Manifest = { entries: {} }; 24 15 25 - Deno.test("local store: save and load round-trip", async () => { 26 - const dir = await Deno.makeTempDir(); 27 - try { 28 - const store = createManifestStore(dir); 29 - const manifest = await store.load(); 30 - markBackedUp( 31 - manifest, 32 - "uuid-1", 33 - "sha256:abc", 34 - "originals/2024/01/uuid-1.heic", 35 - ); 36 - await store.save(manifest); 16 + assertEquals(isBackedUp(manifest, "uuid-1"), false); 37 17 38 - const loaded = await store.load(); 39 - assertEquals( 40 - loaded.entries["uuid-1"].s3Key, 41 - "originals/2024/01/uuid-1.heic", 42 - ); 43 - assertEquals(loaded.entries["uuid-1"].checksum, "sha256:abc"); 44 - assertEquals(loaded.entries["uuid-1"].uuid, "uuid-1"); 45 - } finally { 46 - await Deno.remove(dir, { recursive: true }); 47 - } 48 - }); 18 + markBackedUp( 19 + manifest, 20 + "uuid-1", 21 + "sha256:abc", 22 + "originals/2024/01/uuid-1.heic", 23 + ); 49 24 50 - Deno.test("isBackedUp checks correctly", async () => { 51 - const dir = await Deno.makeTempDir(); 52 - try { 53 - const store = createManifestStore(dir); 54 - const manifest = await store.load(); 55 - 56 - assertEquals(isBackedUp(manifest, "uuid-1"), false); 57 - 58 - markBackedUp( 59 - manifest, 60 - "uuid-1", 61 - "sha256:abc", 62 - "originals/2024/01/uuid-1.heic", 63 - ); 64 - 65 - assertEquals(isBackedUp(manifest, "uuid-1"), true); 66 - assertEquals(isBackedUp(manifest, "uuid-2"), false); 67 - } finally { 68 - await Deno.remove(dir, { recursive: true }); 69 - } 70 - }); 71 - 72 - Deno.test("local store: load rejects invalid manifest JSON", async () => { 73 - const dir = await Deno.makeTempDir(); 74 - try { 75 - await Deno.writeTextFile(`${dir}/manifest.json`, '{"bad": true}'); 76 - const store = createManifestStore(dir); 77 - await assertRejects( 78 - () => store.load(), 79 - Error, 80 - "missing or invalid 'entries'", 81 - ); 82 - } finally { 83 - await Deno.remove(dir, { recursive: true }); 84 - } 25 + assertEquals(isBackedUp(manifest, "uuid-1"), true); 26 + assertEquals(isBackedUp(manifest, "uuid-2"), false); 85 27 }); 86 28 87 29 // --- S3 manifest store --- ··· 152 94 Deno.test("migration: migrates local manifest to S3", async () => { 153 95 const dir = await Deno.makeTempDir(); 154 96 try { 155 - // Create a local manifest 156 - const localStore = createManifestStore(dir); 157 - const localManifest = { entries: {} } as Manifest; 158 - markBackedUp( 159 - localManifest, 160 - "local-uuid", 161 - "sha256:local", 162 - "originals/2024/01/local.heic", 97 + // Write a local manifest file directly 98 + const localManifest = { 99 + entries: { 100 + "local-uuid": { 101 + uuid: "local-uuid", 102 + s3Key: "originals/2024/01/local.heic", 103 + checksum: "sha256:local", 104 + backedUpAt: "2024-01-15T00:00:00Z", 105 + }, 106 + }, 107 + }; 108 + await Deno.writeTextFile( 109 + `${dir}/manifest.json`, 110 + JSON.stringify(localManifest, null, 2), 163 111 ); 164 - await localStore.save(localManifest); 165 112 166 113 // S3 is empty 167 114 const s3 = createMockS3Provider(); ··· 184 131 const manifest = await loadManifestWithMigration(store, "/nonexistent"); 185 132 assertEquals(Object.keys(manifest.entries).length, 0); 186 133 }); 134 + 135 + Deno.test("migration: S3 takes precedence over local", async () => { 136 + const dir = await Deno.makeTempDir(); 137 + try { 138 + // Local manifest has one entry 139 + const localManifest = { 140 + entries: { 141 + "local-uuid": { 142 + uuid: "local-uuid", 143 + s3Key: "originals/2024/01/local.heic", 144 + checksum: "sha256:local", 145 + backedUpAt: "2024-01-15T00:00:00Z", 146 + }, 147 + }, 148 + }; 149 + await Deno.writeTextFile( 150 + `${dir}/manifest.json`, 151 + JSON.stringify(localManifest, null, 2), 152 + ); 153 + 154 + // S3 has a different entry 155 + const s3 = createMockS3Provider(); 156 + const s3Store = createS3ManifestStore(s3); 157 + const s3Manifest = { entries: {} } as Manifest; 158 + markBackedUp( 159 + s3Manifest, 160 + "s3-uuid", 161 + "sha256:s3", 162 + "originals/2024/01/s3.heic", 163 + ); 164 + await s3Store.save(s3Manifest); 165 + 166 + // S3 should win — local is not consulted 167 + const manifest = await loadManifestWithMigration(s3Store, dir); 168 + assertEquals(isBackedUp(manifest, "s3-uuid"), true); 169 + assertEquals(isBackedUp(manifest, "local-uuid"), false); 170 + } finally { 171 + await Deno.remove(dir, { recursive: true }); 172 + } 173 + });
+1 -44
cli/src/manifest/manifest.ts
··· 85 85 86 86 function isNotFoundError(error: unknown): boolean { 87 87 if (!(error instanceof Error)) return false; 88 - const msg = error.message.toLowerCase(); 89 - return msg.includes("not found") || msg.includes("nosuchkey") || 90 - error.name === "NoSuchKey"; 88 + return error.name === "NoSuchKey" || error.name === "NotFound"; 91 89 } 92 90 93 91 /** Load manifest from S3, migrating from local file if needed. ··· 134 132 135 133 return s3Manifest; 136 134 } 137 - 138 - // --- Local file store (kept for migration only) --- 139 - 140 - const DEFAULT_DIR = join( 141 - Deno.env.get("HOME") ?? "~", 142 - ".attic", 143 - ); 144 - 145 - /** Create a local file-based manifest store. 146 - * Used only for migration from local to S3. */ 147 - export function createManifestStore( 148 - dir: string = DEFAULT_DIR, 149 - ): ManifestStore { 150 - const filePath = join(dir, "manifest.json"); 151 - 152 - return { 153 - async load(): Promise<Manifest> { 154 - try { 155 - const text = await Deno.readTextFile(filePath); 156 - const data: unknown = JSON.parse(text); 157 - assertManifest(data); 158 - return data; 159 - } catch (error: unknown) { 160 - if (error instanceof Deno.errors.NotFound) { 161 - return { entries: {} }; 162 - } 163 - throw error; 164 - } 165 - }, 166 - 167 - async save(manifest: Manifest): Promise<void> { 168 - await Deno.mkdir(dir, { recursive: true }); 169 - const tmpPath = filePath + ".tmp"; 170 - await Deno.writeTextFile( 171 - tmpPath, 172 - JSON.stringify(manifest, null, 2) + "\n", 173 - ); 174 - await Deno.rename(tmpPath, filePath); 175 - }, 176 - }; 177 - }
+5 -1
cli/src/storage/s3-client.mock.ts
··· 23 23 24 24 getObject(key: string): Promise<Uint8Array> { 25 25 const obj = objects.get(key); 26 - if (!obj) throw new Error(`Object not found: ${key}`); 26 + if (!obj) { 27 + const err = new Error(`NoSuchKey: ${key}`); 28 + err.name = "NoSuchKey"; 29 + throw err; 30 + } 27 31 return Promise.resolve(obj.body); 28 32 }, 29 33