feat: complete TASK-WP2-02 project autosave

This commit is contained in:
suyx
2026-08-02 18:28:07 +08:00
parent 4d38530361
commit da6fa25e60
17 changed files with 2268 additions and 35 deletions
+192 -6
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
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";
@@ -62,6 +63,14 @@ interface ImageRow {
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);
}
@@ -105,6 +114,38 @@ 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;
@@ -149,6 +190,12 @@ export class ProjectService {
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();
@@ -201,6 +248,10 @@ export class ProjectService {
.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);
@@ -212,6 +263,12 @@ export class ProjectService {
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();
}
@@ -229,14 +286,85 @@ export class ProjectService {
renameProject(ownerId: string, projectId: string, name: string) {
const normalized = normalizeProjectName(name);
const now = this.clock();
const changed = 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);
if (changed.changes !== 1) throw new ProjectError("project_not_found");
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");
@@ -288,14 +416,17 @@ export class ProjectService {
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,
};
}
@@ -347,6 +478,26 @@ export class ProjectService {
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 };
@@ -411,6 +562,26 @@ export class ProjectService {
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
@@ -459,5 +630,20 @@ export class ProjectService {
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();
}
}