650 lines
29 KiB
TypeScript
650 lines
29 KiB
TypeScript
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<ProjectRatio, { height: number; width: number }> = {
|
|
"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<GenerationStatus, "succeeded">;
|
|
}) {
|
|
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<GenerationStatus, "succeeded">;
|
|
}) {
|
|
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<ProjectRow & { image_count: number; latest_status: string | null }>;
|
|
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<GenerationStatus, "succeeded">;
|
|
}, 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();
|
|
}
|
|
}
|