From da6fa25e60540e5d1a1cfd72de640132f9f83236 Mon Sep 17 00:00:00 2001 From: suyx Date: Sun, 2 Aug 2026 18:28:07 +0800 Subject: [PATCH] feat: complete TASK-WP2-02 project autosave --- apps/api/src/app.ts | 77 +++ apps/api/src/project-errors.ts | 9 +- apps/api/src/projects.ts | 198 ++++++- apps/web/src/generated/api/sdk.gen.ts | 11 +- apps/web/src/generated/api/types.gen.ts | 82 +++ apps/web/src/project-autosave.ts | 187 ++++++ apps/web/src/project-pages.css | 139 +++++ apps/web/src/project-pages.tsx | 250 +++++++- openapi/openapi.json | 624 +++++++++++++++++++- package.json | 6 +- packages/shared-contracts/src/canvas.ts | 129 ++++ packages/shared-contracts/src/index.ts | 1 + packages/shared-contracts/src/projects.ts | 3 + scripts/run-wp2-02-validation.mjs | 129 ++++ tests/api/wp2-02-project-state.test.ts | 131 ++++ tests/e2e/project-autosave-conflict.spec.ts | 167 ++++++ tests/unit/wp2-02-autosave.test.ts | 160 +++++ 17 files changed, 2268 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/project-autosave.ts create mode 100644 packages/shared-contracts/src/canvas.ts create mode 100644 scripts/run-wp2-02-validation.mjs create mode 100644 tests/api/wp2-02-project-state.test.ts create mode 100644 tests/e2e/project-autosave-conflict.spec.ts create mode 100644 tests/unit/wp2-02-autosave.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index eb057ea..ab05ec9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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", { diff --git a/apps/api/src/project-errors.ts b/apps/api/src/project-errors.ts index b9ee655..1149ef8 100644 --- a/apps/api/src/project-errors.ts +++ b/apps/api/src/project-errors.ts @@ -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; } } diff --git a/apps/api/src/projects.ts b/apps/api/src/projects.ts index 173ac07..1a85e31 100644 --- a/apps/api/src/projects.ts +++ b/apps/api/src/projects.ts @@ -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(); } } diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index d878bf6..13171a1 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; +import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -175,6 +175,15 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO return response.json() as Promise; } +export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/state`, { body: JSON.stringify(body), method: "PUT", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} }); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index d7f13a5..edabd19 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -136,6 +136,65 @@ export type BrowserSupportSuccess = { export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable"; +export type CanvasBackgroundAdjustments = { + "brightness": number; + "contrast": number; + "crop": { + "height": number; + "width": number; + "x": number; + "y": number; +} | null; + "filter": string; + "fit": "fill" | "fit" | "crop"; + "saturation": number; + "sharpness": number; + "temperature": number; +}; + +export type CanvasElement = { + "colors"?: Array; + "content"?: string; + "coordinates"?: { + "latitude": number; + "longitude": number; +}; + "created_at": string; + "dynamic_fields"?: Record; + "element_id": string; + "font_override"?: string; + "font_size"?: number; + "formatted_value"?: string; + "opacity": number; + "position": { + "x": number; + "y": number; +}; + "resource_version": string; + "rotation": number; + "scale": { + "x": number; + "y": number; +}; + "style_id"?: string; + "style_parameters"?: Record; + "template_or_asset_id": string; + "type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker"; + "z_index": number; +}; + +export type CanvasState = { + "background": { + "adjustments": CanvasBackgroundAdjustments; + "asset_id": string | null; +}; + "elements": Array; + "pixel_height": number; + "pixel_width": number; + "ratio": "3:4" | "1:1" | "4:3" | "9:16"; + "schema_version": 1; +}; + export type CorrelationId = string; export type CreditSummary = { @@ -251,6 +310,7 @@ export type ModelRuntimeSseEvent = { }; export type ProjectDetailResponse = { + "canvas_state": CanvasState; "created_at": string; "current_image_id": ProjectId | null; "deleted_at": string | null; @@ -263,12 +323,18 @@ export type ProjectDetailResponse = { "project_id": ProjectId; "purge_at": string | null; "ratio": ProjectRatio; + "save_status": "saved"; "state_version": number; "status": ProjectViewStatus; "successful_image_count": number; "updated_at": string; }; +export type ProjectEditableState = { + "canvas_state": CanvasState; + "name": string; +}; + export type ProjectId = string; export type ProjectImageItem = { @@ -303,6 +369,22 @@ export type ProjectRenameResponse = { "status": "renamed"; }; +export type ProjectStateConflictResponse = { + "latest_state_version": number; + "save_status": "conflicted"; +}; + +export type ProjectStateSaveHeaders = { + "idempotency-key": string; + "if-match": string; + "x-csrf-token": string; +}; + +export type ProjectStateSaveResponse = { + "save_status": "saved"; + "state_version": number; +}; + export type ProjectSummary = { "current_image_id": ProjectId | null; "deleted_at": string | null; diff --git a/apps/web/src/project-autosave.ts b/apps/web/src/project-autosave.ts new file mode 100644 index 0000000..8c5d216 --- /dev/null +++ b/apps/web/src/project-autosave.ts @@ -0,0 +1,187 @@ +import type { ProjectEditableState } from "@dada/shared-contracts"; + +export type ProjectSaveStatus = "dirty" | "saving" | "saved" | "failed" | "conflicted"; + +export class ProjectStateConflict extends Error { + readonly latestStateVersion: number; + + constructor(latestStateVersion: number) { + super("project_state_conflict"); + this.latestStateVersion = latestStateVersion; + } +} + +export class ConflictExportGuard { + used = false; + + async run(action: () => Promise) { + if (this.used) return false; + this.used = true; + await action(); + return true; + } +} + +export class SessionHistory { + private current: T; + private readonly past: T[] = []; + private readonly future: T[] = []; + + constructor(initial: T) { + this.current = structuredClone(initial); + } + + get canRedo() { return this.future.length > 0; } + get canUndo() { return this.past.length > 0; } + get value() { return structuredClone(this.current); } + + commit(next: T) { + this.past.push(structuredClone(this.current)); + this.current = structuredClone(next); + this.future.length = 0; + } + + undo() { + const previous = this.past.pop(); + if (previous === undefined) return undefined; + this.future.push(structuredClone(this.current)); + this.current = previous; + return structuredClone(this.current); + } + + redo() { + const next = this.future.pop(); + if (next === undefined) return undefined; + this.past.push(structuredClone(this.current)); + this.current = next; + return structuredClone(this.current); + } +} + +interface PendingSave { + operationId: string; + snapshot: ProjectEditableState; +} + +export class ProjectAutoSaveQueue { + status: ProjectSaveStatus = "saved"; + stateVersion: number; + conflictVersion?: number; + + private disposed = false; + private inFlight: Promise | undefined; + private lastSaved: ProjectEditableState; + private pending: PendingSave | undefined; + private retryIndex = 0; + private timer: ReturnType | undefined; + private readonly debounceMs: number; + private readonly onConflict: ((latestVersion: number) => void) | undefined; + private readonly onSaved: ((snapshot: ProjectEditableState, stateVersion: number) => void) | undefined; + private readonly onStatus: ((status: ProjectSaveStatus) => void) | undefined; + private readonly retryDelaysMs: number[]; + private readonly save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>; + + constructor(input: { + debounceMs?: number; + initialState: ProjectEditableState; + initialVersion: number; + onConflict?: (latestVersion: number) => void; + onSaved?: (snapshot: ProjectEditableState, stateVersion: number) => void; + onStatus?: (status: ProjectSaveStatus) => void; + retryDelaysMs?: number[]; + save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>; + }) { + this.debounceMs = input.debounceMs ?? 1_000; + this.lastSaved = structuredClone(input.initialState); + this.onConflict = input.onConflict; + this.onSaved = input.onSaved; + this.onStatus = input.onStatus; + this.retryDelaysMs = input.retryDelaysMs ?? [1_000, 2_000, 4_000, 8_000, 15_000]; + this.save = input.save; + this.stateVersion = input.initialVersion; + } + + commit(snapshot: ProjectEditableState) { + if (this.disposed || this.status === "conflicted") return; + this.pending = { operationId: crypto.randomUUID(), snapshot: structuredClone(snapshot) }; + if (!this.inFlight) { + this.setStatus("dirty"); + this.schedule(this.debounceMs); + } + } + + async saveNow() { + if (this.disposed || (this.status as ProjectSaveStatus) === "conflicted") return false; + this.clearTimer(); + if (this.inFlight) await this.inFlight; + if (this.disposed || this.status === "conflicted") return false; + if (!this.pending) return this.status === "saved"; + return this.startSave(); + } + + dispose() { + this.disposed = true; + this.clearTimer(); + } + + private clearTimer() { + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + } + + private schedule(delay: number) { + this.clearTimer(); + this.timer = setTimeout(() => { + this.timer = undefined; + void this.startSave(); + }, delay); + } + + private startSave() { + if (this.inFlight || !this.pending || this.disposed || this.status === "conflicted") { + return this.inFlight ?? Promise.resolve(false); + } + const request = this.pending; + this.pending = undefined; + this.setStatus("saving"); + const attempt = this.save(structuredClone(request.snapshot), this.stateVersion, request.operationId) + .then((result) => { + this.stateVersion = result.stateVersion; + this.lastSaved = structuredClone(request.snapshot); + this.retryIndex = 0; + this.onSaved?.(structuredClone(request.snapshot), this.stateVersion); + if (this.pending) { + this.setStatus("dirty"); + this.schedule(this.debounceMs); + } else { + this.setStatus("saved"); + } + return true; + }) + .catch((error: unknown) => { + if (error instanceof ProjectStateConflict) { + this.pending = undefined; + this.conflictVersion = error.latestStateVersion; + this.setStatus("conflicted"); + this.onConflict?.(error.latestStateVersion); + return false; + } + if (!this.pending) this.pending = request; + this.setStatus("failed"); + const delay = this.retryDelaysMs[Math.min(this.retryIndex, this.retryDelaysMs.length - 1)] ?? 15_000; + this.retryIndex += 1; + this.schedule(delay); + return false; + }) + .finally(() => { + if (this.inFlight === attempt) this.inFlight = undefined; + }); + this.inFlight = attempt; + return attempt; + } + + private setStatus(status: ProjectSaveStatus) { + this.status = status; + this.onStatus?.(status); + } +} diff --git a/apps/web/src/project-pages.css b/apps/web/src/project-pages.css index cc80687..805a546 100644 --- a/apps/web/src/project-pages.css +++ b/apps/web/src/project-pages.css @@ -647,6 +647,59 @@ font-weight: 700; } +.project-conflict { + display: grid; + grid-template-columns: minmax(260px, 0.75fr) minmax(280px, 1fr) auto; + gap: 24px; + align-items: center; + margin: 24px 0 0; + padding: 20px; + border: 2px solid #b42318; + background: #fff4f2; +} + +.project-conflict h2, +.project-conflict p { + margin: 0; +} + +.project-conflict > div:first-child p { + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.project-conflict > div:first-child span { + display: inline-block; + margin: 8px 14px 0 0; + font-family: Consolas, monospace; + font-size: 12px; +} + +.project-conflict-actions { + display: grid; + min-width: 190px; + gap: 8px; +} + +.project-conflict button { + min-height: 42px; + padding: 9px 14px; + border: 1px solid #111111; + border-radius: 0; + background: #ffffff; + font-weight: 800; +} + +.project-conflict button:first-child:not(:disabled) { + background: #f2f500; +} + +.project-conflict > strong { + grid-column: 1 / -1; + color: #8f1d14; +} + .project-identity { display: grid; grid-template-columns: 240px minmax(0, 1fr); @@ -661,6 +714,25 @@ margin: 0; } +.save-status { + display: inline-block; + margin-top: 8px; + padding-left: 9px; + border-left: 3px solid #2f7d4a; + font-size: 12px; +} + +.save-status.dirty, +.save-status.saving { + border-color: #8a6a00; +} + +.save-status.failed, +.save-status.conflicted { + border-color: #b42318; + color: #8f1d14; +} + .project-identity p { margin-top: 4px; color: #6b6b65; @@ -830,6 +902,61 @@ color: #65655f; } +.project-leave-overlay { + position: fixed; + z-index: 30; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgb(17 17 17 / 58%); +} + +.project-leave-dialog { + width: min(560px, 100%); + padding: 28px; + border: 2px solid #111111; + background: #ffffff; + box-shadow: 10px 10px 0 #f2f500; +} + +.project-leave-dialog > p { + margin: 0 0 8px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.project-leave-dialog > h2 { + margin: 0 0 8px; +} + +.project-leave-dialog > div { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-top: 22px; +} + +.project-leave-dialog button { + min-height: 44px; + padding: 9px; + border: 1px solid #111111; + border-radius: 0; + background: #ffffff; + font-weight: 800; +} + +.project-leave-dialog button:first-child { + background: #f2f500; +} + +.project-leave-dialog > strong { + display: block; + margin-top: 14px; + color: #8f1d14; +} + .local-only-footer { display: grid; width: 100%; @@ -972,6 +1099,14 @@ justify-self: start; } + .project-conflict { + grid-template-columns: 1fr; + } + + .project-conflict-actions { + min-width: 0; + } + .project-identity { display: grid; gap: 14px; @@ -981,6 +1116,10 @@ grid-template-columns: 1fr; } + .project-leave-dialog > div { + grid-template-columns: 1fr; + } + .project-actions { grid-template-columns: 1fr; } diff --git a/apps/web/src/project-pages.tsx b/apps/web/src/project-pages.tsx index 6895cab..805139c 100644 --- a/apps/web/src/project-pages.tsx +++ b/apps/web/src/project-pages.tsx @@ -1,4 +1,12 @@ -import { useEffect, useId, useMemo, useState, type FormEvent } from "react"; +import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react"; +import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts"; + +import { + ConflictExportGuard, + ProjectAutoSaveQueue, + ProjectStateConflict, + type ProjectSaveStatus, +} from "./project-autosave.js"; import "./project-pages.css"; @@ -31,6 +39,7 @@ interface ProjectListPayload { } interface ProjectDetailPayload extends ProjectSummary { + canvas_state: CanvasState; created_at: string; draft_prompt: string; generations: Array<{ @@ -45,6 +54,7 @@ interface ProjectDetailPayload extends ProjectSummary { images: Array<{ created_at: string; generation_id: string; image_id: string }>; pixel_height?: number; pixel_width?: number; + save_status: "saved"; } async function readJson(url: string, init?: RequestInit): Promise { @@ -61,6 +71,52 @@ function formatUpdatedAt(value: string) { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); } +function initialCanvasState(project: Pick): CanvasState { + return { + background: { + adjustments: { + brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", + saturation: 0, sharpness: 0, temperature: 0, + }, + asset_id: project.current_image_id, + }, + elements: [], + pixel_height: project.pixel_height ?? (project.ratio === "9:16" ? 1920 : project.ratio === "3:4" ? 1440 : 1080), + pixel_width: project.pixel_width ?? (project.ratio === "4:3" ? 1440 : 1080), + ratio: project.ratio, + schema_version: 1, + }; +} + +async function downloadConflictPng(canvasState: CanvasState, projectName: string) { + const canvas = document.createElement("canvas"); + canvas.width = canvasState.pixel_width; + canvas.height = canvasState.pixel_height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("canvas_unavailable"); + context.fillStyle = "#f6f6f4"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#d4d4cf"; + const stripe = canvas.width / 4; + for (let index = 0; index < 4; index += 1) { + if (index % 2 === 1) context.fillRect(index * stripe, 0, stripe, canvas.height); + } + context.fillStyle = "#111111"; + context.font = `900 ${Math.max(64, Math.floor(canvas.width / 7))}px Arial`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText("DADA", canvas.width / 2, canvas.height / 2); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((value) => value ? resolve(value) : reject(new Error("canvas_export_failed")), "image/png"); + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.download = `${projectName.trim().replace(/[\\/:*?"<>|]+/g, "-") || "Dada"}-本页版本.png`; + anchor.href = url; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + function ProductHeader({ current }: { current: "workspace" | "projects" }) { return (
@@ -351,9 +407,18 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) { const [session, setSession] = useState(); const [project, setProject] = useState(); const [name, setName] = useState(""); - const [savingName, setSavingName] = useState(false); - const [nameStatus, setNameStatus] = useState(""); + const [saveStatus, setSaveStatus] = useState("saved"); + const [conflictVersions, setConflictVersions] = useState<{ latest: number; page: number }>(); + const [conflictExportBusy, setConflictExportBusy] = useState(false); + const [conflictExportUsed, setConflictExportUsed] = useState(false); + const [conflictNotice, setConflictNotice] = useState(""); + const [pendingNavigation, setPendingNavigation] = useState(); + const [leaving, setLeaving] = useState(false); + const [leaveStatus, setLeaveStatus] = useState(""); const [loadingFailed, setLoadingFailed] = useState(false); + const queueRef = useRef(undefined); + const saveStatusRef = useRef("saved"); + const conflictExportGuard = useRef(new ConflictExportGuard()); useEffect(() => { let active = true; @@ -363,7 +428,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) { ]).then(([nextSession, nextProject]) => { if (!active) return; setSession(nextSession); - setProject(nextProject); + const normalizedProject = { + ...nextProject, + canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject), + save_status: nextProject.save_status ?? "saved", + }; + setProject(normalizedProject); setName(nextProject.name); }).catch((error) => { if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true); @@ -371,30 +441,134 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) { return () => { active = false; }; }, [projectId]); + useEffect(() => { + if (!project || !session) return; + const queue = new ProjectAutoSaveQueue({ + initialState: { canvas_state: project.canvas_state, name: project.name }, + initialVersion: project.state_version, + onConflict: (latestVersion) => setConflictVersions({ latest: latestVersion, page: queue.stateVersion }), + onSaved: (snapshot, stateVersion) => { + setProject((current) => current ? { + ...current, canvas_state: snapshot.canvas_state, name: snapshot.name, + save_status: "saved", state_version: stateVersion, + } : current); + setName(snapshot.name); + }, + onStatus: (status) => { + saveStatusRef.current = status; + setSaveStatus(status); + }, + save: async (snapshot, stateVersion, operationId) => { + const response = await fetch(`/api/v1/projects/${projectId}/state`, { + body: JSON.stringify(snapshot), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": operationId, + "If-Match": String(stateVersion), + "X-CSRF-Token": session.csrf_token, + }, + method: "PUT", + }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + throw new Error("session_invalid"); + } + if (response.status === 412) { + const conflict = await response.json() as { latest_state_version: number }; + throw new ProjectStateConflict(conflict.latest_state_version); + } + if (!response.ok) throw new Error("project_save_failed"); + const saved = await response.json() as { state_version: number }; + return { stateVersion: saved.state_version }; + }, + }); + queueRef.current = queue; + saveStatusRef.current = "saved"; + setSaveStatus("saved"); + return () => { + queue.dispose(); + if (queueRef.current === queue) queueRef.current = undefined; + }; + }, [project?.created_at, projectId, session?.csrf_token]); + + useEffect(() => { + const guardActive = () => ["dirty", "saving", "failed"].includes(saveStatusRef.current); + const beforeUnload = (event: BeforeUnloadEvent) => { + if (!guardActive()) return; + event.preventDefault(); + event.returnValue = ""; + void queueRef.current?.saveNow(); + }; + const interceptNavigation = (event: MouseEvent) => { + if (!guardActive() || event.defaultPrevented || event.button !== 0) return; + const target = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!(target instanceof HTMLAnchorElement) || target.target || target.download) return; + const destination = new URL(target.href, window.location.href); + if (destination.origin !== window.location.origin) return; + event.preventDefault(); + setLeaveStatus(""); + setPendingNavigation(destination.href); + }; + window.addEventListener("beforeunload", beforeUnload); + document.addEventListener("click", interceptNavigation, true); + return () => { + window.removeEventListener("beforeunload", beforeUnload); + document.removeEventListener("click", interceptNavigation, true); + }; + }, []); + + function updateName(value: string) { + if (!project || saveStatus === "conflicted") return; + setName(value); + const snapshot: ProjectEditableState = { canvas_state: project.canvas_state, name: value }; + queueRef.current?.commit(snapshot); + } + async function rename(event: FormEvent) { event.preventDefault(); - if (!session || !project || savingName || !name.trim()) return; - setSavingName(true); - setNameStatus(""); + if (!name.trim() || saveStatus === "conflicted") return; + await queueRef.current?.saveNow(); + } + + async function exportConflictVersion() { + if (!project || conflictExportBusy) return; + setConflictExportBusy(true); + setConflictNotice(""); try { - const result = await readJson<{ name: string; state_version: number }>(`/api/v1/projects/${projectId}`, { - body: JSON.stringify({ name }), headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token }, method: "PATCH", - }); - setProject({ ...project, name: result.name, state_version: result.state_version }); - setName(result.name); - setNameStatus("项目名已保存"); + await conflictExportGuard.current.run(() => downloadConflictPng(project.canvas_state, name)); + setConflictNotice("仅下载本页版本,未写入项目"); } catch { - setNameStatus("项目名保存失败"); + setConflictNotice("本页版本导出失败,未写入项目"); } finally { - setSavingName(false); + setConflictExportUsed(conflictExportGuard.current.used); + setConflictExportBusy(false); } } + async function saveAndLeave() { + if (!pendingNavigation) return; + setLeaving(true); + setLeaveStatus(""); + const saved = await queueRef.current?.saveNow(); + if (saved) window.location.assign(pendingNavigation); + else setLeaveStatus("保存未完成,仍停留在当前页面"); + setLeaving(false); + } + if (!project && !loadingFailed) return ; if (!project) { return

项目详情暂时无法读取。

返回项目
; } const atHistoryLimit = project.successful_image_count >= 10; + const conflicted = saveStatus === "conflicted"; + const saveLabel = { + conflicted: "版本冲突", + dirty: "有未保存修改", + failed: "未保存", + saved: "已保存", + saving: "正在保存", + }[saveStatus]; return (
@@ -405,12 +579,27 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {

{project.status === "failed_empty" ? "FAILED EMPTY" : project.status.toUpperCase()}

{project.name}

固定比例 {project.ratio}
+ {conflicted && conflictVersions ? ( +
+
+

STATE VERSION CONFLICT

+

版本冲突

+ 本页版本 {conflictVersions.page} + 最新版本 {conflictVersions.latest} +
+

此页面已转为只读。可本地导出当前页一次,然后刷新本机后端保存的最新版本。

+
+ + +
+ {conflictNotice ? {conflictNotice} : null} +
+ ) : null}
-

项目名称

state version {project.state_version}

+

项目名称

state version {project.state_version}

{saveLabel}
- setName(event.target.value)} value={name} /> - - {nameStatus ? {nameStatus} : null} + updateName(event.target.value)} value={name} /> +
@@ -418,12 +607,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {

当前底图

{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}
- - - + + +
{atHistoryLimit ?

请先删除一张非当前底图的历史图

: null} - {project.status === "failed_empty" ? 修改并重试 : null} + {project.status === "failed_empty" && !conflicted ? 修改并重试 : null}

生成历史

{project.successful_image_count} / 10 张成功图
@@ -442,6 +631,21 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
+ {pendingNavigation ? ( +
+
+

UNSAVED PROJECT

+

有未保存修改

+ 保存成功后才会离开当前项目。 +
+ + + +
+ {leaveStatus ? {leaveStatus} : null} +
+
+ ) : null} ); diff --git a/openapi/openapi.json b/openapi/openapi.json index 4e80e90..458068a 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -787,6 +787,407 @@ } ] }, + "CanvasBackgroundAdjustments": { + "additionalProperties": false, + "properties": { + "brightness": { + "maximum": 100, + "minimum": -100, + "type": "number" + }, + "contrast": { + "maximum": 100, + "minimum": -100, + "type": "number" + }, + "crop": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "height": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "width": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "x": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "y": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "height", + "width", + "x", + "y" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "filter": { + "maxLength": 64, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "fit": { + "anyOf": [ + { + "enum": [ + "fill" + ], + "type": "string" + }, + { + "enum": [ + "fit" + ], + "type": "string" + }, + { + "enum": [ + "crop" + ], + "type": "string" + } + ] + }, + "saturation": { + "maximum": 100, + "minimum": -100, + "type": "number" + }, + "sharpness": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "temperature": { + "maximum": 100, + "minimum": -100, + "type": "number" + } + }, + "required": [ + "brightness", + "contrast", + "crop", + "filter", + "fit", + "saturation", + "sharpness", + "temperature" + ], + "type": "object" + }, + "CanvasElement": { + "additionalProperties": false, + "properties": { + "colors": { + "items": { + "pattern": "^#[0-9A-Fa-f]{6}$", + "type": "string" + }, + "maxItems": 5, + "minItems": 5, + "type": "array" + }, + "content": { + "maxLength": 4000, + "type": "string" + }, + "coordinates": { + "additionalProperties": false, + "properties": { + "latitude": { + "maximum": 90, + "minimum": -90, + "type": "number" + }, + "longitude": { + "maximum": 180, + "minimum": -180, + "type": "number" + } + }, + "required": [ + "latitude", + "longitude" + ], + "type": "object" + }, + "created_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "dynamic_fields": { + "additionalProperties": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + "element_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "font_override": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$", + "type": "string" + }, + "font_size": { + "maximum": 512, + "minimum": 1, + "type": "number" + }, + "formatted_value": { + "maxLength": 4000, + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "position": { + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "type": "object" + }, + "resource_version": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$", + "type": "string" + }, + "rotation": { + "maximum": 360, + "minimum": -360, + "type": "number" + }, + "scale": { + "additionalProperties": false, + "properties": { + "x": { + "maximum": 100, + "minimum": 0.01, + "type": "number" + }, + "y": { + "maximum": 100, + "minimum": 0.01, + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "type": "object" + }, + "style_id": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$", + "type": "string" + }, + "style_parameters": { + "additionalProperties": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + "template_or_asset_id": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$", + "type": "string" + }, + "type": { + "anyOf": [ + { + "enum": [ + "text_template" + ], + "type": "string" + }, + { + "enum": [ + "static_sticker" + ], + "type": "string" + }, + { + "enum": [ + "color_card" + ], + "type": "string" + }, + { + "enum": [ + "dynamic_sticker" + ], + "type": "string" + } + ] + }, + "z_index": { + "maximum": 49, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "created_at", + "element_id", + "opacity", + "position", + "resource_version", + "rotation", + "scale", + "template_or_asset_id", + "type", + "z_index" + ], + "type": "object" + }, + "CanvasState": { + "additionalProperties": false, + "properties": { + "background": { + "additionalProperties": false, + "properties": { + "adjustments": { + "$ref": "#/components/schemas/CanvasBackgroundAdjustments" + }, + "asset_id": { + "anyOf": [ + { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "adjustments", + "asset_id" + ], + "type": "object" + }, + "elements": { + "items": { + "$ref": "#/components/schemas/CanvasElement" + }, + "maxItems": 50, + "type": "array" + }, + "pixel_height": { + "minimum": 1, + "type": "integer" + }, + "pixel_width": { + "minimum": 1, + "type": "integer" + }, + "ratio": { + "anyOf": [ + { + "enum": [ + "3:4" + ], + "type": "string" + }, + { + "enum": [ + "1:1" + ], + "type": "string" + }, + { + "enum": [ + "4:3" + ], + "type": "string" + }, + { + "enum": [ + "9:16" + ], + "type": "string" + } + ] + }, + "schema_version": { + "enum": [ + 1 + ], + "type": "number" + } + }, + "required": [ + "background", + "elements", + "pixel_height", + "pixel_width", + "ratio", + "schema_version" + ], + "type": "object" + }, "CorrelationId": { "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", "type": "string" @@ -1677,6 +2078,9 @@ "ProjectDetailResponse": { "additionalProperties": false, "properties": { + "canvas_state": { + "$ref": "#/components/schemas/CanvasState" + }, "created_at": { "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", "type": "string" @@ -1750,6 +2154,12 @@ "ratio": { "$ref": "#/components/schemas/ProjectRatio" }, + "save_status": { + "enum": [ + "saved" + ], + "type": "string" + }, "state_version": { "minimum": 1, "type": "integer" @@ -1778,12 +2188,32 @@ "status", "successful_image_count", "updated_at", + "canvas_state", "created_at", "draft_prompt", "generations", "images", "pixel_height", - "pixel_width" + "pixel_width", + "save_status" + ], + "type": "object" + }, + "ProjectEditableState": { + "additionalProperties": false, + "properties": { + "canvas_state": { + "$ref": "#/components/schemas/CanvasState" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "canvas_state", + "name" ], "type": "object" }, @@ -1944,6 +2374,72 @@ ], "type": "object" }, + "ProjectStateConflictResponse": { + "additionalProperties": false, + "properties": { + "latest_state_version": { + "minimum": 1, + "type": "integer" + }, + "save_status": { + "enum": [ + "conflicted" + ], + "type": "string" + } + }, + "required": [ + "latest_state_version", + "save_status" + ], + "type": "object" + }, + "ProjectStateSaveHeaders": { + "additionalProperties": true, + "properties": { + "idempotency-key": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "if-match": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "x-csrf-token": { + "maxLength": 512, + "minLength": 32, + "type": "string" + } + }, + "required": [ + "idempotency-key", + "if-match", + "x-csrf-token" + ], + "type": "object" + }, + "ProjectStateSaveResponse": { + "additionalProperties": false, + "properties": { + "save_status": { + "enum": [ + "saved" + ], + "type": "string" + }, + "state_version": { + "minimum": 2, + "type": "integer" + } + }, + "required": [ + "save_status", + "state_version" + ], + "type": "object" + }, "ProjectSummary": { "additionalProperties": false, "properties": { @@ -4628,6 +5124,132 @@ ] } }, + "/api/v1/projects/{projectId}/state": { + "put": { + "operationId": "saveProjectState", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "if-match", + "required": true, + "schema": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 32, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectEditableState" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStateSaveResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStateConflictResponse" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + } + }, "/api/v1/projects/failed-empty/trash": { "post": { "operationId": "trashFailedEmptyProjects", diff --git a/package.json b/package.json index 2e748e7..86927cf 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -53,7 +53,9 @@ "test:wp1-06": "node scripts/run-wp1-06-validation.mjs", "test:wp1-06:red": "node scripts/run-wp1-06-validation.mjs --phase red", "test:wp2-01": "node scripts/run-wp2-01-validation.mjs", - "test:wp2-01:red": "node scripts/run-wp2-01-validation.mjs --phase red" + "test:wp2-01:red": "node scripts/run-wp2-01-validation.mjs --phase red", + "test:wp2-02": "node scripts/run-wp2-02-validation.mjs", + "test:wp2-02:red": "node scripts/run-wp2-02-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/canvas.ts b/packages/shared-contracts/src/canvas.ts new file mode 100644 index 0000000..3d7584d --- /dev/null +++ b/packages/shared-contracts/src/canvas.ts @@ -0,0 +1,129 @@ +import { Type, type Static } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; +const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"; +const stableResourcePattern = "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$"; + +export const CanvasBackgroundAdjustmentsSchema = Type.Object( + { + brightness: Type.Number({ maximum: 100, minimum: -100 }), + contrast: Type.Number({ maximum: 100, minimum: -100 }), + crop: Type.Union([ + Type.Object({ + height: Type.Number({ maximum: 1, minimum: 0 }), + width: Type.Number({ maximum: 1, minimum: 0 }), + x: Type.Number({ maximum: 1, minimum: 0 }), + y: Type.Number({ maximum: 1, minimum: 0 }), + }, { additionalProperties: false }), + Type.Null(), + ]), + filter: Type.String({ maxLength: 64, pattern: "^[A-Za-z0-9_-]+$" }), + fit: Type.Union([Type.Literal("fill"), Type.Literal("fit"), Type.Literal("crop")]), + saturation: Type.Number({ maximum: 100, minimum: -100 }), + sharpness: Type.Number({ maximum: 100, minimum: 0 }), + temperature: Type.Number({ maximum: 100, minimum: -100 }), + }, + { additionalProperties: false, $id: "CanvasBackgroundAdjustments" }, +); + +const CanvasScalarSchema = Type.Union([ + Type.String({ maxLength: 4_000 }), Type.Number(), Type.Boolean(), Type.Null(), +]); + +export const CanvasElementSchema = Type.Object( + { + colors: Type.Optional(Type.Array(Type.String({ pattern: "^#[0-9A-Fa-f]{6}$" }), { maxItems: 5, minItems: 5 })), + content: Type.Optional(Type.String({ maxLength: 4_000 })), + coordinates: Type.Optional(Type.Object({ + latitude: Type.Number({ maximum: 90, minimum: -90 }), + longitude: Type.Number({ maximum: 180, minimum: -180 }), + }, { additionalProperties: false })), + created_at: Type.String({ pattern: isoTimestampPattern }), + dynamic_fields: Type.Optional(Type.Record(Type.String({ pattern: "^[A-Za-z][A-Za-z0-9_]{0,63}$" }), CanvasScalarSchema)), + element_id: Type.String({ pattern: uuidPattern }), + font_override: Type.Optional(Type.String({ maxLength: 120, pattern: stableResourcePattern })), + font_size: Type.Optional(Type.Number({ maximum: 512, minimum: 1 })), + formatted_value: Type.Optional(Type.String({ maxLength: 4_000 })), + opacity: Type.Number({ maximum: 1, minimum: 0 }), + position: Type.Object({ x: Type.Number(), y: Type.Number() }, { additionalProperties: false }), + resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }), + rotation: Type.Number({ maximum: 360, minimum: -360 }), + scale: Type.Object({ + x: Type.Number({ maximum: 100, minimum: 0.01 }), + y: Type.Number({ maximum: 100, minimum: 0.01 }), + }, { additionalProperties: false }), + style_id: Type.Optional(Type.String({ maxLength: 120, pattern: stableResourcePattern })), + style_parameters: Type.Optional(Type.Record(Type.String({ pattern: "^[A-Za-z][A-Za-z0-9_]{0,63}$" }), CanvasScalarSchema)), + template_or_asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }), + type: Type.Union([ + Type.Literal("text_template"), Type.Literal("static_sticker"), + Type.Literal("color_card"), Type.Literal("dynamic_sticker"), + ]), + z_index: Type.Integer({ maximum: 49, minimum: 0 }), + }, + { additionalProperties: false, $id: "CanvasElement" }, +); + +export const CanvasStateSchema = Type.Object( + { + background: Type.Object({ + adjustments: Type.Ref(CanvasBackgroundAdjustmentsSchema), + asset_id: Type.Union([Type.String({ pattern: uuidPattern }), Type.Null()]), + }, { additionalProperties: false }), + elements: Type.Array(Type.Ref(CanvasElementSchema), { maxItems: 50 }), + pixel_height: Type.Integer({ minimum: 1 }), + pixel_width: Type.Integer({ minimum: 1 }), + ratio: Type.Union([Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")]), + schema_version: Type.Literal(1), + }, + { additionalProperties: false, $id: "CanvasState" }, +); + +export const ProjectEditableStateSchema = Type.Object( + { + canvas_state: Type.Ref(CanvasStateSchema), + name: Type.String({ maxLength: 80, minLength: 1 }), + }, + { additionalProperties: false, $id: "ProjectEditableState" }, +); + +export const ProjectStateSaveHeadersSchema = Type.Object( + { + "idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }), + "if-match": Type.String({ pattern: "^[1-9][0-9]*$" }), + "x-csrf-token": Type.String({ maxLength: 512, minLength: 32 }), + }, + { additionalProperties: true, $id: "ProjectStateSaveHeaders" }, +); + +export const ProjectStateSaveResponseSchema = Type.Object( + { save_status: Type.Literal("saved"), state_version: Type.Integer({ minimum: 2 }) }, + { additionalProperties: false, $id: "ProjectStateSaveResponse" }, +); + +export const ProjectStateConflictResponseSchema = Type.Object( + { latest_state_version: Type.Integer({ minimum: 1 }), save_status: Type.Literal("conflicted") }, + { additionalProperties: false, $id: "ProjectStateConflictResponse" }, +); + +export type CanvasState = Static; +export type ProjectEditableState = Static; +export type ProjectStateSaveHeaders = Static; + +export function isCanvasState(value: unknown): value is CanvasState { + if (!Value.Check(CanvasStateSchema, [CanvasBackgroundAdjustmentsSchema, CanvasElementSchema], value)) return false; + const state = value as CanvasState; + const ids = new Set(state.elements.map((element) => element.element_id)); + const zIndexes = new Set(state.elements.map((element) => element.z_index)); + return ids.size === state.elements.length && zIndexes.size === state.elements.length; +} + +export function isProjectEditableState(value: unknown): value is ProjectEditableState { + return Value.Check( + ProjectEditableStateSchema, + [CanvasBackgroundAdjustmentsSchema, CanvasElementSchema, CanvasStateSchema], + value, + ) + && isCanvasState((value as ProjectEditableState).canvas_state); +} diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index 6605f2d..162331a 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -2,6 +2,7 @@ export { Type } from "@sinclair/typebox"; export * from "./api.js"; export * from "./auth.js"; export * from "./bootstrap.js"; +export * from "./canvas.js"; export * from "./events.js"; export * from "./projects.js"; export * from "./registration-notice.js"; diff --git a/packages/shared-contracts/src/projects.ts b/packages/shared-contracts/src/projects.ts index 7352007..a14f7ef 100644 --- a/packages/shared-contracts/src/projects.ts +++ b/packages/shared-contracts/src/projects.ts @@ -1,6 +1,7 @@ import { Type, type Static } from "@sinclair/typebox"; import { GenerationErrorCategorySchema } from "./api.js"; +import { CanvasStateSchema } from "./canvas.js"; const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"; @@ -71,12 +72,14 @@ export const ProjectImageItemSchema = Type.Object( export const ProjectDetailResponseSchema = Type.Object( { ...ProjectSummarySchema.properties, + canvas_state: Type.Ref(CanvasStateSchema), created_at: Type.String({ pattern: isoTimestampPattern }), draft_prompt: Type.String({ maxLength: 4_000, minLength: 1 }), generations: Type.Array(Type.Ref(GenerationProjectItemSchema)), images: Type.Array(Type.Ref(ProjectImageItemSchema), { maxItems: 10 }), pixel_height: Type.Integer({ minimum: 1 }), pixel_width: Type.Integer({ minimum: 1 }), + save_status: Type.Literal("saved"), }, { additionalProperties: false, $id: "ProjectDetailResponse" }, ); diff --git a/scripts/run-wp2-02-validation.mjs b/scripts/run-wp2-02-validation.mjs new file mode 100644 index 0000000..5a4f31e --- /dev/null +++ b/scripts/run-wp2-02-validation.mjs @@ -0,0 +1,129 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp2-02-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const casesRoot = resolve(runDirectory, "cases"); +const playwrightDirectory = resolve(runDirectory, "playwright"); +const caseIds = [ + "TDD-WP2-PROJ-002-cas-success", + "TDD-WP2-PROJ-002-debounce", + "TDD-WP2-PROJ-002-save-failure-recovery", + "TDD-WP2-PROJ-004-conflict-export-once", + "TDD-WP2-PROJ-004-stale-tab", +]; +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const caseId of caseIds) mkdirSync(resolve(casesRoot, caseId), { recursive: true }); + +const commandsToRun = phase === "red" + ? [ + ["project-state-unit", ["exec", "vitest", "run", "tests/unit/wp2-02-autosave.test.ts"]], + ["project-state-api", ["exec", "vitest", "run", "tests/api/wp2-02-project-state.test.ts"]], + ["project-state-e2e", ["exec", "playwright", "test", "tests/e2e/project-autosave-conflict.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["unit", ["test:unit"]], + ["api", ["test:api"]], + ["e2e", ["test:e2e"]], + ["tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_PROJECT_STATE: casesRoot, + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, +}; +const commandResults = []; +for (const [name, args] of commandsToRun) { + const command = `pnpm ${args.join(" ")}`; + const started_at = new Date().toISOString(); + const execution = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} + +function find(root, name) { + if (!existsSync(root)) return []; + return readdirSync(root).flatMap((entry) => { + const child = resolve(root, entry); + return statSync(child).isDirectory() ? find(child, name) : entry === name ? [child] : []; + }); +} + +if (phase === "green") { + const traces = find(playwrightDirectory, "trace.zip"); + const recoveryTrace = traces.find((path) => path.includes("browser-draft-persistence")); + const conflictTrace = traces.find((path) => path.includes("consumes-one-local-export")); + if (recoveryTrace) { + copyFileSync(recoveryTrace, resolve(casesRoot, caseIds[1], "trace.zip")); + copyFileSync(recoveryTrace, resolve(casesRoot, caseIds[2], "trace.zip")); + } + if (conflictTrace) { + copyFileSync(conflictTrace, resolve(casesRoot, caseIds[3], "trace.zip")); + copyFileSync(conflictTrace, resolve(casesRoot, caseIds[4], "trace.zip")); + } +} + +for (const caseId of caseIds) { + writeFileSync(resolve(casesRoot, caseId, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +} +const evidenceByCase = { + "TDD-WP2-PROJ-002-cas-success": ["request.json", "response.json", "db-diff.json"], + "TDD-WP2-PROJ-002-debounce": ["network-timeline.json", "response.json", "db-diff.json", "trace.zip"], + "TDD-WP2-PROJ-002-save-failure-recovery": ["response.json", "db-diff.json", "cache-enumeration.json", "trace.zip", "screenshots/save-failed.png"], + "TDD-WP2-PROJ-004-conflict-export-once": ["network-timeline.json", "db-diff.json", "trace.zip", "screenshots/conflict-export.png"], + "TDD-WP2-PROJ-004-stale-tab": ["response.json", "db-diff.json", "trace.zip", "screenshots/conflicted.png"], +}; +const commandState = phase === "red" + ? commandResults.every((result) => result.exit_code !== 0) + : commandResults.every((result) => result.exit_code === 0); +if (phase === "red") { + for (const caseId of caseIds) { + writeFileSync(resolve(casesRoot, caseId, "red-observation.json"), `${JSON.stringify({ + expected_failure: caseId.includes("PROJ-002") + ? "Canvas schema, serial autosave, failure recovery and CAS are absent" + : "stale-tab conflict lock and one-use local export are absent", + status: commandState ? "red_confirmed" : "failed", + }, null, 2)}\n`); + } +} +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), +}; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const metadata = { + "TDD-WP2-PROJ-002-cas-success": { acceptance_criteria: ["AC-20"], layer: ["API"], requirements: ["PROJECT-04"] }, + "TDD-WP2-PROJ-002-debounce": { acceptance_criteria: ["AC-20"], layer: ["UNIT", "E2E"], requirements: ["PROJECT-04", "PROJECT-06"] }, + "TDD-WP2-PROJ-002-save-failure-recovery": { acceptance_criteria: ["AC-20", "AC-35"], layer: ["UNIT", "E2E"], requirements: ["PROJECT-04", "PROJECT-05", "EXPORT-06"] }, + "TDD-WP2-PROJ-004-conflict-export-once": { acceptance_criteria: ["AC-36"], layer: ["E2E"], requirements: ["PROJECT-07", "EXPORT-05"] }, + "TDD-WP2-PROJ-004-stale-tab": { acceptance_criteria: ["AC-36"], layer: ["API", "E2E"], requirements: ["PROJECT-07"] }, +}; +const results = caseIds.map((testId) => { + const directory = resolve(casesRoot, testId); + const evidence_refs = phase === "red" ? ["red-observation.json"] : evidenceByCase[testId]; + const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directory, file))); + const status = commandState && missing_evidence.length === 0 ? (phase === "red" ? "red_confirmed" : "passed") : "failed"; + const result = { + ...metadata[testId], automation: ["automated"], commit, evidence_refs, manifest, missing_evidence, phase, + run_id: runId, status, task_id: "TASK-WP2-02", test_id: testId, work_package: "WP-2", + worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +}); +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +const passed = results.every((result) => result.status === targetStatus); +const summary = { + cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), + phase, run_id: runId, status: passed ? targetStatus : "failed", +}; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!passed) process.exit(1); diff --git a/tests/api/wp2-02-project-state.test.ts b/tests/api/wp2-02-project-state.test.ts new file mode 100644 index 0000000..cd9281c --- /dev/null +++ b/tests/api/wp2-02-project-state.test.ts @@ -0,0 +1,131 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { ProjectService } from "../../apps/api/src/projects.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const now = Date.parse("2026-08-02T08:00:00.000Z"); +const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; +const roots: string[] = []; +const registrations: RegistrationService[] = []; +const projects: ProjectService[] = []; + +const canvas = { + background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null }, + elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, +}; + +function harness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp2-02-api-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x31), clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath, + invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33), + }); + registrations.push(registration); + const projectService = new ProjectService({ clock: () => now, databasePath }); + projects.push(projectService); + const ownerId = randomUUID(); + registration.database.prepare(`INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at + ) VALUES (?, 'state@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'State User', '@state_user')") + .run(ownerId); + registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)") + .run(ownerId, now); + const session = registration.issueAuthenticatedSession(ownerId, "user"); + return { ownerId, projectService, registration, session }; +} + +function writeEvidence(caseId: string, file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; + if (!root) return; + const directory = resolve(root, caseId); + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => { + for (const project of projects.splice(0)) project.close(); + for (const registration of registrations.splice(0)) registration.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +async function authenticatedApp() { + const fixture = harness(); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, projects: fixture.projectService, registration: fixture.registration }); + const cookie = `dada_session=${fixture.session.sessionToken}`; + const session = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/auth/session" }); + return { ...fixture, app, cookie, csrf: session.json().csrf_token as string }; +} + +describe("TDD-WP2-PROJ-002-cas-success", () => { + it("validates a complete Canvas snapshot and replays one idempotent CAS result without incrementing twice", async () => { + const fixture = await authenticatedApp(); + const created = fixture.projectService.createProjectForGeneration({ ownerId: fixture.ownerId, prompt: "CAS 项目", ratio: "3:4", status: "failed" }); + const url = `/api/v1/projects/${created.project.projectId}/state`; + const headers = { ...baseHeaders, cookie: fixture.cookie, "idempotency-key": "wp2-02-cas-key-00000000000000010", "if-match": "1", "x-csrf-token": fixture.csrf }; + const payload = { canvas_state: canvas, name: "CAS 保存成功" }; + + const first = await fixture.app.inject({ headers, method: "PUT", payload, url }); + const replay = await fixture.app.inject({ headers, method: "PUT", payload, url }); + expect(first.statusCode).toBe(200); + expect(first.json()).toEqual({ save_status: "saved", state_version: 2 }); + expect(replay.statusCode).toBe(200); + expect(replay.json()).toEqual(first.json()); + + const foreign = fixture.projectService.createProjectForGeneration({ ownerId: randomUUID(), prompt: "他人底图", ratio: "3:4", status: "running" }); + const foreignImageId = randomUUID(); + fixture.projectService.recordSuccessfulImage({ generationId: foreign.generation.generationId, imageId: foreignImageId }); + const foreignReference = await fixture.app.inject({ + headers: { ...headers, "idempotency-key": "wp2-02-foreign-000000000000000000", "if-match": "2" }, method: "PUT", + payload: { canvas_state: { ...canvas, background: { ...canvas.background, asset_id: foreignImageId } }, name: "非法引用" }, url, + }); + expect(foreignReference.statusCode).toBe(400); + + const detail = await fixture.app.inject({ headers: { ...baseHeaders, cookie: fixture.cookie }, method: "GET", url: `/api/v1/projects/${created.project.projectId}` }); + expect(detail.json()).toMatchObject({ canvas_state: canvas, name: "CAS 保存成功", save_status: "saved", state_version: 2 }); + expect(fixture.projectService.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(created.project.projectId)) + .toEqual({ count: 2 }); + writeEvidence("TDD-WP2-PROJ-002-cas-success", "request.json", { + expected_state_version: 1, payload, route: "/api/v1/projects/{projectId}/state", + }); + writeEvidence("TDD-WP2-PROJ-002-cas-success", "response.json", { first: first.json(), replay: replay.json() }); + writeEvidence("TDD-WP2-PROJ-002-cas-success", "db-diff.json", { + foreign_reference_write: 0, project_state_rows: 2, state_version_delta: 1, + }); + await fixture.app.close(); + }, 15_000); +}); + +describe("TDD-WP2-PROJ-004-stale-tab", () => { + it("returns 412 with only the latest safe version and leaves the stale tab without any write", async () => { + const fixture = await authenticatedApp(); + const created = fixture.projectService.createProjectForGeneration({ ownerId: fixture.ownerId, prompt: "多标签", ratio: "3:4", status: "failed" }); + const url = `/api/v1/projects/${created.project.projectId}/state`; + const request = (name: string, key: string) => fixture.app.inject({ + headers: { ...baseHeaders, cookie: fixture.cookie, "idempotency-key": key, "if-match": "1", "x-csrf-token": fixture.csrf }, + method: "PUT", payload: { canvas_state: canvas, name }, url, + }); + const newer = await request("标签 A", "wp2-02-tab-a-0000000000000000010"); + const stale = await request("标签 B", "wp2-02-tab-b-0000000000000000010"); + expect(newer.statusCode).toBe(200); + expect(stale.statusCode).toBe(412); + expect(stale.json()).toEqual({ latest_state_version: 2, save_status: "conflicted" }); + expect(fixture.projectService.getProject(fixture.ownerId, created.project.projectId)).toMatchObject({ name: "标签 A", stateVersion: 2 }); + expect(fixture.projectService.listProjects(fixture.ownerId, "active")).toHaveLength(1); + expect(fixture.projectService.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(created.project.projectId)) + .toEqual({ count: 2 }); + writeEvidence("TDD-WP2-PROJ-004-stale-tab", "response.json", { newer: newer.json(), stale: stale.json() }); + writeEvidence("TDD-WP2-PROJ-004-stale-tab", "db-diff.json", { project_count: 1, stale_write_delta: 0, state_rows: 2 }); + await fixture.app.close(); + }, 15_000); +}); diff --git a/tests/e2e/project-autosave-conflict.spec.ts b/tests/e2e/project-autosave-conflict.spec.ts new file mode 100644 index 0000000..909faec --- /dev/null +++ b/tests/e2e/project-autosave-conflict.spec.ts @@ -0,0 +1,167 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test, type BrowserContext, type Page } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +const projectId = "00000000-0000-4000-8000-000000000401"; +const canvasState = { + background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null }, + elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, +}; +const session = { + audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, + csrf_token: "csrf-project-state-fixture-000000000000000000000000000000000", + expires_at: "2026-09-02T08:00:00.000Z", + user: { creator_name: "State User", role: "user", social_id: "@state_user", status: "active", user_id: "00000000-0000-4000-8000-000000000402" }, +}; + +function projectPayload(name: string, version: number) { + return { + canvas_state: canvasState, created_at: "2026-08-02T08:00:00.000Z", current_image_id: null, + draft_prompt: "多标签项目", generations: [], images: [], name, pixel_height: 1440, pixel_width: 1080, + project_id: projectId, ratio: "3:4", save_status: "saved", state_version: version, + status: "failed_empty", successful_image_count: 0, updated_at: "2026-08-02T08:00:00.000Z", + }; +} + +async function routeSession(target: Page | BrowserContext) { + await target.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 })); +} + +function writeEvidence(caseId: string, file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; + if (!root) return; + const directory = resolve(root, caseId); + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +async function screenshot(page: Page, caseId: string, file: string) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; + if (!root) return; + const directory = resolve(root, caseId, "screenshots"); + mkdirSync(directory, { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(directory, file) }); +} + +test("TDD-WP2-PROJ-002 recovers a failed debounced save without browser draft persistence", async ({ context, page }) => { + await routeSession(context); + let backendName = "后端已保存"; + let backendVersion = 4; + await context.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload(backendName, backendVersion)), contentType: "application/json", status: 200 })); + const timeline: Array<{ name: string; version: string | undefined }> = []; + let attempts = 0; + let inFlight = 0; + let maxInFlight = 0; + let releaseRecovery!: () => void; + const recoveryGate = new Promise((resolveRecovery) => { + releaseRecovery = resolveRecovery; + }); + await context.route(`**/api/v1/projects/${projectId}/state`, async (route) => { + attempts += 1; + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + timeline.push({ name: route.request().postDataJSON().name, version: route.request().headers()["if-match"] }); + if (attempts === 1) await route.fulfill({ body: "null", contentType: "application/json", status: 503 }); + else { + await recoveryGate; + backendName = route.request().postDataJSON().name; + backendVersion += 1; + await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backendVersion }), contentType: "application/json", status: 200 }); + } + inFlight -= 1; + }); + await page.goto(`${webUrl}/app/projects/${projectId}`); + const input = page.getByRole("textbox", { name: "项目名称" }); + await input.fill("第一次"); + await input.fill("一秒内最终名称"); + await expect(page.getByText("未保存", { exact: true })).toBeVisible({ timeout: 4_000 }); + await screenshot(page, "TDD-WP2-PROJ-002-save-failure-recovery", "save-failed.png"); + await page.getByRole("link", { exact: true, name: "项目" }).click(); + const leaveDialog = page.getByRole("dialog", { name: "有未保存修改" }); + await expect(leaveDialog).toBeVisible(); + await expect(leaveDialog.getByRole("button", { name: "保存并离开" })).toBeVisible(); + await expect(leaveDialog.getByRole("button", { name: "放弃修改" })).toBeVisible(); + await leaveDialog.getByRole("button", { name: "取消" }).click(); + await expect(leaveDialog).toBeHidden(); + releaseRecovery(); + await expect(page.getByText("已保存", { exact: true })).toBeVisible({ timeout: 6_000 }); + expect(timeline).toEqual([ + { name: "一秒内最终名称", version: "4" }, + { name: "一秒内最终名称", version: "4" }, + ]); + expect(maxInFlight).toBe(1); + await input.fill("强制关闭前的未保存修改"); + await page.close(); + const reopened = await context.newPage(); + await reopened.goto(`${webUrl}/app/projects/${projectId}`); + await expect(reopened.getByRole("textbox", { name: "项目名称" })).toHaveValue("一秒内最终名称"); + await expect(reopened.getByText("state version 5")).toBeVisible(); + const storage = await reopened.evaluate(async () => ({ + cache_keys: "caches" in window ? await caches.keys() : [], + indexed_db: "databases" in indexedDB ? (await indexedDB.databases()).map((entry) => entry.name) : [], + local_storage: Object.keys(localStorage), + })); + expect(JSON.stringify(storage)).not.toContain("强制关闭前的未保存修改"); + writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "cache-enumeration.json", storage); + writeEvidence("TDD-WP2-PROJ-002-debounce", "network-timeline.json", { max_in_flight: maxInFlight, requests: timeline }); +}); + +test("TDD-WP2-PROJ-004 turns the stale tab read-only and consumes one local export", async ({ context }) => { + await routeSession(context); + let backendName = "共同版本"; + let backendVersion = 1; + const network: string[] = []; + await context.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload(backendName, backendVersion)), contentType: "application/json", status: 200 })); + await context.route(`**/api/v1/projects/${projectId}/state`, async (route) => { + network.push(route.request().url()); + const expected = Number(route.request().headers()["if-match"]); + if (expected !== backendVersion) { + await route.fulfill({ body: JSON.stringify({ latest_state_version: backendVersion, save_status: "conflicted" }), contentType: "application/json", status: 412 }); + return; + } + backendName = route.request().postDataJSON().name; + backendVersion += 1; + await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backendVersion }), contentType: "application/json", status: 200 }); + }); + const pageA = await context.newPage(); + const pageB = await context.newPage(); + await Promise.all([pageA.goto(`${webUrl}/app/projects/${projectId}`), pageB.goto(`${webUrl}/app/projects/${projectId}`)]); + await pageA.getByRole("textbox", { name: "项目名称" }).fill("标签 A 已保存"); + await expect.poll(() => backendVersion, { timeout: 4_000 }).toBe(2); + await expect(pageA.getByText("已保存", { exact: true })).toBeVisible({ timeout: 4_000 }); + await pageB.getByRole("textbox", { name: "项目名称" }).fill("标签 B 的本地版本"); + await expect(pageB.getByRole("heading", { name: "版本冲突" })).toBeVisible({ timeout: 4_000 }); + await expect(pageB.getByText("本页版本 1")).toBeVisible(); + await expect(pageB.getByText("最新版本 2")).toBeVisible(); + await expect(pageB.getByRole("textbox", { name: "项目名称" })).toBeDisabled(); + await expect(pageB.getByText("另存为")).toHaveCount(0); + await expect(pageB.getByRole("link", { name: "修改并重试" })).toHaveCount(0); + await screenshot(pageB, "TDD-WP2-PROJ-004-stale-tab", "conflicted.png"); + + const download = pageB.waitForEvent("download"); + await pageB.getByRole("button", { name: "本地导出本页版本" }).click(); + await download; + await expect(pageB.getByRole("button", { name: "本地导出本页版本" })).toBeDisabled(); + await expect(pageB.getByText("仅下载本页版本,未写入项目")).toBeVisible(); + expect(network.every((url) => !url.includes("latest") && !url.includes("export"))).toBe(true); + await screenshot(pageB, "TDD-WP2-PROJ-004-conflict-export-once", "conflict-export.png"); + writeEvidence("TDD-WP2-PROJ-004-stale-tab", "response.json", { latest_state_version: 2, save_status: "conflicted" }); + writeEvidence("TDD-WP2-PROJ-004-stale-tab", "db-diff.json", { stale_writes: 0, version: 2 }); + writeEvidence("TDD-WP2-PROJ-004-conflict-export-once", "network-timeline.json", { requests: network }); + writeEvidence("TDD-WP2-PROJ-004-conflict-export-once", "db-diff.json", { latest_export_delta: 0, project_delta: 0, state_delta: 0 }); +}); diff --git a/tests/unit/wp2-02-autosave.test.ts b/tests/unit/wp2-02-autosave.test.ts new file mode 100644 index 0000000..5f1a9e0 --- /dev/null +++ b/tests/unit/wp2-02-autosave.test.ts @@ -0,0 +1,160 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isCanvasState, type ProjectEditableState } from "../../packages/shared-contracts/src/canvas.js"; +import { + ConflictExportGuard, + ProjectAutoSaveQueue, + ProjectStateConflict, + SessionHistory, +} from "../../apps/web/src/project-autosave.js"; + +const canvas = { + background: { + adjustments: { + brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", + saturation: 0, sharpness: 0, temperature: 0, + }, + asset_id: null, + }, + elements: [], + pixel_height: 1440, + pixel_width: 1080, + ratio: "3:4", + schema_version: 1, +} as const; + +function state(name: string): ProjectEditableState { + return { canvas_state: structuredClone(canvas), name }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); + return { promise, reject, resolve }; +} + +function writeEvidence(caseId: string, file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; + if (!root) return; + const directory = resolve(root, caseId); + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => vi.useRealTimers()); + +describe("TDD-WP2-PROJ-002-debounce", () => { + it("debounces committed edits, keeps one request in flight, and sends only the latest full snapshot", async () => { + vi.useFakeTimers(); + const first = deferred<{ stateVersion: number }>(); + const second = deferred<{ stateVersion: number }>(); + const requests: Array<{ snapshot: ProjectEditableState; stateVersion: number }> = []; + const statuses: string[] = []; + const queue = new ProjectAutoSaveQueue({ + initialState: state("初始名称"), initialVersion: 3, + onStatus: (status) => statuses.push(status), + save: (snapshot, stateVersion) => { + requests.push({ snapshot: structuredClone(snapshot), stateVersion }); + return requests.length === 1 ? first.promise : second.promise; + }, + }); + + queue.commit(state("第一次编辑")); + await vi.advanceTimersByTimeAsync(500); + queue.commit(state("一秒内最终编辑")); + await vi.advanceTimersByTimeAsync(999); + expect(requests).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + expect(requests).toEqual([{ snapshot: state("一秒内最终编辑"), stateVersion: 3 }]); + + queue.commit(state("请求期间的最新编辑")); + await vi.advanceTimersByTimeAsync(2_000); + expect(requests).toHaveLength(1); + first.resolve({ stateVersion: 4 }); + await vi.advanceTimersByTimeAsync(999); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(requests[1]).toEqual({ snapshot: state("请求期间的最新编辑"), stateVersion: 4 }); + second.resolve({ stateVersion: 5 }); + await vi.runAllTimersAsync(); + + expect(queue.status).toBe("saved"); + expect(queue.stateVersion).toBe(5); + expect(statuses).toEqual(expect.arrayContaining(["dirty", "saving", "saved"])); + writeEvidence("TDD-WP2-PROJ-002-debounce", "network-timeline.json", { max_in_flight: 1, requests }); + writeEvidence("TDD-WP2-PROJ-002-debounce", "response.json", { final_status: queue.status, state_version: queue.stateVersion }); + writeEvidence("TDD-WP2-PROJ-002-debounce", "db-diff.json", { successful_version_delta: 2 }); + queue.dispose(); + }); + + it("validates normalized Canvas state and rejects Fabric-private fields or a fifty-first element", () => { + expect(isCanvasState(canvas)).toBe(true); + expect(isCanvasState({ ...canvas, _objects: [] })).toBe(false); + expect(isCanvasState({ ...canvas, elements: Array.from({ length: 51 }, (_, index) => ({ + created_at: "2026-08-02T08:00:00.000Z", element_id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + opacity: 1, position: { x: 0, y: 0 }, resource_version: "v1", rotation: 0, + scale: { x: 1, y: 1 }, template_or_asset_id: `STK${String(index).padStart(3, "0")}`, + type: "static_sticker", z_index: index, + })) })).toBe(false); + }); +}); + +describe("TDD-WP2-PROJ-002-save-failure-recovery", () => { + it("retries while the page lives and restores a saved session with an empty undo stack", async () => { + vi.useFakeTimers(); + let attempts = 0; + const statuses: string[] = []; + const queue = new ProjectAutoSaveQueue({ + initialState: state("后端已保存"), initialVersion: 7, + onStatus: (status) => statuses.push(status), retryDelaysMs: [1_000], + save: async () => { + attempts += 1; + if (attempts === 1) throw new Error("network unavailable"); + return { stateVersion: 8 }; + }, + }); + queue.commit(state("只在本页内存的修改")); + await vi.advanceTimersByTimeAsync(1_000); + expect(queue.status).toBe("failed"); + await vi.advanceTimersByTimeAsync(1_000); + expect(queue.status).toBe("saved"); + expect(attempts).toBe(2); + + const history = new SessionHistory(state("后端已保存")); + history.commit(state("会话修改")); + expect(history.undo()?.name).toBe("后端已保存"); + const reopened = new SessionHistory(state("后端已保存")); + expect(reopened.canUndo).toBe(false); + expect(reopened.canRedo).toBe(false); + expect(statuses).toContain("failed"); + writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "response.json", { attempts, statuses }); + writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "db-diff.json", { failed_delta: 0, recovered_delta: 1 }); + queue.dispose(); + }); +}); + +describe("TDD-WP2-PROJ-004-conflict-export-once", () => { + it("stops saving on conflict and consumes the local export allowance even when export fails", async () => { + vi.useFakeTimers(); + const queue = new ProjectAutoSaveQueue({ + initialState: state("旧标签"), initialVersion: 2, + save: async () => { throw new ProjectStateConflict(3); }, + }); + queue.commit(state("旧标签本地修改")); + await vi.advanceTimersByTimeAsync(1_000); + expect(queue.status).toBe("conflicted"); + queue.commit(state("冲突后不应发送")); + await vi.runAllTimersAsync(); + expect(queue.status).toBe("conflicted"); + + const guard = new ConflictExportGuard(); + await expect(guard.run(async () => { throw new Error("render failed"); })).rejects.toThrow("render failed"); + expect(guard.used).toBe(true); + await expect(guard.run(async () => undefined)).resolves.toBe(false); + queue.dispose(); + }); +});