import { createHash, randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import type { Readable } from "node:stream"; import type BetterSqlite3 from "better-sqlite3"; import { ManagedStorage } from "./managed-storage.js"; import { stableJson } from "./projects.js"; import { LatestExportError } from "./latest-export-errors.js"; export { LatestExportError } from "./latest-export-errors.js"; const require = createRequire(import.meta.url); const Database = require("better-sqlite3") as typeof BetterSqlite3; export type ExportFormat = "jpg" | "png"; interface ExportRow { byte_size: number; created_at: number; export_id: string; format: ExportFormat; managed_file_id: string; pixel_height: number; pixel_width: number; project_id: string; sha256: string; state_version: number; } interface ManagedFileRow { byte_size: number; file_id: string; mime_type: string; relative_path: string; sha256: string; } type LatestExportView = ReturnType; function iso(timestamp: number) { return new Date(timestamp).toISOString(); } function validSha256(value: string) { return /^[0-9a-f]{64}$/.test(value); } export class LatestExportService { readonly database: BetterSqlite3.Database; private readonly clock: () => number; private readonly storage: ManagedStorage; constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) { this.clock = input.clock ?? Date.now; this.storage = input.storage; const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); this.database.pragma("journal_mode = WAL"); this.database.pragma("foreign_keys = ON"); this.database.pragma("busy_timeout = 5000"); this.migrate(); } close() { this.database.close(); } async saveLatest(input: { byteSize: number; content: Readable; exportId: string; format: ExportFormat; ownerId: string; pixelHeight: number; pixelWidth: number; projectId: string; sha256: string; stateVersion: number; }) { this.validateInput(input); const requestHash = createHash("sha256").update(stableJson({ byte_size: input.byteSize, export_id: input.exportId, format: input.format, owner_id: input.ownerId, pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId, sha256: input.sha256, state_version: input.stateVersion, })).digest("hex"); const replay = this.database.prepare("SELECT owner_id, request_hash, response_json FROM latest_export_receipts WHERE export_id = ?") .get(input.exportId) as { owner_id: string; request_hash: string; response_json: string | null } | undefined; if (replay) { if (replay.owner_id !== input.ownerId || replay.request_hash !== requestHash) throw new LatestExportError("conflict"); const replayHash = createHash("sha256"); let replayBytes = 0; for await (const chunk of input.content) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); replayBytes += bytes.byteLength; replayHash.update(bytes); } if (replayBytes !== input.byteSize || replayHash.digest("hex") !== input.sha256) throw new LatestExportError("conflict"); return replay.response_json ? JSON.parse(replay.response_json) as LatestExportView : this.readByExportId(input.ownerId, input.exportId); } this.assertWritableProject(input); const stored = await this.storage.commitStream({ content: input.content, expectedMimeType: input.format === "png" ? "image/png" : "image/jpeg", expectedSha256: input.sha256, fileKind: "export", fileName: `latest.${input.format === "jpg" ? "jpg" : "png"}`, operationId: input.exportId, ownerRef: input.ownerId, projectedWriteBytes: input.byteSize, }); try { if (stored.bytes !== input.byteSize) throw new LatestExportError("invalid"); const createdAt = this.clock(); const response = this.view({ byte_size: stored.bytes, created_at: createdAt, export_id: input.exportId, format: input.format, managed_file_id: stored.file_id, pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId, sha256: stored.sha256, state_version: input.stateVersion, }); const transaction = this.database.transaction(() => { this.assertWritableProject(input); const previous = this.database.prepare("SELECT managed_file_id FROM latest_exports WHERE project_id = ? AND format = ?") .get(input.projectId, input.format) as { managed_file_id: string } | undefined; this.database.prepare(` INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'export', ?) `).run(input.projectId, stored.file_id, createdAt); this.database.prepare(` INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?) `).run(`project:${input.projectId}:${stored.file_id}`, stored.file_id, iso(createdAt)); this.database.prepare(` INSERT INTO latest_exports ( project_id, format, export_id, managed_file_id, state_version, sha256, byte_size, pixel_width, pixel_height, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, format) DO UPDATE SET export_id = excluded.export_id, managed_file_id = excluded.managed_file_id, state_version = excluded.state_version, sha256 = excluded.sha256, byte_size = excluded.byte_size, pixel_width = excluded.pixel_width, pixel_height = excluded.pixel_height, created_at = excluded.created_at `).run( input.projectId, input.format, input.exportId, stored.file_id, input.stateVersion, stored.sha256, stored.bytes, input.pixelWidth, input.pixelHeight, createdAt, ); this.database.prepare("INSERT INTO latest_export_receipts (export_id, owner_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?)") .run(input.exportId, input.ownerId, requestHash, stableJson(response), createdAt); if (previous && previous.managed_file_id !== stored.file_id) this.retireReplacedFile(previous.managed_file_id, createdAt); }); transaction.immediate(); return response; } catch (error) { this.storage.retireManagedFile(stored.file_id, "compensation"); throw error; } } getLatest(ownerId: string, projectId: string, format: ExportFormat) { const row = this.database.prepare(` SELECT le.*, mf.mime_type, mf.relative_path FROM latest_exports le JOIN projects p ON p.project_id = le.project_id JOIN managed_files mf ON mf.file_id = le.managed_file_id AND mf.status = 'committed' WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND le.format = ? `).get(ownerId, projectId, format) as (ExportRow & ManagedFileRow) | undefined; if (!row) throw new LatestExportError("not_found"); const path = this.storage.resolveManagedFile(row.managed_file_id); if (!path) throw new LatestExportError("not_found"); return { ...this.view(row), mimeType: row.mime_type, path }; } getOriginal(ownerId: string, projectId: string, imageId: string) { const row = this.database.prepare(` SELECT mf.file_id, mf.relative_path, mf.byte_size, mf.mime_type, mf.sha256 FROM project_images pi JOIN projects p ON p.project_id = pi.project_id JOIN managed_files mf ON mf.file_id = pi.image_id AND mf.status = 'committed' WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND pi.image_id = ? `).get(ownerId, projectId, imageId) as ManagedFileRow | undefined; if (!row) throw new LatestExportError("not_found"); const path = this.storage.resolveManagedFile(row.file_id); if (!path) throw new LatestExportError("not_found"); return { ...row, path }; } private assertWritableProject(input: Pick[0], "ownerId" | "pixelHeight" | "pixelWidth" | "projectId" | "stateVersion">) { const project = this.database.prepare(` SELECT state_version, pixel_width, pixel_height FROM projects WHERE owner_id = ? AND project_id = ? AND status = 'active' `).get(input.ownerId, input.projectId) as { pixel_height: number; pixel_width: number; state_version: number } | undefined; if (!project) throw new LatestExportError("not_found"); if (project.state_version !== input.stateVersion) throw new LatestExportError("conflict"); if (project.pixel_width !== input.pixelWidth || project.pixel_height !== input.pixelHeight) throw new LatestExportError("invalid"); } private readByExportId(ownerId: string, exportId: string) { const row = this.database.prepare(` SELECT le.* FROM latest_exports le JOIN projects p ON p.project_id = le.project_id WHERE p.owner_id = ? AND le.export_id = ? `).get(ownerId, exportId) as ExportRow | undefined; if (!row) throw new LatestExportError("not_found"); return this.view(row); } private retireReplacedFile(fileId: string, timestamp: number) { const file = this.database.prepare("SELECT relative_path, byte_size FROM managed_files WHERE file_id = ? AND status = 'committed'") .get(fileId) as { byte_size: number; relative_path: string } | undefined; if (!file) return; this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(iso(timestamp), fileId); this.database.prepare(` INSERT OR IGNORE INTO file_cleanup_queue ( cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, reason, status, created_at, completed_at, last_error ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) `).run(randomUUID(), fileId, file.relative_path, file.byte_size, iso(timestamp)); } private validateInput(input: Parameters[0]) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.exportId) || !["jpg", "png"].includes(input.format) || !validSha256(input.sha256) || ![input.byteSize, input.pixelHeight, input.pixelWidth, input.stateVersion].every((value) => Number.isSafeInteger(value) && value > 0)) { throw new LatestExportError("invalid"); } } private view(row: ExportRow) { return { byteSize: row.byte_size, createdAt: iso(row.created_at), downloadUrl: `/api/v1/projects/${row.project_id}/latest-exports/${row.format}`, exportId: row.export_id, format: row.format, pixelHeight: row.pixel_height, pixelWidth: row.pixel_width, projectId: row.project_id, sha256: row.sha256, stateVersion: row.state_version, }; } private migrate() { this.database.exec(` CREATE TABLE IF NOT EXISTS latest_export_receipts ( export_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)), created_at INTEGER NOT NULL ); `); const columns = this.database.prepare("PRAGMA table_info(latest_export_receipts)").all() as Array<{ name: string }>; if (!columns.some((column) => column.name === "response_json")) { this.database.exec("ALTER TABLE latest_export_receipts ADD COLUMN response_json TEXT"); } } }