import { createHash, randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import type BetterSqlite3 from "better-sqlite3"; import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts"; import { ProjectError } from "./project-errors.js"; export { ProjectError } from "./project-errors.js"; const require = createRequire(import.meta.url); const Database = require("better-sqlite3") as typeof BetterSqlite3; export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const; export type ProjectRatio = typeof projectRatios[number]; export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; const ratioPixels: Record = { "3:4": { height: 1440, width: 1080 }, "1:1": { height: 1080, width: 1080 }, "4:3": { height: 1080, width: 1440 }, "9:16": { height: 1920, width: 1080 }, }; const projectLimit = 20; const historyLimit = 10; const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000; const generationErrorCategories = new Set([ "upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled", "gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid", "unknown_retryable", "unknown_non_retryable", ]); interface ProjectRow { created_at: number; current_image_id: string | null; deleted_at: number | null; draft_prompt: string; name: string; owner_id: string; pixel_height: number; pixel_width: number; project_id: string; purge_at: number | null; ratio: ProjectRatio; state_version: number; status: "active" | "trashed" | "purged"; updated_at: number; } interface GenerationRow { created_at: number; error_category: string | null; generation_id: string; prompt: string; project_id: string; ratio: ProjectRatio; status: GenerationStatus; updated_at: number; } interface ImageRow { created_at: number; generation_id: string; image_id: string; } interface ProjectStateRow { canvas_json: string; created_at: number; name: string; project_id: string; state_version: number; } function isProjectRatio(value: string): value is ProjectRatio { return projectRatios.includes(value as ProjectRatio); } function normalizePrompt(value: string) { const normalized = value.trim().replace(/\s+/gu, " "); if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid"); return normalized; } function takeGraphemes(value: string, count: number) { const Segmenter = Intl.Segmenter; if (Segmenter) { return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)] .slice(0, count) .map((entry) => entry.segment) .join(""); } return Array.from(value).slice(0, count).join(""); } function localDate(timestamp: number) { const date = new Date(timestamp); return [date.getFullYear(), date.getMonth() + 1, date.getDate()] .map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0")) .join("-"); } function defaultProjectName(prompt: string, timestamp: number) { const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作"; return `${summary} ${localDate(timestamp)}`; } function normalizeProjectName(value: string) { const normalized = value.trim().replace(/\s+/gu, " "); if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid"); return normalized; } function iso(timestamp: number) { return new Date(timestamp).toISOString(); } function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState { return { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0, }, asset_id: assetId, }, elements: [], pixel_height: pixels.height, pixel_width: pixels.width, ratio, schema_version: 1, }; } function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`; } return JSON.stringify(value); } export class ProjectService { readonly database: BetterSqlite3.Database; private readonly clock: () => number; constructor(input: { clock?: () => number; databasePath: string }) { this.clock = input.clock ?? Date.now; 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("synchronous = FULL"); this.database.pragma("busy_timeout = 5000"); this.migrate(); } close() { this.database.close(); } createProjectForGeneration(input: { ownerId: string; prompt: string; ratio: ProjectRatio; status: Exclude; }) { if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid"); const prompt = normalizePrompt(input.prompt); const projectId = randomUUID(); const generationId = randomUUID(); const now = this.clock(); const pixels = ratioPixels[input.ratio]; const transaction = this.database.transaction(() => { const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") .get(input.ownerId) as { count: number }; if (active.count >= projectLimit) throw new ProjectError("project_active_limit"); this.database.prepare(` INSERT INTO projects ( project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height, status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL) `).run( projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio, pixels.width, pixels.height, now, now, ); this.insertProjectState({ canvasState: defaultCanvasState(input.ratio, pixels, null), name: defaultProjectName(prompt, now), projectId, stateVersion: 1, }, now); this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now); }); transaction.immediate(); return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) }; } continueProjectGeneration(input: { ownerId: string; projectId: string; prompt: string; ratio: ProjectRatio; status: Exclude; }) { if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid"); const generationId = randomUUID(); const prompt = normalizePrompt(input.prompt); const now = this.clock(); const transaction = this.database.transaction(() => { const project = this.readOwnedProject(input.ownerId, input.projectId); if (project.status !== "active") throw new ProjectError("project_not_found"); if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed"); if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit"); this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now); this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?") .run(prompt, now, input.projectId); }); transaction.immediate(); return this.readGeneration(generationId); } retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) { const project = this.getProject(input.ownerId, input.projectId); const latest = project.generations.at(-1); if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) { throw new ProjectError("project_retry_not_allowed"); } return this.continueProjectGeneration({ ownerId: input.ownerId, projectId: input.projectId, prompt: input.prompt, ratio: project.ratio, status: "queued", }); } recordSuccessfulImage(input: { generationId: string; imageId: string }) { const now = this.clock(); const transaction = this.database.transaction(() => { const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?") .get(input.generationId) as GenerationRow | undefined; if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid"); if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit"); const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow; const currentState = this.readProjectState(generation.project_id); const nextCanvas = structuredClone(currentState.canvasState); if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId; this.database.prepare(` UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ? `).run(now, input.generationId); this.database.prepare(` INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?) `).run(input.imageId, generation.project_id, input.generationId, now); this.database.prepare(` UPDATE projects SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1 WHERE project_id = ? `).run(input.imageId, now, generation.project_id); this.insertProjectState({ canvasState: nextCanvas, name: project.name, projectId: generation.project_id, stateVersion: project.state_version + 1, }, now); }); transaction.immediate(); } markGenerationFailed(generationId: string, errorCategory: string) { if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid"); const status = errorCategory === "safety_rejected" ? "rejected" : "failed"; const changed = this.database.prepare(` UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ? WHERE generation_id = ? AND status IN ('queued', 'running') `).run(status, errorCategory, this.clock(), generationId); if (changed.changes !== 1) throw new ProjectError("generation_state_invalid"); } renameProject(ownerId: string, projectId: string, name: string) { const normalized = normalizeProjectName(name); const now = this.clock(); const transaction = this.database.transaction(() => { const project = this.readOwnedProject(ownerId, projectId); if (project.status !== "active") throw new ProjectError("project_not_found"); const currentState = this.readProjectState(projectId); this.database.prepare(` UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1 WHERE owner_id = ? AND project_id = ? AND status = 'active' `).run(normalized, now, ownerId, projectId); this.insertProjectState({ canvasState: currentState.canvasState, name: normalized, projectId, stateVersion: project.state_version + 1, }, now); }); transaction.immediate(); return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version }; } saveProjectState(input: { expectedStateVersion: number; idempotencyKey: string; ownerId: string; projectId: string; state: ProjectEditableState; }) { if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) { throw new ProjectError("project_state_invalid"); } const name = normalizeProjectName(input.state.name); const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState; const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex"); const now = this.clock(); let result!: { stateVersion: number }; const transaction = this.database.transaction(() => { const replay = this.database.prepare(` SELECT request_hash, response_state_version FROM project_state_idempotency WHERE owner_id = ? AND project_id = ? AND idempotency_key = ? `).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined; if (replay) { if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict"); result = { stateVersion: replay.response_state_version }; return; } const project = this.readOwnedProject(input.ownerId, input.projectId); if (project.status !== "active") throw new ProjectError("project_not_found"); if (project.state_version !== input.expectedStateVersion) { throw new ProjectError("project_state_conflict", project.state_version); } const canvas = state.canvas_state; if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) { throw new ProjectError("project_state_invalid"); } if (canvas.background.asset_id) { const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?") .get(input.projectId, canvas.background.asset_id); if (!owned) throw new ProjectError("project_state_invalid"); } const nextVersion = project.state_version + 1; const changed = this.database.prepare(` UPDATE projects SET name = ?, state_version = ?, updated_at = ? WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active' `).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion); if (changed.changes !== 1) { const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version; throw new ProjectError("project_state_conflict", latest); } this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now); this.database.prepare(` INSERT INTO project_state_idempotency ( owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at ) VALUES (?, ?, ?, ?, ?, ?) `).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now); result = { stateVersion: nextVersion }; }); transaction.immediate(); return result; } trashFailedEmpty(ownerId: string, projectIds: string[]) { const uniqueIds = [...new Set(projectIds)]; if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid"); const trashedProjectIds: string[] = []; const ignoredProjectIds: string[] = []; const now = this.clock(); const transaction = this.database.transaction(() => { for (const projectId of uniqueIds) { const row = this.database.prepare(` SELECT p.project_id, (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active' `).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined; if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) { ignoredProjectIds.push(projectId); continue; } this.database.prepare(` UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1 WHERE project_id = ? `).run(now, now + trashRetentionMilliseconds, now, projectId); trashedProjectIds.push(projectId); } }); transaction.immediate(); return { ignoredProjectIds, trashedProjectIds }; } listProjects(ownerId: string, status: "active" | "trashed") { const rows = this.database.prepare(` SELECT p.*, (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status FROM projects p WHERE p.owner_id = ? AND p.status = ? ORDER BY p.updated_at DESC, p.project_id DESC `).all(ownerId, status) as Array; return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status)); } getProject(ownerId: string, projectId: string) { const row = this.readOwnedProject(ownerId, projectId); if (row.status === "purged") throw new ProjectError("project_not_found"); const generations = this.database.prepare(` SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid `).all(projectId) as GenerationRow[]; const images = this.database.prepare(` SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid `).all(projectId) as ImageRow[]; const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null); const projectState = this.readProjectState(projectId); return { ...summary, canvasState: projectState.canvasState, createdAt: iso(row.created_at), draftPrompt: row.draft_prompt, generations: generations.map((generation) => this.generationView(generation)), images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })), pixelHeight: row.pixel_height, pixelWidth: row.pixel_width, saveStatus: "saved" as const, }; } activeProjectCount(ownerId: string) { const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") .get(ownerId) as { count: number }; return row.count; } private insertGeneration(input: { generationId: string; ownerId: string; projectId: string; prompt: string; ratio: ProjectRatio; status: Exclude; }, now: number) { const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null; this.database.prepare(` INSERT INTO generation_jobs ( generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now); } private readGeneration(generationId: string) { const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined; if (!row) throw new ProjectError("generation_state_invalid"); return this.generationView(row); } private generationView(row: GenerationRow) { return { createdAt: iso(row.created_at), errorCategory: row.error_category, generationId: row.generation_id, projectId: row.project_id, prompt: row.prompt, ratio: row.ratio, status: row.status, updatedAt: iso(row.updated_at), }; } private readOwnedProject(ownerId: string, projectId: string) { const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?") .get(ownerId, projectId) as ProjectRow | undefined; if (!row) throw new ProjectError("project_not_found"); return row; } private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) { this.database.prepare(` INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, ?, ?, ?, ?) `).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now); } private readProjectState(projectId: string) { const row = this.database.prepare(` SELECT project_id, state_version, name, canvas_json, created_at FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1 `).get(projectId) as ProjectStateRow | undefined; if (!row) throw new ProjectError("project_state_invalid"); const canvasState: unknown = JSON.parse(row.canvas_json); if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid"); return { canvasState, name: row.name, stateVersion: row.state_version } as { canvasState: CanvasState; name: string; stateVersion: number; }; } private successfulImageCount(projectId: string) { const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?") .get(projectId) as { count: number }; return row.count; } private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) { const status: ProjectViewStatus = row.status === "trashed" ? "trashed" : imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus) ? "failed_empty" : "active"; return { currentImageId: row.current_image_id, deletedAt: row.deleted_at === null ? null : iso(row.deleted_at), name: row.name, projectId: row.project_id, purgeAt: row.purge_at === null ? null : iso(row.purge_at), ratio: row.ratio, stateVersion: row.state_version, status, successfulImageCount: imageCount, updatedAt: iso(row.updated_at), }; } private migrate() { this.database.exec(` CREATE TABLE IF NOT EXISTS projects ( project_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160), draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000), ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')), pixel_width INTEGER NOT NULL, pixel_height INTEGER NOT NULL, status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')), state_version INTEGER NOT NULL CHECK (state_version >= 1), current_image_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER, purge_at INTEGER, UNIQUE(project_id, ratio), CHECK ( (ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR (ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR (ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR (ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920) ), CHECK ( (status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR (status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR (status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC); DROP TRIGGER IF EXISTS projects_active_insert_limit; CREATE TRIGGER projects_active_insert_limit BEFORE INSERT ON projects WHEN NEW.status = 'active' AND ( SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active' ) >= ${projectLimit} BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END; CREATE TABLE IF NOT EXISTS project_states ( project_id TEXT NOT NULL, state_version INTEGER NOT NULL CHECK (state_version >= 1), name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80), canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)), created_at INTEGER NOT NULL, PRIMARY KEY (project_id, state_version), FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC); CREATE TABLE IF NOT EXISTS project_state_idempotency ( owner_id TEXT NOT NULL, project_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2), created_at INTEGER NOT NULL, PRIMARY KEY (owner_id, project_id, idempotency_key), FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE ); DROP TRIGGER IF EXISTS projects_active_restore_limit; CREATE TRIGGER projects_active_restore_limit BEFORE UPDATE OF status ON projects WHEN OLD.status <> 'active' AND NEW.status = 'active' AND ( SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active' ) >= ${projectLimit} BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END; CREATE TABLE IF NOT EXISTS generation_jobs ( generation_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, project_id TEXT NOT NULL, prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000), ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')), status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')), error_category TEXT CHECK (error_category IS NULL OR error_category IN ( 'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled', 'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid', 'unknown_retryable', 'unknown_non_retryable' )), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(generation_id, project_id), FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE, CHECK ( (status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR (status IN ('failed', 'rejected') AND error_category IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id); CREATE TABLE IF NOT EXISTS project_images ( image_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, generation_id TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id); DROP TRIGGER IF EXISTS projects_ratio_immutable; CREATE TRIGGER projects_ratio_immutable BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END; DROP TRIGGER IF EXISTS project_images_history_limit; CREATE TRIGGER project_images_history_limit BEFORE INSERT ON project_images WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit} BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END; `); const missingStates = this.database.prepare(` SELECT p.* FROM projects p WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id) `).all() as ProjectRow[]; const insertBackfill = this.database.transaction(() => { for (const project of missingStates) { this.insertProjectState({ canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id), name: project.name, projectId: project.project_id, stateVersion: project.state_version, }, project.updated_at); } }); insertBackfill.immediate(); } }