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
+77
View File
@@ -15,6 +15,9 @@ import {
AdminLoginSendRequestSchema,
AdminSessionResponseSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
@@ -33,6 +36,7 @@ import {
FailedEmptyTrashResponseSchema,
GenerationProjectItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectIdSchema,
ProjectImageItemSchema,
ProjectListQuerySchema,
@@ -41,6 +45,9 @@ import {
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectStateConflictResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RegistrationCompleteHeadersSchema,
@@ -66,6 +73,8 @@ import {
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
type ProjectEditableState,
type ProjectStateSaveHeaders,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -196,6 +205,9 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
project_not_found: 404,
project_ratio_fixed: 409,
project_retry_not_allowed: 409,
project_state_conflict: 412,
project_state_idempotency_conflict: 409,
project_state_invalid: 400,
} as const;
return reply.code(mapping[error.code]).send(null);
}
@@ -221,6 +233,7 @@ function projectSummaryResponse(project: ProjectSummaryView) {
function projectDetailResponse(project: ProjectDetailView) {
return {
...projectSummaryResponse(project),
canvas_state: project.canvasState,
created_at: project.createdAt,
draft_prompt: project.draftPrompt,
generations: project.generations.map((generation) => ({
@@ -239,6 +252,7 @@ function projectDetailResponse(project: ProjectDetailView) {
})),
pixel_height: project.pixelHeight,
pixel_width: project.pixelWidth,
save_status: project.saveStatus,
};
}
@@ -336,6 +350,9 @@ export async function createApp(options: CreateAppOptions = {}) {
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
StateSseEventSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
@@ -350,8 +367,12 @@ export async function createApp(options: CreateAppOptions = {}) {
GenerationProjectItemSchema,
ProjectImageItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
]) {
@@ -1084,6 +1105,62 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.put(
"/api/v1/projects/:projectId/state",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectEditableStateSchema),
headers: Type.Ref(ProjectStateSaveHeadersSchema),
operationId: "saveProjectState",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectStateSaveResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Ref(ErrorEnvelopeSchema),
412: Type.Ref(ProjectStateConflictResponseSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const headers = request.headers as ProjectStateSaveHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const saved = options.projects.saveProjectState({
expectedStateVersion: Number(headers["if-match"]),
idempotencyKey: headers["idempotency-key"],
ownerId: owner.userId,
projectId: (request.params as ProjectParams).projectId,
state: request.body as ProjectEditableState,
});
return { save_status: "saved" as const, state_version: saved.stateVersion };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof ProjectError && error.code === "project_state_conflict") {
return reply.code(412).send({
latest_state_version: error.latestStateVersion ?? 1,
save_status: "conflicted" as const,
});
}
if (error instanceof ProjectError && error.code === "project_state_idempotency_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId: request.id }));
}
return projectFailure(reply, request.id, error);
}
},
);
app.patch(
"/api/v1/projects/:projectId",
{
+7 -2
View File
@@ -5,13 +5,18 @@ export type ProjectErrorCode =
| "project_name_invalid"
| "project_not_found"
| "project_ratio_fixed"
| "project_retry_not_allowed";
| "project_retry_not_allowed"
| "project_state_conflict"
| "project_state_invalid"
| "project_state_idempotency_conflict";
export class ProjectError extends Error {
readonly code: ProjectErrorCode;
readonly latestStateVersion: number | undefined;
constructor(code: ProjectErrorCode) {
constructor(code: ProjectErrorCode, latestStateVersion?: number) {
super(code);
this.code = code;
this.latestStateVersion = latestStateVersion;
}
}
+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();
}
}