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: type safety, error classes, cleanup

- Add setEstimatedBatchBytes? to Exporter interface, replacing 3 unsafe
duck-type casts with clean optional calls
- Replace isTimeoutError regex matching with LadderTimeoutError class
and instanceof check
- Move Deno.mkdir out of spawnLadder into createLadderExporter (once)
- Guard timeoutForBytes against negative input
- Move assetLabel helper above first use site
- Remove ineffective assetBytes * 2 doubling on deferred retry
- Rename exporter-subdivide.test.ts to exporter-timeout.test.ts
- Update CLI version string to 0.2.3

+69 -65
+1 -1
cli/mod.ts
··· 14 14 15 15 const main = new Command() 16 16 .name("attic") 17 - .version("0.2.0") 17 + .version("0.2.3") 18 18 .description("Back up your iCloud Photos library to S3-compatible storage") 19 19 .action(function (this: Command) { 20 20 this.showHelp();
+7 -3
cli/src/commands/backup.test.ts
··· 5 5 import { createS3ManifestStore, isBackedUp } from "../manifest/manifest.ts"; 6 6 import { createMockExporter } from "../export/exporter.mock.ts"; 7 7 import { createMockS3Provider } from "../storage/s3-client.mock.ts"; 8 - import type { ExportBatchResult, Exporter } from "../export/exporter.ts"; 8 + import { 9 + type ExportBatchResult, 10 + type Exporter, 11 + LadderTimeoutError, 12 + } from "../export/exporter.ts"; 9 13 10 14 function makeAsset( 11 15 uuid: string, ··· 320 324 ): Promise<ExportBatchResult> { 321 325 if (uuids.includes("slow-1") && uuids.length > 1) { 322 326 // Batch with slow asset: timeout 323 - throw new Error("Ladder subprocess timed out after 300s"); 327 + throw new LadderTimeoutError(300_000); 324 328 } 325 329 if (uuids.length === 1 && uuids[0] === "slow-1") { 326 330 slowRetryCount++; 327 331 if (slowRetryCount === 1) { 328 332 // First individual retry: also times out (gets deferred) 329 - throw new Error("Ladder subprocess timed out after 300s"); 333 + throw new LadderTimeoutError(300_000); 330 334 } 331 335 // Second retry (deferred): succeeds 332 336 }
+14 -24
cli/src/commands/backup.ts
··· 173 173 }; 174 174 Deno.addSignalListener("SIGINT", onInterrupt); 175 175 176 + // Helper: format asset name for log messages 177 + function assetLabel(uuid: string): string { 178 + const a = assetByUuid.get(uuid); 179 + if (!a) return uuid.substring(0, 8); 180 + const name = a.originalFilename ?? a.filename ?? uuid.substring(0, 8); 181 + const size = a.originalFileSize ? formatBytes(a.originalFileSize) : "?"; 182 + const type = a.kind === AssetKind.PHOTO ? "photo" : "video"; 183 + return `${name} (${type}, ${size})`; 184 + } 185 + 176 186 // Helper: upload exported assets to S3, update manifest and report 177 187 async function uploadExported( 178 188 batchResult: ExportBatchResult, ··· 265 275 } 266 276 } 267 277 268 - // Helper: format asset name for log messages 269 - function assetLabel(uuid: string): string { 270 - const a = assetByUuid.get(uuid); 271 - if (!a) return uuid.substring(0, 8); 272 - const name = a.originalFilename ?? a.filename ?? uuid.substring(0, 8); 273 - const size = a.originalFileSize ? formatBytes(a.originalFileSize) : "?"; 274 - const type = a.kind === AssetKind.PHOTO ? "photo" : "video"; 275 - return `${name} (${type}, ${size})`; 276 - } 277 - 278 278 // Assets deferred due to individual timeout — retried after all batches 279 279 const deferred: string[] = []; 280 280 ··· 306 306 ); 307 307 } 308 308 309 - // Scale timeout based on estimated batch size 310 - if ("setEstimatedBatchBytes" in exporter) { 311 - (exporter as { setEstimatedBatchBytes(n: number): void }) 312 - .setEstimatedBatchBytes(batchBytes); 313 - } 309 + exporter.setEstimatedBatchBytes?.(batchBytes); 314 310 315 311 // 1. Export via ladder 316 312 const spinner = options.quiet ··· 333 329 for (const uuid of batchUuids) { 334 330 if (signal.aborted) break; 335 331 const assetBytes = assetByUuid.get(uuid)?.originalFileSize ?? 0; 336 - if ("setEstimatedBatchBytes" in exporter) { 337 - (exporter as { setEstimatedBatchBytes(n: number): void }) 338 - .setEstimatedBatchBytes(assetBytes); 339 - } 332 + exporter.setEstimatedBatchBytes?.(assetBytes); 340 333 try { 341 334 const result = await exporter.exportBatch([uuid], signal); 342 335 combined.results.push(...result.results); ··· 387 380 } 388 381 } 389 382 390 - // Retry deferred assets with double timeout 383 + // Retry deferred assets 391 384 if (deferred.length > 0 && !signal.aborted) { 392 385 log(); 393 386 log(` Retrying ${deferred.length} deferred assets...`); 394 387 for (const uuid of deferred) { 395 388 if (signal.aborted) break; 396 389 const assetBytes = assetByUuid.get(uuid)?.originalFileSize ?? 0; 397 - if ("setEstimatedBatchBytes" in exporter) { 398 - (exporter as { setEstimatedBatchBytes(n: number): void }) 399 - .setEstimatedBatchBytes(assetBytes * 2); 400 - } 390 + exporter.setEstimatedBatchBytes?.(assetBytes); 401 391 try { 402 392 const result = await exporter.exportBatch([uuid], signal); 403 393 await uploadExported(result);
+3 -4
cli/src/export/exporter-race.test.ts
··· 1 1 import { assertEquals, assertRejects } from "@std/assert"; 2 - import { raceSubprocess } from "./exporter.ts"; 2 + import { LadderTimeoutError, raceSubprocess } from "./exporter.ts"; 3 3 import { AbortError } from "../abort-error.ts"; 4 4 5 5 /** Create a fake ChildProcess that resolves/rejects after a delay. */ ··· 62 62 assertEquals(result.code, 0); 63 63 }); 64 64 65 - Deno.test("raceSubprocess: rejects on timeout", async () => { 65 + Deno.test("raceSubprocess: rejects on timeout with LadderTimeoutError", async () => { 66 66 const process = fakeProcess(5000); 67 67 await assertRejects( 68 68 () => raceSubprocess(process, 50), 69 - Error, 70 - "timed out", 69 + LadderTimeoutError, 71 70 ); 72 71 }); 73 72
-21
cli/src/export/exporter-subdivide.test.ts
··· 1 - import { assertEquals } from "@std/assert"; 2 - import { isTimeoutError, timeoutForBytes } from "./exporter.ts"; 3 - 4 - Deno.test("timeoutForBytes: scales with size", () => { 5 - // Small batch: base timeout (5 min) + 1 min for < 100 MB 6 - assertEquals(timeoutForBytes(50 * 1024 * 1024), 5 * 60_000 + 60_000); 7 - // 500 MB batch: base + 5 min 8 - assertEquals(timeoutForBytes(500 * 1024 * 1024), 5 * 60_000 + 5 * 60_000); 9 - // 0 bytes: just base 10 - assertEquals(timeoutForBytes(0), 5 * 60_000); 11 - }); 12 - 13 - Deno.test("isTimeoutError: detects timeout errors", () => { 14 - assertEquals( 15 - isTimeoutError(new Error("Ladder subprocess timed out after 300s")), 16 - true, 17 - ); 18 - assertEquals(isTimeoutError(new Error("something timed out")), true); 19 - assertEquals(isTimeoutError(new Error("connection reset")), false); 20 - assertEquals(isTimeoutError("not an error"), false); 21 - });
+24
cli/src/export/exporter-timeout.test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { 3 + isTimeoutError, 4 + LadderTimeoutError, 5 + timeoutForBytes, 6 + } from "./exporter.ts"; 7 + 8 + Deno.test("timeoutForBytes: scales with size", () => { 9 + // Small batch: base timeout (5 min) + 1 min for < 100 MB 10 + assertEquals(timeoutForBytes(50 * 1024 * 1024), 5 * 60_000 + 60_000); 11 + // 500 MB batch: base + 5 min 12 + assertEquals(timeoutForBytes(500 * 1024 * 1024), 5 * 60_000 + 5 * 60_000); 13 + // 0 bytes: just base 14 + assertEquals(timeoutForBytes(0), 5 * 60_000); 15 + // Negative bytes: treated as 0 16 + assertEquals(timeoutForBytes(-100), 5 * 60_000); 17 + }); 18 + 19 + Deno.test("isTimeoutError: detects LadderTimeoutError", () => { 20 + assertEquals(isTimeoutError(new LadderTimeoutError(300_000)), true); 21 + assertEquals(isTimeoutError(new Error("connection reset")), false); 22 + assertEquals(isTimeoutError(new Error("timed out")), false); // plain Error, not LadderTimeoutError 23 + assertEquals(isTimeoutError("not an error"), false); 24 + });
+20 -12
cli/src/export/exporter.ts
··· 28 28 uuids: string[], 29 29 signal?: AbortSignal, 30 30 ): Promise<ExportBatchResult>; 31 + /** Hint to scale timeout for the next exportBatch call. Implementations may ignore. */ 32 + setEstimatedBatchBytes?(estimatedBytes: number): void; 33 + } 34 + 35 + /** Thrown when the ladder subprocess exceeds its timeout. */ 36 + export class LadderTimeoutError extends Error { 37 + constructor(timeoutMs: number) { 38 + super(`Ladder subprocess timed out after ${timeoutMs / 1000}s`); 39 + this.name = "LadderTimeoutError"; 40 + } 31 41 } 32 42 33 43 const DEFAULT_STAGING_DIR = join( ··· 118 128 119 129 /** Calculate timeout based on estimated batch size in bytes. */ 120 130 export function timeoutForBytes(estimatedBytes: number): number { 121 - const extra = Math.ceil(estimatedBytes / (100 * 1024 * 1024)) * 122 - TIMEOUT_PER_100MB_MS; 131 + const bytes = Math.max(0, estimatedBytes); 132 + const extra = Math.ceil(bytes / (100 * 1024 * 1024)) * TIMEOUT_PER_100MB_MS; 123 133 return LADDER_BASE_TIMEOUT_MS + extra; 124 134 } 125 135 ··· 138 148 timeoutMs: number, 139 149 signal?: AbortSignal, 140 150 ): Promise<ExportBatchResult> { 141 - await Deno.mkdir(stagingDir, { recursive: true }); 142 - 143 151 // PhotoKit expects local identifiers in "UUID/L0/001" format 144 152 const photoKitIds = uuids.map((uuid) => `${uuid}/L0/001`); 145 153 ··· 187 195 188 196 /** Check whether an error is a ladder timeout. */ 189 197 export function isTimeoutError(error: unknown): boolean { 190 - return error instanceof Error && /timed out/i.test(error.message); 198 + return error instanceof LadderTimeoutError; 191 199 } 192 200 193 201 /** Create an exporter that shells out to the ladder binary. */ ··· 197 205 ): Exporter & { stagingDir: string; setEstimatedBatchBytes(n: number): void } { 198 206 const stagingDir = opts.stagingDir ?? DEFAULT_STAGING_DIR; 199 207 const baseTimeoutMs = opts.baseTimeoutMs ?? LADDER_BASE_TIMEOUT_MS; 200 - 201 - // Updated by backup.ts before each batch to scale the timeout 202 208 let currentTimeoutMs = baseTimeoutMs; 209 + let stagingDirCreated = false; 203 210 204 211 return { 205 212 stagingDir, 206 213 207 - /** Set the estimated byte size so the timeout scales accordingly. */ 208 214 setEstimatedBatchBytes(estimatedBytes: number) { 209 215 currentTimeoutMs = Math.max( 210 216 baseTimeoutMs, ··· 212 218 ); 213 219 }, 214 220 215 - exportBatch( 221 + async exportBatch( 216 222 uuids: string[], 217 223 signal?: AbortSignal, 218 224 ): Promise<ExportBatchResult> { 225 + if (!stagingDirCreated) { 226 + await Deno.mkdir(stagingDir, { recursive: true }); 227 + stagingDirCreated = true; 228 + } 219 229 return spawnLadder( 220 230 ladderPath, 221 231 uuids, ··· 255 265 let timeoutId: number | undefined; 256 266 const timeoutPromise = new Promise<never>((_resolve, reject) => { 257 267 timeoutId = setTimeout(() => { 258 - reject( 259 - new Error(`Ladder subprocess timed out after ${timeoutMs / 1000}s`), 260 - ); 268 + reject(new LadderTimeoutError(timeoutMs)); 261 269 }, timeoutMs); 262 270 Deno.unrefTimer(timeoutId); 263 271 });