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.

Enrich backup metadata with albums, descriptions, keywords, and people

Add four enrichment queries to Photos.sqlite reader that build lookup
maps for descriptions, albums, keywords, and identified people. Merge
into PhotoAsset during row mapping with graceful degradation when
enrichment tables are missing (schema resilience across macOS versions).

Also adds runtime validation for asset kind and cloud state values,
debug logging for unexpected query failures, and O(1) Set-based
deduplication for people data.

+303 -13
+5
cli/src/commands/backup.test.ts
··· 26 26 cloudLocalState: CloudLocalState.LOCAL, 27 27 originalFileSize: 3000, 28 28 originalStableHash: "abc123", 29 + title: null, 30 + description: null, 31 + albums: [], 32 + keywords: [], 33 + people: [], 29 34 ...overrides, 30 35 }; 31 36 }
+11 -1
cli/src/commands/backup.ts
··· 1 - import type { PhotoAsset } from "@attic/shared"; 1 + import type { AlbumRef, PersonRef, PhotoAsset } from "@attic/shared"; 2 2 import { 3 3 AssetKind, 4 4 extensionFromUtiOrFilename, ··· 52 52 fileSize: number | null; 53 53 type: string | null; 54 54 favorite: boolean; 55 + title: string | null; 56 + description: string | null; 57 + albums: AlbumRef[]; 58 + keywords: string[]; 59 + people: PersonRef[]; 55 60 s3Key: string; 56 61 checksum: string; 57 62 backedUpAt: string; ··· 275 280 fileSize: asset.originalFileSize, 276 281 type: asset.uniformTypeIdentifier, 277 282 favorite: asset.favorite, 283 + title: asset.title, 284 + description: asset.description, 285 + albums: asset.albums, 286 + keywords: asset.keywords, 287 + people: asset.people, 278 288 s3Key, 279 289 checksum: `sha256:${sha256}`, 280 290 backedUpAt: new Date().toISOString(),
+126 -7
cli/src/photos-db/reader.test.ts
··· 3 3 import { coreDataTimestampToDate, openPhotosDb } from "./reader.ts"; 4 4 import { AssetKind, CloudLocalState } from "@attic/shared"; 5 5 6 - function createTestDb(): string { 6 + function createTestDb(opts?: { withEnrichment?: boolean }): string { 7 7 const path = Deno.makeTempFileSync({ suffix: ".sqlite" }); 8 8 const db = new Database(path); 9 9 ··· 30 30 ZASSET INTEGER, 31 31 ZORIGINALFILESIZE INTEGER, 32 32 ZORIGINALFILENAME TEXT, 33 - ZORIGINALSTABLEHASH TEXT 33 + ZORIGINALSTABLEHASH TEXT, 34 + ZTITLE TEXT 34 35 ); 35 36 `); 36 37 ··· 52 53 (3, 'uuid-trashed', 'IMG_0003.HEIC', '/some/dir', ${coreDataTs}, ${AssetKind.PHOTO}, 53 54 'public.heic', 4032, 3024, NULL, NULL, 0, ${CloudLocalState.LOCAL}, 1); 54 55 55 - INSERT INTO ZADDITIONALASSETATTRIBUTES (Z_PK, ZASSET, ZORIGINALFILESIZE, ZORIGINALFILENAME, ZORIGINALSTABLEHASH) 56 + INSERT INTO ZADDITIONALASSETATTRIBUTES (Z_PK, ZASSET, ZORIGINALFILESIZE, ZORIGINALFILENAME, ZORIGINALSTABLEHASH, ZTITLE) 56 57 VALUES 57 - (1, 1, 3158112, 'IMG_0001.HEIC', 'abc123'), 58 - (2, 2, 52428800, 'IMG_0002.MOV', 'def456'), 59 - (3, 3, 1000000, 'IMG_0003.HEIC', 'ghi789'); 58 + (1, 1, 3158112, 'IMG_0001.HEIC', 'abc123', 'Sunset at the beach'), 59 + (2, 2, 52428800, 'IMG_0002.MOV', 'def456', NULL), 60 + (3, 3, 1000000, 'IMG_0003.HEIC', 'ghi789', NULL); 60 61 `); 61 62 63 + if (opts?.withEnrichment !== false) { 64 + db.exec(` 65 + CREATE TABLE ZASSETDESCRIPTION ( 66 + Z_PK INTEGER PRIMARY KEY, 67 + ZASSETATTRIBUTES INTEGER, 68 + ZLONGDESCRIPTION TEXT 69 + ); 70 + 71 + CREATE TABLE ZGENERICALBUM ( 72 + Z_PK INTEGER PRIMARY KEY, 73 + ZUUID TEXT, 74 + ZTITLE TEXT 75 + ); 76 + 77 + CREATE TABLE Z_33ASSETS ( 78 + Z_3ASSETS INTEGER, 79 + Z_33ALBUMS INTEGER 80 + ); 81 + 82 + CREATE TABLE ZKEYWORD ( 83 + Z_PK INTEGER PRIMARY KEY, 84 + ZTITLE TEXT 85 + ); 86 + 87 + CREATE TABLE Z_1KEYWORDS ( 88 + Z_1ASSETATTRIBUTES INTEGER, 89 + Z_52KEYWORDS INTEGER 90 + ); 91 + 92 + CREATE TABLE ZPERSON ( 93 + Z_PK INTEGER PRIMARY KEY, 94 + ZPERSONUUID TEXT, 95 + ZDISPLAYNAME TEXT 96 + ); 97 + 98 + CREATE TABLE ZDETECTEDFACE ( 99 + Z_PK INTEGER PRIMARY KEY, 100 + ZASSETFORFACE INTEGER, 101 + ZPERSONFORFACE INTEGER, 102 + ZHIDDEN INTEGER DEFAULT 0, 103 + ZASSETVISIBLE INTEGER DEFAULT 1 104 + ); 105 + `); 106 + 107 + if (opts?.withEnrichment) { 108 + // Description for photo asset (aa.Z_PK = 1, aa.ZASSET = 1) 109 + db.exec(` 110 + INSERT INTO ZASSETDESCRIPTION (Z_PK, ZASSETATTRIBUTES, ZLONGDESCRIPTION) 111 + VALUES (1, 1, 'A beautiful sunset over the ocean'); 112 + `); 113 + 114 + // Albums 115 + db.exec(` 116 + INSERT INTO ZGENERICALBUM (Z_PK, ZUUID, ZTITLE) 117 + VALUES (1, 'album-uuid-1', 'Vacation 2024'), 118 + (2, 'album-uuid-2', 'Favorites'); 119 + 120 + INSERT INTO Z_33ASSETS (Z_3ASSETS, Z_33ALBUMS) 121 + VALUES (1, 1), (1, 2); 122 + `); 123 + 124 + // Keywords 125 + db.exec(` 126 + INSERT INTO ZKEYWORD (Z_PK, ZTITLE) 127 + VALUES (1, 'sunset'), (2, 'ocean'); 128 + 129 + INSERT INTO Z_1KEYWORDS (Z_1ASSETATTRIBUTES, Z_52KEYWORDS) 130 + VALUES (1, 1), (1, 2); 131 + `); 132 + 133 + // People 134 + db.exec(` 135 + INSERT INTO ZPERSON (Z_PK, ZPERSONUUID, ZDISPLAYNAME) 136 + VALUES (1, 'person-uuid-1', 'Alice'); 137 + 138 + INSERT INTO ZDETECTEDFACE (Z_PK, ZASSETFORFACE, ZPERSONFORFACE, ZHIDDEN, ZASSETVISIBLE) 139 + VALUES (1, 1, 1, 0, 1); 140 + `); 141 + } 142 + } 143 + 62 144 db.close(); 63 145 return path; 64 146 } ··· 74 156 }); 75 157 76 158 Deno.test("readAssets returns non-trashed assets with correct fields", () => { 77 - const dbPath = createTestDb(); 159 + const dbPath = createTestDb({ withEnrichment: true }); 78 160 try { 79 161 const reader = openPhotosDb(dbPath); 80 162 const assets = reader.readAssets(); ··· 100 182 "2024-01-15T12:00:00.000Z", 101 183 ); 102 184 185 + // Enrichment fields 186 + assertEquals(photo.title, "Sunset at the beach"); 187 + assertEquals(photo.description, "A beautiful sunset over the ocean"); 188 + assertEquals(photo.albums.length, 2); 189 + assertEquals(photo.albums[0].title, "Vacation 2024"); 190 + assertEquals(photo.albums[1].title, "Favorites"); 191 + assertEquals(photo.keywords, ["sunset", "ocean"]); 192 + assertEquals(photo.people.length, 1); 193 + assertEquals(photo.people[0].displayName, "Alice"); 194 + 103 195 const video = assets.find((a) => a.uuid === "uuid-video-1")!; 104 196 assertEquals(video.kind, AssetKind.VIDEO); 105 197 assertEquals(video.latitude, null); ··· 107 199 assertEquals(video.favorite, false); 108 200 assertEquals(video.cloudLocalState, CloudLocalState.ICLOUD_ONLY); 109 201 assertEquals(video.originalFileSize, 52428800); 202 + 203 + // Video has no enrichment data 204 + assertEquals(video.title, null); 205 + assertEquals(video.description, null); 206 + assertEquals(video.albums, []); 207 + assertEquals(video.keywords, []); 208 + assertEquals(video.people, []); 209 + } finally { 210 + Deno.removeSync(dbPath); 211 + } 212 + }); 213 + 214 + Deno.test("readAssets works without enrichment tables (schema resilience)", () => { 215 + const dbPath = createTestDb({ withEnrichment: false }); 216 + try { 217 + const reader = openPhotosDb(dbPath); 218 + const assets = reader.readAssets(); 219 + reader.close(); 220 + 221 + assertEquals(assets.length, 2, "should return both non-trashed assets"); 222 + 223 + const photo = assets.find((a) => a.uuid === "uuid-photo-1")!; 224 + assertEquals(photo.title, "Sunset at the beach"); 225 + assertEquals(photo.description, null); 226 + assertEquals(photo.albums, []); 227 + assertEquals(photo.keywords, []); 228 + assertEquals(photo.people, []); 110 229 } finally { 111 230 Deno.removeSync(dbPath); 112 231 }
+144 -5
cli/src/photos-db/reader.ts
··· 1 1 import { Database } from "@db/sqlite"; 2 2 import type { 3 + AlbumRef, 3 4 AssetKindValue, 4 5 CloudLocalStateValue, 6 + PersonRef, 5 7 PhotoAsset, 6 8 } from "@attic/shared"; 9 + import { AssetKind, CloudLocalState } from "@attic/shared"; 7 10 8 11 const DEFAULT_DB_PATH = `${ 9 12 Deno.env.get("HOME") ··· 11 14 12 15 const ASSETS_QUERY = ` 13 16 SELECT 17 + a.Z_PK, 14 18 a.ZUUID, 15 19 a.ZFILENAME, 16 20 a.ZDIRECTORY, ··· 25 29 a.ZCLOUDLOCALSTATE, 26 30 aa.ZORIGINALFILESIZE, 27 31 aa.ZORIGINALFILENAME, 28 - aa.ZORIGINALSTABLEHASH 32 + aa.ZORIGINALSTABLEHASH, 33 + aa.ZTITLE 29 34 FROM ZASSET a 30 35 JOIN ZADDITIONALASSETATTRIBUTES aa ON aa.ZASSET = a.Z_PK 31 36 WHERE a.ZTRASHEDSTATE = 0 ··· 43 48 } 44 49 45 50 interface RawRow { 51 + Z_PK: number; 46 52 ZUUID: string; 47 53 ZFILENAME: string | null; 48 54 ZDIRECTORY: string | null; ··· 58 64 ZORIGINALFILESIZE: number | null; 59 65 ZORIGINALFILENAME: string | null; 60 66 ZORIGINALSTABLEHASH: string | null; 67 + ZTITLE: string | null; 61 68 } 62 69 63 70 const REQUIRED_COLUMNS = [ ··· 80 87 } 81 88 } 82 89 83 - function rowToAsset(row: RawRow): PhotoAsset { 90 + const VALID_ASSET_KINDS = new Set<number>(Object.values(AssetKind)); 91 + const VALID_CLOUD_STATES = new Set<number>(Object.values(CloudLocalState)); 92 + 93 + function assertAssetKind(value: number): AssetKindValue { 94 + if (!VALID_ASSET_KINDS.has(value)) { 95 + throw new Error(`Unknown asset kind: ${value}`); 96 + } 97 + return value as AssetKindValue; 98 + } 99 + 100 + function assertCloudLocalState(value: number): CloudLocalStateValue { 101 + if (!VALID_CLOUD_STATES.has(value)) { 102 + throw new Error(`Unknown cloud local state: ${value}`); 103 + } 104 + return value as CloudLocalStateValue; 105 + } 106 + 107 + interface EnrichmentMaps { 108 + descriptions: Map<number, string>; 109 + albums: Map<number, AlbumRef[]>; 110 + keywords: Map<number, string[]>; 111 + people: Map<number, PersonRef[]>; 112 + } 113 + 114 + function rowToAsset(row: RawRow, enrichment: EnrichmentMaps): PhotoAsset { 115 + const pk = row.Z_PK; 84 116 return { 85 117 uuid: row.ZUUID, 86 118 filename: row.ZFILENAME ?? "", 87 119 directory: row.ZDIRECTORY, 88 120 dateCreated: coreDataTimestampToDate(row.ZDATECREATED), 89 - kind: row.ZKIND as AssetKindValue, 121 + kind: assertAssetKind(row.ZKIND), 90 122 uniformTypeIdentifier: row.ZUNIFORMTYPEIDENTIFIER, 91 123 width: row.ZWIDTH, 92 124 height: row.ZHEIGHT, 93 125 latitude: row.ZLATITUDE, 94 126 longitude: row.ZLONGITUDE, 95 127 favorite: row.ZFAVORITE === 1, 96 - cloudLocalState: row.ZCLOUDLOCALSTATE as CloudLocalStateValue, 128 + cloudLocalState: assertCloudLocalState(row.ZCLOUDLOCALSTATE), 97 129 originalFileSize: row.ZORIGINALFILESIZE, 98 130 originalFilename: row.ZORIGINALFILENAME, 99 131 originalStableHash: row.ZORIGINALSTABLEHASH, 132 + title: row.ZTITLE ?? null, 133 + description: enrichment.descriptions.get(pk) ?? null, 134 + albums: enrichment.albums.get(pk) ?? [], 135 + keywords: enrichment.keywords.get(pk) ?? [], 136 + people: enrichment.people.get(pk) ?? [], 100 137 }; 101 138 } 102 139 140 + function safeQuery<T>(db: Database, sql: string, label: string): T[] { 141 + try { 142 + return db.prepare(sql).all() as unknown as T[]; 143 + } catch (err: unknown) { 144 + const msg = err instanceof Error ? err.message : String(err); 145 + if (!msg.includes("no such table")) { 146 + console.error(`Enrichment query failed (${label}): ${msg}`); 147 + } 148 + return []; 149 + } 150 + } 151 + 152 + function buildDescriptionMap(db: Database): Map<number, string> { 153 + const rows = safeQuery<{ ZASSET: number; ZLONGDESCRIPTION: string }>( 154 + db, 155 + `SELECT aa.ZASSET, d.ZLONGDESCRIPTION 156 + FROM ZASSETDESCRIPTION d 157 + JOIN ZADDITIONALASSETATTRIBUTES aa ON d.ZASSETATTRIBUTES = aa.Z_PK 158 + WHERE d.ZLONGDESCRIPTION IS NOT NULL AND d.ZLONGDESCRIPTION != ''`, 159 + "descriptions", 160 + ); 161 + const map = new Map<number, string>(); 162 + for (const r of rows) map.set(r.ZASSET, r.ZLONGDESCRIPTION); 163 + return map; 164 + } 165 + 166 + function buildAlbumMap(db: Database): Map<number, AlbumRef[]> { 167 + const rows = safeQuery< 168 + { Z_3ASSETS: number; ZUUID: string; ZTITLE: string } 169 + >( 170 + db, 171 + `SELECT ja.Z_3ASSETS, g.ZUUID, g.ZTITLE 172 + FROM Z_33ASSETS ja 173 + JOIN ZGENERICALBUM g ON ja.Z_33ALBUMS = g.Z_PK 174 + WHERE g.ZTITLE IS NOT NULL`, 175 + "albums", 176 + ); 177 + const map = new Map<number, AlbumRef[]>(); 178 + for (const r of rows) { 179 + const list = map.get(r.Z_3ASSETS) ?? []; 180 + list.push({ uuid: r.ZUUID, title: r.ZTITLE }); 181 + map.set(r.Z_3ASSETS, list); 182 + } 183 + return map; 184 + } 185 + 186 + function buildKeywordMap(db: Database): Map<number, string[]> { 187 + const rows = safeQuery<{ ZASSET: number; ZTITLE: string }>( 188 + db, 189 + `SELECT aa.ZASSET, k.ZTITLE 190 + FROM Z_1KEYWORDS jk 191 + JOIN ZKEYWORD k ON jk.Z_52KEYWORDS = k.Z_PK 192 + JOIN ZADDITIONALASSETATTRIBUTES aa ON jk.Z_1ASSETATTRIBUTES = aa.Z_PK 193 + WHERE k.ZTITLE IS NOT NULL`, 194 + "keywords", 195 + ); 196 + const map = new Map<number, string[]>(); 197 + for (const r of rows) { 198 + const list = map.get(r.ZASSET) ?? []; 199 + list.push(r.ZTITLE); 200 + map.set(r.ZASSET, list); 201 + } 202 + return map; 203 + } 204 + 205 + function buildPeopleMap(db: Database): Map<number, PersonRef[]> { 206 + const rows = safeQuery< 207 + { ZASSETFORFACE: number; ZPERSONUUID: string; ZDISPLAYNAME: string } 208 + >( 209 + db, 210 + `SELECT df.ZASSETFORFACE, p.ZPERSONUUID, p.ZDISPLAYNAME 211 + FROM ZDETECTEDFACE df 212 + JOIN ZPERSON p ON df.ZPERSONFORFACE = p.Z_PK 213 + WHERE p.ZDISPLAYNAME IS NOT NULL AND p.ZDISPLAYNAME != '' 214 + AND df.ZHIDDEN = 0 AND df.ZASSETVISIBLE = 1`, 215 + "people", 216 + ); 217 + const map = new Map<number, PersonRef[]>(); 218 + const seen = new Map<number, Set<string>>(); 219 + for (const r of rows) { 220 + const assetSeen = seen.get(r.ZASSETFORFACE) ?? new Set(); 221 + if (!assetSeen.has(r.ZPERSONUUID)) { 222 + assetSeen.add(r.ZPERSONUUID); 223 + seen.set(r.ZASSETFORFACE, assetSeen); 224 + const list = map.get(r.ZASSETFORFACE) ?? []; 225 + list.push({ uuid: r.ZPERSONUUID, displayName: r.ZDISPLAYNAME }); 226 + map.set(r.ZASSETFORFACE, list); 227 + } 228 + } 229 + return map; 230 + } 231 + 103 232 export interface PhotosDbReader { 104 233 readAssets(): PhotoAsset[]; 105 234 close(): void; ··· 119 248 if (rows.length > 0) { 120 249 assertValidRow(rows[0]); 121 250 } 122 - return (rows as unknown as RawRow[]).map(rowToAsset); 251 + 252 + const enrichment: EnrichmentMaps = { 253 + descriptions: buildDescriptionMap(db), 254 + albums: buildAlbumMap(db), 255 + keywords: buildKeywordMap(db), 256 + people: buildPeopleMap(db), 257 + }; 258 + 259 + return (rows as unknown as RawRow[]).map((row) => 260 + rowToAsset(row, enrichment) 261 + ); 123 262 }, 124 263 close() { 125 264 db.close();
+2
shared/mod.ts
··· 1 1 export type { 2 + AlbumRef, 2 3 AssetKindValue, 3 4 CloudLocalStateValue, 5 + PersonRef, 4 6 PhotoAsset, 5 7 } from "./types.ts"; 6 8 export { AssetKind, CloudLocalState } from "./types.ts";
+15
shared/types.ts
··· 1 + export interface AlbumRef { 2 + uuid: string; 3 + title: string; 4 + } 5 + 6 + export interface PersonRef { 7 + uuid: string; 8 + displayName: string; 9 + } 10 + 1 11 /** Represents a single photo/video asset from the Photos library. */ 2 12 export interface PhotoAsset { 3 13 uuid: string; ··· 15 25 cloudLocalState: CloudLocalStateValue; 16 26 originalFileSize: number | null; 17 27 originalStableHash: string | null; 28 + title: string | null; 29 + description: string | null; 30 + albums: AlbumRef[]; 31 + keywords: string[]; 32 + people: PersonRef[]; 18 33 } 19 34 20 35 /** Cloud local state values from Photos.sqlite */