feat: complete TASK-WP2-02 project autosave

This commit is contained in:
suyx
2026-08-02 18:28:07 +08:00
parent 4d38530361
commit da6fa25e60
17 changed files with 2268 additions and 35 deletions
+77
View File
@@ -15,6 +15,9 @@ import {
AdminLoginSendRequestSchema,
AdminSessionResponseSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
@@ -33,6 +36,7 @@ import {
FailedEmptyTrashResponseSchema,
GenerationProjectItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectIdSchema,
ProjectImageItemSchema,
ProjectListQuerySchema,
@@ -41,6 +45,9 @@ import {
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectStateConflictResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RegistrationCompleteHeadersSchema,
@@ -66,6 +73,8 @@ import {
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
type ProjectEditableState,
type ProjectStateSaveHeaders,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -196,6 +205,9 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
project_not_found: 404,
project_ratio_fixed: 409,
project_retry_not_allowed: 409,
project_state_conflict: 412,
project_state_idempotency_conflict: 409,
project_state_invalid: 400,
} as const;
return reply.code(mapping[error.code]).send(null);
}
@@ -221,6 +233,7 @@ function projectSummaryResponse(project: ProjectSummaryView) {
function projectDetailResponse(project: ProjectDetailView) {
return {
...projectSummaryResponse(project),
canvas_state: project.canvasState,
created_at: project.createdAt,
draft_prompt: project.draftPrompt,
generations: project.generations.map((generation) => ({
@@ -239,6 +252,7 @@ function projectDetailResponse(project: ProjectDetailView) {
})),
pixel_height: project.pixelHeight,
pixel_width: project.pixelWidth,
save_status: project.saveStatus,
};
}
@@ -336,6 +350,9 @@ export async function createApp(options: CreateAppOptions = {}) {
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
StateSseEventSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
@@ -350,8 +367,12 @@ export async function createApp(options: CreateAppOptions = {}) {
GenerationProjectItemSchema,
ProjectImageItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
]) {
@@ -1084,6 +1105,62 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.put(
"/api/v1/projects/:projectId/state",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectEditableStateSchema),
headers: Type.Ref(ProjectStateSaveHeadersSchema),
operationId: "saveProjectState",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectStateSaveResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Ref(ErrorEnvelopeSchema),
412: Type.Ref(ProjectStateConflictResponseSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const headers = request.headers as ProjectStateSaveHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const saved = options.projects.saveProjectState({
expectedStateVersion: Number(headers["if-match"]),
idempotencyKey: headers["idempotency-key"],
ownerId: owner.userId,
projectId: (request.params as ProjectParams).projectId,
state: request.body as ProjectEditableState,
});
return { save_status: "saved" as const, state_version: saved.stateVersion };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof ProjectError && error.code === "project_state_conflict") {
return reply.code(412).send({
latest_state_version: error.latestStateVersion ?? 1,
save_status: "conflicted" as const,
});
}
if (error instanceof ProjectError && error.code === "project_state_idempotency_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId: request.id }));
}
return projectFailure(reply, request.id, error);
}
},
);
app.patch(
"/api/v1/projects/:projectId",
{
+7 -2
View File
@@ -5,13 +5,18 @@ export type ProjectErrorCode =
| "project_name_invalid"
| "project_not_found"
| "project_ratio_fixed"
| "project_retry_not_allowed";
| "project_retry_not_allowed"
| "project_state_conflict"
| "project_state_invalid"
| "project_state_idempotency_conflict";
export class ProjectError extends Error {
readonly code: ProjectErrorCode;
readonly latestStateVersion: number | undefined;
constructor(code: ProjectErrorCode) {
constructor(code: ProjectErrorCode, latestStateVersion?: number) {
super(code);
this.code = code;
this.latestStateVersion = latestStateVersion;
}
}
+192 -6
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts";
import { ProjectError } from "./project-errors.js";
export { ProjectError } from "./project-errors.js";
@@ -62,6 +63,14 @@ interface ImageRow {
image_id: string;
}
interface ProjectStateRow {
canvas_json: string;
created_at: number;
name: string;
project_id: string;
state_version: number;
}
function isProjectRatio(value: string): value is ProjectRatio {
return projectRatios.includes(value as ProjectRatio);
}
@@ -105,6 +114,38 @@ function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
return {
background: {
adjustments: {
brightness: 0,
contrast: 0,
crop: null,
filter: "none",
fit: "fill",
saturation: 0,
sharpness: 0,
temperature: 0,
},
asset_id: assetId,
},
elements: [],
pixel_height: pixels.height,
pixel_width: pixels.width,
ratio,
schema_version: 1,
};
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
}
return JSON.stringify(value);
}
export class ProjectService {
readonly database: BetterSqlite3.Database;
private readonly clock: () => number;
@@ -149,6 +190,12 @@ export class ProjectService {
projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
pixels.width, pixels.height, now, now,
);
this.insertProjectState({
canvasState: defaultCanvasState(input.ratio, pixels, null),
name: defaultProjectName(prompt, now),
projectId,
stateVersion: 1,
}, now);
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
});
transaction.immediate();
@@ -201,6 +248,10 @@ export class ProjectService {
.get(input.generationId) as GenerationRow | undefined;
if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid");
if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit");
const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow;
const currentState = this.readProjectState(generation.project_id);
const nextCanvas = structuredClone(currentState.canvasState);
if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId;
this.database.prepare(`
UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
`).run(now, input.generationId);
@@ -212,6 +263,12 @@ export class ProjectService {
SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
WHERE project_id = ?
`).run(input.imageId, now, generation.project_id);
this.insertProjectState({
canvasState: nextCanvas,
name: project.name,
projectId: generation.project_id,
stateVersion: project.state_version + 1,
}, now);
});
transaction.immediate();
}
@@ -229,14 +286,85 @@ export class ProjectService {
renameProject(ownerId: string, projectId: string, name: string) {
const normalized = normalizeProjectName(name);
const now = this.clock();
const changed = this.database.prepare(`
UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
WHERE owner_id = ? AND project_id = ? AND status = 'active'
`).run(normalized, now, ownerId, projectId);
if (changed.changes !== 1) throw new ProjectError("project_not_found");
const transaction = this.database.transaction(() => {
const project = this.readOwnedProject(ownerId, projectId);
if (project.status !== "active") throw new ProjectError("project_not_found");
const currentState = this.readProjectState(projectId);
this.database.prepare(`
UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
WHERE owner_id = ? AND project_id = ? AND status = 'active'
`).run(normalized, now, ownerId, projectId);
this.insertProjectState({
canvasState: currentState.canvasState,
name: normalized,
projectId,
stateVersion: project.state_version + 1,
}, now);
});
transaction.immediate();
return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
}
saveProjectState(input: {
expectedStateVersion: number;
idempotencyKey: string;
ownerId: string;
projectId: string;
state: ProjectEditableState;
}) {
if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) {
throw new ProjectError("project_state_invalid");
}
const name = normalizeProjectName(input.state.name);
const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState;
const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex");
const now = this.clock();
let result!: { stateVersion: number };
const transaction = this.database.transaction(() => {
const replay = this.database.prepare(`
SELECT request_hash, response_state_version FROM project_state_idempotency
WHERE owner_id = ? AND project_id = ? AND idempotency_key = ?
`).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined;
if (replay) {
if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict");
result = { stateVersion: replay.response_state_version };
return;
}
const project = this.readOwnedProject(input.ownerId, input.projectId);
if (project.status !== "active") throw new ProjectError("project_not_found");
if (project.state_version !== input.expectedStateVersion) {
throw new ProjectError("project_state_conflict", project.state_version);
}
const canvas = state.canvas_state;
if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) {
throw new ProjectError("project_state_invalid");
}
if (canvas.background.asset_id) {
const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?")
.get(input.projectId, canvas.background.asset_id);
if (!owned) throw new ProjectError("project_state_invalid");
}
const nextVersion = project.state_version + 1;
const changed = this.database.prepare(`
UPDATE projects SET name = ?, state_version = ?, updated_at = ?
WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active'
`).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion);
if (changed.changes !== 1) {
const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version;
throw new ProjectError("project_state_conflict", latest);
}
this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now);
this.database.prepare(`
INSERT INTO project_state_idempotency (
owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at
) VALUES (?, ?, ?, ?, ?, ?)
`).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now);
result = { stateVersion: nextVersion };
});
transaction.immediate();
return result;
}
trashFailedEmpty(ownerId: string, projectIds: string[]) {
const uniqueIds = [...new Set(projectIds)];
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
@@ -288,14 +416,17 @@ export class ProjectService {
SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid
`).all(projectId) as ImageRow[];
const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
const projectState = this.readProjectState(projectId);
return {
...summary,
canvasState: projectState.canvasState,
createdAt: iso(row.created_at),
draftPrompt: row.draft_prompt,
generations: generations.map((generation) => this.generationView(generation)),
images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })),
pixelHeight: row.pixel_height,
pixelWidth: row.pixel_width,
saveStatus: "saved" as const,
};
}
@@ -347,6 +478,26 @@ export class ProjectService {
return row;
}
private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) {
this.database.prepare(`
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now);
}
private readProjectState(projectId: string) {
const row = this.database.prepare(`
SELECT project_id, state_version, name, canvas_json, created_at
FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1
`).get(projectId) as ProjectStateRow | undefined;
if (!row) throw new ProjectError("project_state_invalid");
const canvasState: unknown = JSON.parse(row.canvas_json);
if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid");
return { canvasState, name: row.name, stateVersion: row.state_version } as {
canvasState: CanvasState; name: string; stateVersion: number;
};
}
private successfulImageCount(projectId: string) {
const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
.get(projectId) as { count: number };
@@ -411,6 +562,26 @@ export class ProjectService {
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
) >= ${projectLimit}
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
CREATE TABLE IF NOT EXISTS project_states (
project_id TEXT NOT NULL,
state_version INTEGER NOT NULL CHECK (state_version >= 1),
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)),
created_at INTEGER NOT NULL,
PRIMARY KEY (project_id, state_version),
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC);
CREATE TABLE IF NOT EXISTS project_state_idempotency (
owner_id TEXT NOT NULL,
project_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2),
created_at INTEGER NOT NULL,
PRIMARY KEY (owner_id, project_id, idempotency_key),
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
);
DROP TRIGGER IF EXISTS projects_active_restore_limit;
CREATE TRIGGER projects_active_restore_limit
BEFORE UPDATE OF status ON projects
@@ -459,5 +630,20 @@ export class ProjectService {
WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
`);
const missingStates = this.database.prepare(`
SELECT p.* FROM projects p
WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id)
`).all() as ProjectRow[];
const insertBackfill = this.database.transaction(() => {
for (const project of missingStates) {
this.insertProjectState({
canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id),
name: project.name,
projectId: project.project_id,
stateVersion: project.state_version,
}, project.updated_at);
}
});
insertBackfill.immediate();
}
}
+10 -1
View File
@@ -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<ProjectRenameResponse>;
}
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
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<ProjectStateSaveResponse>;
}
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
+82
View File
@@ -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<string>;
"content"?: string;
"coordinates"?: {
"latitude": number;
"longitude": number;
};
"created_at": string;
"dynamic_fields"?: Record<string, never>;
"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<string, never>;
"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<CanvasElement>;
"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;
+187
View File
@@ -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<void>) {
if (this.used) return false;
this.used = true;
await action();
return true;
}
}
export class SessionHistory<T> {
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<boolean> | undefined;
private lastSaved: ProjectEditableState;
private pending: PendingSave | undefined;
private retryIndex = 0;
private timer: ReturnType<typeof setTimeout> | 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);
}
}
+139
View File
@@ -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;
}
+227 -23
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
@@ -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<ProjectDetailPayload, "current_image_id" | "pixel_height" | "pixel_width" | "ratio">): 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<Blob>((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 (
<header className="product-header">
@@ -351,9 +407,18 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<SessionPayload>();
const [project, setProject] = useState<ProjectDetailPayload>();
const [name, setName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameStatus, setNameStatus] = useState("");
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("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<string>();
const [leaving, setLeaving] = useState(false);
const [leaveStatus, setLeaveStatus] = useState("");
const [loadingFailed, setLoadingFailed] = useState(false);
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const saveStatusRef = useRef<ProjectSaveStatus>("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 <LoadingPage label="正在读取项目详情" />;
if (!project) {
return <main className="product-loading"><p role="alert"></p><a href="/app/projects"></a></main>;
}
const atHistoryLimit = project.successful_image_count >= 10;
const conflicted = saveStatus === "conflicted";
const saveLabel = {
conflicted: "版本冲突",
dirty: "有未保存修改",
failed: "未保存",
saved: "已保存",
saving: "正在保存",
}[saveStatus];
return (
<div className="product-page">
@@ -405,12 +579,27 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<div><p>{project.status === "failed_empty" ? "FAILED EMPTY" : project.status.toUpperCase()}</p><h1>{project.name}</h1></div>
<span> {project.ratio}</span>
</header>
{conflicted && conflictVersions ? (
<section className="project-conflict" aria-live="assertive">
<div>
<p>STATE VERSION CONFLICT</p>
<h2></h2>
<span> {conflictVersions.page}</span>
<span> {conflictVersions.latest}</span>
</div>
<p></p>
<div className="project-conflict-actions">
<button disabled={conflictExportBusy || conflictExportUsed} onClick={exportConflictVersion} type="button"></button>
<button onClick={() => window.location.reload()} type="button"></button>
</div>
{conflictNotice ? <strong role="status">{conflictNotice}</strong> : null}
</section>
) : null}
<section className="project-identity" aria-labelledby="rename-title">
<div><h2 id="rename-title"></h2><p>state version {project.state_version}</p></div>
<div><h2 id="rename-title"></h2><p>state version {project.state_version}</p><strong className={`save-status ${saveStatus}`} aria-live={saveStatus === "failed" || conflicted ? "assertive" : "polite"}>{saveLabel}</strong></div>
<form onSubmit={rename}>
<input aria-label="项目名称" maxLength={80} onChange={(event) => setName(event.target.value)} value={name} />
<button disabled={savingName || !name.trim() || name.trim() === project.name} type="submit">{savingName ? "保存中" : "保存名称"}</button>
{nameStatus ? <span role="status">{nameStatus}</span> : null}
<input aria-label="项目名称" disabled={conflicted} maxLength={80} onChange={(event) => updateName(event.target.value)} value={name} />
<button disabled={conflicted || saveStatus === "saving" || !name.trim() || (saveStatus === "saved" && name.trim() === project.name)} type="submit"></button>
</form>
</section>
<div className="project-detail-grid">
@@ -418,12 +607,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-actions">
<button disabled={atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={conflicted || !project.current_image_id} type="button"></button>
<button disabled={conflicted || !project.current_image_id} type="button"></button>
</div>
{atHistoryLimit ? <p className="project-blocker"></p> : null}
{project.status === "failed_empty" ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
{project.status === "failed_empty" && !conflicted ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
</section>
<section className="project-history" aria-labelledby="history-title">
<header><h2 id="history-title"></h2><strong>{project.successful_image_count} / 10 </strong></header>
@@ -442,6 +631,21 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
</section>
</div>
</main>
{pendingNavigation ? (
<div className="project-leave-overlay" role="presentation">
<section aria-labelledby="leave-project-title" aria-modal="true" className="project-leave-dialog" role="dialog">
<p>UNSAVED PROJECT</p>
<h2 id="leave-project-title"></h2>
<span></span>
<div>
<button disabled={leaving} onClick={saveAndLeave} type="button"></button>
<button disabled={leaving} onClick={() => window.location.assign(pendingNavigation)} type="button"></button>
<button disabled={leaving} onClick={() => setPendingNavigation(undefined)} type="button"></button>
</div>
{leaveStatus ? <strong role="alert">{leaveStatus}</strong> : null}
</section>
</div>
) : null}
<LocalOnlyFooter />
</div>
);
+623 -1
View File
@@ -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",
+4 -2
View File
@@ -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",
+129
View File
@@ -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<typeof CanvasStateSchema>;
export type ProjectEditableState = Static<typeof ProjectEditableStateSchema>;
export type ProjectStateSaveHeaders = Static<typeof ProjectStateSaveHeadersSchema>;
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);
}
+1
View File
@@ -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";
@@ -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" },
);
+129
View File
@@ -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);
+131
View File
@@ -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);
});
+167
View File
@@ -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<void>((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 });
});
+160
View File
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((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();
});
});