feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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
@@ -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