feat: complete TASK-WP2-02 project autosave
This commit is contained in:
+192
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user