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.

Extract shared metadata builder, fix queue perf, add failure exit code and test

+105 -120
+1 -1
CLAUDE.md
··· 7 7 8 8 ```bash 9 9 deno task check # Type check 10 - deno task test # Run tests (57 tests) 10 + deno task test # Run tests (58 tests) 11 11 deno task lint # Lint 12 12 deno task fmt # Format 13 13 deno task fmt:check # Check formatting
+2 -1
cli/mod.ts
··· 153 153 s3ConnectionFromConfig(config), 154 154 ); 155 155 156 - await refreshMetadata(assets, manifest, s3, { 156 + const report = await refreshMetadata(assets, manifest, s3, { 157 157 concurrency: options.concurrency, 158 158 dryRun: options.dryRun ?? false, 159 159 }); 160 + if (report.failed > 0) Deno.exit(2); 160 161 } finally { 161 162 reader.close(); 162 163 }
+8 -56
cli/src/commands/backup.ts
··· 1 - import type { AlbumRef, PersonRef, PhotoAsset } from "@attic/shared"; 1 + import type { PhotoAsset } from "@attic/shared"; 2 2 import { 3 3 AssetKind, 4 + buildMetadataJson, 4 5 extensionFromUtiOrFilename, 5 6 metadataKey, 6 7 originalKey, ··· 39 40 skipped: number; 40 41 totalBytes: number; 41 42 errors: Array<{ uuid: string; message: string }>; 42 - } 43 - 44 - interface AssetMetadata { 45 - uuid: string; 46 - originalFilename: string; 47 - dateCreated: string | null; 48 - width: number; 49 - height: number; 50 - latitude: number | null; 51 - longitude: number | null; 52 - fileSize: number | null; 53 - type: string | null; 54 - favorite: boolean; 55 - title: string | null; 56 - description: string | null; 57 - albums: AlbumRef[]; 58 - keywords: string[]; 59 - people: PersonRef[]; 60 - hasEdit: boolean; 61 - editedAt: string | null; 62 - editor: string | null; 63 - s3Key: string; 64 - checksum: string; 65 - backedUpAt: string; 66 43 } 67 44 68 45 /** Run the backup pipeline: scan -> filter -> export -> upload -> manifest. */ ··· 191 168 await s3.putObject(s3Key, fileData, contentTypeFor(ext)); 192 169 193 170 // Upload metadata JSON 194 - const meta = buildMetadataJson(asset, s3Key, exported.sha256); 171 + const meta = buildMetadataJson( 172 + asset, 173 + s3Key, 174 + `sha256:${exported.sha256}`, 175 + new Date().toISOString(), 176 + ); 195 177 const metaData = new TextEncoder().encode( 196 178 JSON.stringify(meta, null, 2), 197 179 ); ··· 266 248 }; 267 249 return map[ext] ?? "application/octet-stream"; 268 250 } 269 - 270 - function buildMetadataJson( 271 - asset: PhotoAsset, 272 - s3Key: string, 273 - sha256: string, 274 - ): AssetMetadata { 275 - return { 276 - uuid: asset.uuid, 277 - originalFilename: asset.originalFilename ?? asset.filename, 278 - dateCreated: asset.dateCreated?.toISOString() ?? null, 279 - width: asset.width, 280 - height: asset.height, 281 - latitude: asset.latitude, 282 - longitude: asset.longitude, 283 - fileSize: asset.originalFileSize, 284 - type: asset.uniformTypeIdentifier, 285 - favorite: asset.favorite, 286 - title: asset.title, 287 - description: asset.description, 288 - albums: asset.albums, 289 - keywords: asset.keywords, 290 - people: asset.people, 291 - hasEdit: asset.hasEdit, 292 - editedAt: asset.editedAt?.toISOString() ?? null, 293 - editor: asset.editor, 294 - s3Key, 295 - checksum: `sha256:${sha256}`, 296 - backedUpAt: new Date().toISOString(), 297 - }; 298 - }
+24
cli/src/commands/refresh-metadata.test.ts
··· 141 141 assertEquals(report.skipped, 1); 142 142 assertEquals(s3.objects.size, 0); 143 143 }); 144 + 145 + Deno.test("refresh-metadata: records failures when S3 upload throws", async () => { 146 + const assets = [makeAsset("uuid-1")]; 147 + 148 + const manifest = makeManifest({ 149 + "uuid-1": { 150 + s3Key: "originals/2024/01/uuid-1.heic", 151 + checksum: "sha256:abc", 152 + backedUpAt: "2024-02-01T00:00:00Z", 153 + }, 154 + }); 155 + 156 + const s3 = createMockS3Provider(); 157 + s3.putObject = () => { 158 + throw new Error("S3 unavailable"); 159 + }; 160 + 161 + const report = await refreshMetadata(assets, manifest, s3); 162 + 163 + assertEquals(report.updated, 0); 164 + assertEquals(report.failed, 1); 165 + assertEquals(report.errors.length, 1); 166 + assertEquals(report.errors[0].message, "S3 unavailable"); 167 + });
+10 -62
cli/src/commands/refresh-metadata.ts
··· 1 - import type { AlbumRef, PersonRef, PhotoAsset } from "@attic/shared"; 2 - import { metadataKey } from "@attic/shared"; 1 + import type { PhotoAsset } from "@attic/shared"; 2 + import { buildMetadataJson, metadataKey } from "@attic/shared"; 3 3 import type { Manifest } from "../manifest/manifest.ts"; 4 4 import type { S3Provider } from "../storage/s3-client.ts"; 5 5 import { formatBytes } from "../format.ts"; ··· 24 24 errors: Array<{ uuid: string; message: string }>; 25 25 } 26 26 27 - interface AssetMetadata { 28 - uuid: string; 29 - originalFilename: string; 30 - dateCreated: string | null; 31 - width: number; 32 - height: number; 33 - latitude: number | null; 34 - longitude: number | null; 35 - fileSize: number | null; 36 - type: string | null; 37 - favorite: boolean; 38 - title: string | null; 39 - description: string | null; 40 - albums: AlbumRef[]; 41 - keywords: string[]; 42 - people: PersonRef[]; 43 - hasEdit: boolean; 44 - editedAt: string | null; 45 - editor: string | null; 46 - s3Key: string; 47 - checksum: string; 48 - backedUpAt: string; 49 - } 50 - 51 27 /** 52 28 * Re-upload metadata JSON for already-backed-up assets. 53 29 * Original files and manifest are left untouched. ··· 59 35 opts: Partial<RefreshMetadataOptions> = {}, 60 36 ): Promise<RefreshMetadataReport> { 61 37 const options = { ...DEFAULT_OPTIONS, ...opts }; 38 + options.concurrency = Math.max(1, options.concurrency); 62 39 63 40 // Only refresh assets that are in the manifest 64 41 const assetByUuid = new Map<string, PhotoAsset>(); ··· 112 89 errors: [], 113 90 }; 114 91 115 - // Process with bounded concurrency 116 - const queue = [...toRefresh]; 92 + // Process with bounded concurrency using an index counter (O(1) per item). 93 + // Mutations to `report` are safe: Deno is single-threaded, and all 94 + // increments happen synchronously between await points. 95 + let cursor = 0; 117 96 const workers = Array.from( 118 - { length: Math.min(options.concurrency, queue.length) }, 97 + { length: Math.min(options.concurrency, toRefresh.length) }, 119 98 async () => { 120 - while (queue.length > 0) { 121 - const item = queue.shift()!; 99 + while (cursor < toRefresh.length) { 100 + const item = toRefresh[cursor++]; 122 101 try { 123 102 const meta = buildMetadataJson( 124 103 item.asset, ··· 163 142 164 143 return report; 165 144 } 166 - 167 - function buildMetadataJson( 168 - asset: PhotoAsset, 169 - s3Key: string, 170 - checksum: string, 171 - backedUpAt: string, 172 - ): AssetMetadata { 173 - return { 174 - uuid: asset.uuid, 175 - originalFilename: asset.originalFilename ?? asset.filename, 176 - dateCreated: asset.dateCreated?.toISOString() ?? null, 177 - width: asset.width, 178 - height: asset.height, 179 - latitude: asset.latitude, 180 - longitude: asset.longitude, 181 - fileSize: asset.originalFileSize, 182 - type: asset.uniformTypeIdentifier, 183 - favorite: asset.favorite, 184 - title: asset.title, 185 - description: asset.description, 186 - albums: asset.albums, 187 - keywords: asset.keywords, 188 - people: asset.people, 189 - hasEdit: asset.hasEdit, 190 - editedAt: asset.editedAt?.toISOString() ?? null, 191 - editor: asset.editor, 192 - s3Key, 193 - checksum, 194 - backedUpAt, 195 - }; 196 - }
+58
shared/metadata.ts
··· 1 + import type { AlbumRef, PersonRef, PhotoAsset } from "./types.ts"; 2 + 3 + /** Per-asset metadata JSON uploaded to S3 at metadata/assets/{uuid}.json. */ 4 + export interface AssetMetadata { 5 + uuid: string; 6 + originalFilename: string; 7 + dateCreated: string | null; 8 + width: number; 9 + height: number; 10 + latitude: number | null; 11 + longitude: number | null; 12 + fileSize: number | null; 13 + type: string | null; 14 + favorite: boolean; 15 + title: string | null; 16 + description: string | null; 17 + albums: AlbumRef[]; 18 + keywords: string[]; 19 + people: PersonRef[]; 20 + hasEdit: boolean; 21 + editedAt: string | null; 22 + editor: string | null; 23 + s3Key: string; 24 + checksum: string; 25 + backedUpAt: string; 26 + } 27 + 28 + /** Build a metadata JSON object for upload to S3. */ 29 + export function buildMetadataJson( 30 + asset: PhotoAsset, 31 + s3Key: string, 32 + checksum: string, 33 + backedUpAt: string, 34 + ): AssetMetadata { 35 + return { 36 + uuid: asset.uuid, 37 + originalFilename: asset.originalFilename ?? asset.filename, 38 + dateCreated: asset.dateCreated?.toISOString() ?? null, 39 + width: asset.width, 40 + height: asset.height, 41 + latitude: asset.latitude, 42 + longitude: asset.longitude, 43 + fileSize: asset.originalFileSize, 44 + type: asset.uniformTypeIdentifier, 45 + favorite: asset.favorite, 46 + title: asset.title, 47 + description: asset.description, 48 + albums: asset.albums, 49 + keywords: asset.keywords, 50 + people: asset.people, 51 + hasEdit: asset.hasEdit, 52 + editedAt: asset.editedAt?.toISOString() ?? null, 53 + editor: asset.editor, 54 + s3Key, 55 + checksum, 56 + backedUpAt, 57 + }; 58 + }
+2
shared/mod.ts
··· 11 11 metadataKey, 12 12 originalKey, 13 13 } from "./s3-paths.ts"; 14 + export type { AssetMetadata } from "./metadata.ts"; 15 + export { buildMetadataJson } from "./metadata.ts";