diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ab05ec9..2035b34 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -45,6 +45,9 @@ import { ProjectRatioSchema, ProjectRenameRequestSchema, ProjectRenameResponseSchema, + ProjectRestoreResponseSchema, + ProjectPurgeResponseSchema, + ProjectTrashResponseSchema, ProjectStateConflictResponseSchema, ProjectStateSaveHeadersSchema, ProjectStateSaveResponseSchema, @@ -370,6 +373,9 @@ export async function createApp(options: CreateAppOptions = {}) { ProjectEditableStateSchema, ProjectRenameRequestSchema, ProjectRenameResponseSchema, + ProjectTrashResponseSchema, + ProjectRestoreResponseSchema, + ProjectPurgeResponseSchema, ProjectStateSaveHeadersSchema, ProjectStateSaveResponseSchema, ProjectStateConflictResponseSchema, @@ -1247,6 +1253,67 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + const projectLifecycle = ( + action: "trash" | "restore" | "purge", + operationId: string, + responseSchema: typeof ProjectTrashResponseSchema | typeof ProjectRestoreResponseSchema | typeof ProjectPurgeResponseSchema, + ) => { + app.post( + `/api/v1/projects/:projectId/${action}`, + { + attachValidation: true, + schema: { + headers: Type.Ref(CsrfHeadersSchema), + operationId, + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(responseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 409: Type.Null(), + 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 csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const projectId = (request.params as ProjectParams).projectId; + if (action === "trash") { + const project = options.projects.trashProject(owner.userId, projectId); + return { deleted_at: project.deletedAt, project_id: projectId, purge_at: project.purgeAt, status: "trashed" as const }; + } + if (action === "restore") { + options.projects.restoreProject(owner.userId, projectId); + return { deleted_at: null, project_id: projectId, purge_at: null, status: "active" as const }; + } + options.projects.purgeProject(owner.userId, projectId); + return { project_id: projectId, status: "purged" as const }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : projectFailure(reply, request.id, error); + } + }, + ); + }; + + projectLifecycle("trash", "trashProject", ProjectTrashResponseSchema); + projectLifecycle("restore", "restoreProject", ProjectRestoreResponseSchema); + projectLifecycle("purge", "purgeProject", ProjectPurgeResponseSchema); + app.post( "/api/v1/support/check", { diff --git a/apps/api/src/projects.ts b/apps/api/src/projects.ts index 1a85e31..eef8fa1 100644 --- a/apps/api/src/projects.ts +++ b/apps/api/src/projects.ts @@ -14,6 +14,7 @@ export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const; export type ProjectRatio = typeof projectRatios[number]; export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; +export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference"; const ratioPixels: Record = { "3:4": { height: 1440, width: 1080 }, "1:1": { height: 1080, width: 1080 }, @@ -394,7 +395,100 @@ export class ProjectService { return { ignoredProjectIds, trashedProjectIds }; } + trashProject(ownerId: string, projectId: string) { + const now = this.clock(); + let purged = false; + let result!: ReturnType; + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status === "purged") throw new ProjectError("project_not_found"); + if (project.status === "trashed") { + if (project.purge_at !== null && project.purge_at <= now) { + this.transitionToPurged(project, now); + purged = true; + return; + } + result = this.projectSummary(project, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId)); + return; + } + this.database.prepare(` + UPDATE projects + SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND owner_id = ? AND status = 'active' + `).run(now, now + trashRetentionMilliseconds, now, projectId, ownerId); + const updated = this.readOwnedProject(ownerId, projectId); + result = this.projectSummary(updated, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId)); + }); + transaction.immediate(); + if (purged) throw new ProjectError("project_not_found"); + return result; + } + + restoreProject(ownerId: string, projectId: string) { + const now = this.clock(); + let purged = false; + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status === "purged") throw new ProjectError("project_not_found"); + if (project.status === "active") return; + if (project.purge_at !== null && project.purge_at <= now) { + this.transitionToPurged(project, now); + purged = true; + return; + } + const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(ownerId) as { count: number }; + if (active.count >= projectLimit) throw new ProjectError("project_active_limit"); + this.database.prepare(` + UPDATE projects + SET status = 'active', deleted_at = NULL, purge_at = NULL, updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND owner_id = ? AND status = 'trashed' + `).run(now, projectId, ownerId); + }); + transaction.immediate(); + if (purged) throw new ProjectError("project_not_found"); + return this.getProject(ownerId, projectId); + } + + purgeProject(ownerId: string, projectId: string) { + const now = this.clock(); + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status !== "trashed") throw new ProjectError("project_not_found"); + this.transitionToPurged(project, now); + }); + transaction.immediate(); + return { projectId, status: "purged" as const }; + } + + linkManagedResource(ownerId: string, projectId: string, managedFileId: string, resourceKind: ProjectManagedResourceKind) { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status === "purged" || !["derived", "export", "generated", "reference"].includes(resourceKind)) { + throw new ProjectError("project_not_found"); + } + if (!this.tableExists("managed_files") || !this.tableExists("project_asset_refs")) { + throw new ProjectError("project_state_invalid"); + } + const managed = this.database.prepare(` + SELECT file_id FROM managed_files WHERE file_id = ? AND owner_ref = ? AND status = 'committed' + `).get(managedFileId, ownerId); + if (!managed) throw new ProjectError("project_state_invalid"); + const now = this.clock(); + const transaction = this.database.transaction(() => { + this.database.prepare(` + INSERT OR IGNORE INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) + VALUES (?, ?, ?, ?) + `).run(projectId, managedFileId, resourceKind, now); + this.database.prepare(` + INSERT OR IGNORE INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) + VALUES (?, ?, 'project', ?) + `).run(`project:${projectId}:${managedFileId}`, managedFileId, iso(now)); + }); + transaction.immediate(); + } + listProjects(ownerId: string, status: "active" | "trashed") { + this.purgeExpiredProjects(ownerId); const rows = this.database.prepare(` SELECT p.*, (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, @@ -407,6 +501,7 @@ export class ProjectService { } getProject(ownerId: string, projectId: string) { + this.purgeExpiredProjects(ownerId, projectId); const row = this.readOwnedProject(ownerId, projectId); if (row.status === "purged") throw new ProjectError("project_not_found"); const generations = this.database.prepare(` @@ -504,6 +599,78 @@ export class ProjectService { return row.count; } + private latestGenerationStatus(projectId: string) { + const row = this.database.prepare(` + SELECT status FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1 + `).get(projectId) as { status: string } | undefined; + return row?.status ?? null; + } + + private tableExists(name: string) { + return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + } + + private purgeExpiredProjects(ownerId: string, projectId?: string) { + const now = this.clock(); + const transaction = this.database.transaction(() => { + const rows = this.database.prepare(` + SELECT * FROM projects + WHERE owner_id = ? AND status = 'trashed' AND purge_at <= ? AND (? IS NULL OR project_id = ?) + ORDER BY purge_at, project_id + `).all(ownerId, now, projectId ?? null, projectId ?? null) as ProjectRow[]; + for (const row of rows) this.transitionToPurged(row, now); + }); + transaction.immediate(); + } + + private transitionToPurged(project: ProjectRow, now: number) { + const changed = this.database.prepare(` + UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND status = 'trashed' + `).run(now, project.project_id); + if (changed.changes !== 1) return; + this.queueManagedProjectFiles(project.project_id, now); + this.database.prepare(` + INSERT OR IGNORE INTO project_cleanup_queue ( + cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL) + `).run( + randomUUID(), project.project_id, project.owner_id, + stableJson(["project_state", "generation", "generated_image", "reference", "location", "latest_export"]), now, + ); + } + + private queueManagedProjectFiles(projectId: string, now: number) { + if (!["managed_files", "file_cleanup_queue", "project_asset_refs"].every((table) => this.tableExists(table))) return; + const files = this.database.prepare(` + SELECT mf.file_id, mf.relative_path, mf.byte_size + FROM project_resource_files prf + JOIN managed_files mf ON mf.file_id = prf.managed_file_id + WHERE prf.project_id = ? AND mf.status = 'committed' + ORDER BY mf.file_id + `).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>; + for (const file of files) { + this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?") + .run(`project:${projectId}:${file.file_id}`); + const otherProject = this.database.prepare(` + SELECT 1 FROM project_resource_files prf + JOIN projects p ON p.project_id = prf.project_id + WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged' + LIMIT 1 + `).get(file.file_id, projectId); + const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id); + if (otherProject || otherReference) continue; + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'") + .run(iso(now), file.file_id); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) + `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now)); + } + } + private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) { const status: ProjectViewStatus = row.status === "trashed" ? "trashed" @@ -572,6 +739,26 @@ export class ProjectService { 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_resource_files ( + project_id TEXT NOT NULL, + managed_file_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('derived', 'export', 'generated', 'reference')), + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, managed_file_id), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id); + CREATE TABLE IF NOT EXISTS project_cleanup_queue ( + cleanup_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE, + owner_id TEXT NOT NULL, + resource_scope_json TEXT NOT NULL CHECK (json_valid(resource_scope_json)), + status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')), + created_at INTEGER NOT NULL, + completed_at INTEGER, + last_error TEXT + ); + CREATE INDEX IF NOT EXISTS project_cleanup_queue_status_created ON project_cleanup_queue(status, created_at, project_id); CREATE TABLE IF NOT EXISTS project_state_idempotency ( owner_id TEXT NOT NULL, project_id TEXT NOT NULL, diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 13171a1..f41f36d 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectStateSaveResponse, ProjectEditableState, 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, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -166,6 +166,13 @@ export async function logoutUser(options: ClientOptions = {}): Promise; } +export async function purgeProject(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -175,6 +182,13 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO return response.json() as Promise; } +export async function restoreProject(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -227,6 +241,13 @@ export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, op return response.json() as Promise; } +export async function trashProject(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/trash`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index edabd19..33e62dc 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -357,6 +357,11 @@ export type ProjectParams = { "projectId": ProjectId; }; +export type ProjectPurgeResponse = { + "project_id": ProjectId; + "status": "purged"; +}; + export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16"; export type ProjectRenameRequest = { @@ -369,6 +374,13 @@ export type ProjectRenameResponse = { "status": "renamed"; }; +export type ProjectRestoreResponse = { + "deleted_at": null; + "project_id": ProjectId; + "purge_at": null; + "status": "active"; +}; + export type ProjectStateConflictResponse = { "latest_state_version": number; "save_status": "conflicted"; @@ -398,6 +410,13 @@ export type ProjectSummary = { "updated_at": string; }; +export type ProjectTrashResponse = { + "deleted_at": string; + "project_id": ProjectId; + "purge_at": string; + "status": "trashed"; +}; + export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; export type RegistrationCompleteHeaders = { diff --git a/apps/web/src/project-pages.css b/apps/web/src/project-pages.css index 805a546..3c0d745 100644 --- a/apps/web/src/project-pages.css +++ b/apps/web/src/project-pages.css @@ -522,8 +522,8 @@ .project-card-body { display: grid; - min-height: 170px; - grid-template-rows: minmax(48px, auto) auto auto 1fr; + min-height: 198px; + grid-template-rows: minmax(48px, auto) auto auto auto 1fr; gap: 8px; padding: 16px; } @@ -568,6 +568,46 @@ font-weight: 800; } +.project-card-body .trash-expiry { + color: #8f1d14; + font-weight: 700; +} + +.project-card-body .project-card-actions { + display: flex; + align-self: end; + justify-content: flex-start; + gap: 8px; + flex-wrap: wrap; +} + +.project-card-actions a, +.project-card-actions button { + display: inline-grid; + min-height: 38px; + place-items: center; + padding: 7px 10px; + border: 1px solid #111111; + border-radius: 0; + color: #111111; + background: #ffffff; + font-size: 12px; + font-weight: 800; + text-decoration: none; +} + +.project-card-actions button:disabled { + border-color: #b2b2ac; + color: #777770; + background: #dfdfda; + cursor: not-allowed; +} + +.project-card[data-status="trashed"] .project-card-actions button:last-child:not(:disabled) { + border-color: #a52e24; + color: #8f1d14; +} + .projects-stale, .projects-notice { padding: 12px 14px; @@ -912,6 +952,55 @@ background: rgb(17 17 17 / 58%); } +.project-purge-overlay { + position: fixed; + z-index: 40; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgb(17 17 17 / 62%); +} + +.project-purge-dialog { + width: min(520px, 100%); + padding: 28px; + border: 2px solid #111111; + background: #ffffff; + box-shadow: 10px 10px 0 #d14a3b; +} + +.project-purge-dialog > p:first-child { + margin: 0 0 8px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.project-purge-dialog h2 { + margin: 0 0 10px; +} + +.project-purge-dialog > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 22px; +} + +.project-purge-dialog button { + min-height: 44px; + border: 1px solid #111111; + border-radius: 0; + background: #ffffff; + font-weight: 800; +} + +.project-purge-dialog button:first-child { + color: #ffffff; + background: #a52e24; +} + .project-leave-dialog { width: min(560px, 100%); padding: 28px; @@ -1120,6 +1209,10 @@ grid-template-columns: 1fr; } + .project-purge-dialog > div { + grid-template-columns: 1fr; + } + .project-actions { grid-template-columns: 1fr; } diff --git a/apps/web/src/project-pages.tsx b/apps/web/src/project-pages.tsx index 805139c..e05a304 100644 --- a/apps/web/src/project-pages.tsx +++ b/apps/web/src/project-pages.tsx @@ -262,8 +262,13 @@ export function WorkspacePage() { ); } -function ProjectCard({ project, selectable, selected, onSelect }: { +function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, onTrash, project, selectable, selected }: { + activeLimitReached?: boolean; + busy?: boolean; + onPurge?: (() => void) | undefined; + onRestore?: (() => void) | undefined; onSelect?: (selected: boolean) => void; + onTrash?: (() => void) | undefined; project: ProjectSummary; selectable?: boolean; selected?: boolean; @@ -284,8 +289,14 @@ function ProjectCard({ project, selectable, selected, onSelect }: {

{project.name}

{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}

{project.successful_image_count} 张成功图 · {project.ratio}

+ {project.status === "trashed" && project.purge_at ?

将在 {formatUpdatedAt(project.purge_at)} 永久删除

: null} - 打开 +
+ {project.status !== "trashed" ? 打开 : null} + {onTrash ? : null} + {onRestore ? : null} + {onPurge ? : null} +
); @@ -300,6 +311,8 @@ export function ProjectsPage() { const [selected, setSelected] = useState([]); const [loadingFailed, setLoadingFailed] = useState(false); const [notice, setNotice] = useState(""); + const [busyProjectId, setBusyProjectId] = useState(); + const [pendingPurge, setPendingPurge] = useState(); useEffect(() => { let active = true; @@ -346,6 +359,36 @@ export function ProjectsPage() { } } + async function runLifecycle(project: ProjectSummary, action: "trash" | "restore" | "purge") { + if (!session || busyProjectId) return; + setBusyProjectId(project.project_id); + try { + await readJson(`/api/v1/projects/${project.project_id}/${action}`, { + headers: { "X-CSRF-Token": session.csrf_token }, + method: "POST", + }); + setPayload((current) => current ? { + ...current, + active_count: action === "trash" + ? Math.max(0, current.active_count - 1) + : action === "restore" + ? Math.min(current.active_limit, current.active_count + 1) + : current.active_count, + projects: current.projects.filter((item) => item.project_id !== project.project_id), + } : current); + setNotice(action === "trash" + ? "项目已移入回收站" + : action === "restore" + ? "项目已恢复" + : "项目已永久删除,物理清理将在后台完成"); + if (action === "purge") setPendingPurge(undefined); + } catch { + setNotice("操作未完成,请重试"); + } finally { + setBusyProjectId(undefined); + } + } + if (!payload && !loadingFailed) return ; return ( @@ -371,6 +414,9 @@ export function ProjectsPage() { {loadingFailed ?

项目列表读取失败。{payload ? "当前显示上次读取的数据。" : ""}

: null} {notice ?

{notice}

: null} + {status === "trashed" && payload && payload.active_count === payload.active_limit ? ( +

活动项目已达 20 个,请先释放名额

+ ) : null} {visible.length === 0 ? (

{query ? "没有符合条件的项目" : status === "active" ? "还没有项目" : "回收站为空"}

@@ -385,6 +431,11 @@ export function ProjectsPage() { ? [...current, project.project_id] : current.filter((projectId) => projectId !== project.project_id))} project={project} + activeLimitReached={payload?.active_count === payload?.active_limit} + busy={busyProjectId === project.project_id} + onPurge={status === "trashed" ? () => setPendingPurge(project) : undefined} + onRestore={status === "trashed" ? () => runLifecycle(project, "restore") : undefined} + onTrash={status === "active" ? () => runLifecycle(project, "trash") : undefined} selectable={status === "active" && project.status === "failed_empty"} selected={selected.includes(project.project_id)} /> @@ -398,6 +449,19 @@ export function ProjectsPage() {
) : null} + {pendingPurge ? ( +
+
+

IRREVERSIBLE ACTION

+

永久删除项目

+

“{pendingPurge.name}”将立即无法恢复。关联数据和文件会进入后台物理清理队列。

+
+ + +
+
+
+ ) : null} ); diff --git a/apps/worker/src/project-purge-cleanup.ts b/apps/worker/src/project-purge-cleanup.ts new file mode 100644 index 0000000..7ad034a --- /dev/null +++ b/apps/worker/src/project-purge-cleanup.ts @@ -0,0 +1,241 @@ +import { randomUUID } from "node:crypto"; +import { rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { isAbsolute, relative, resolve } from "node:path"; + +import type BetterSqlite3 from "better-sqlite3"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +interface ProjectCleanupRow { + cleanup_id: string; + project_id: string; +} + +interface ExpiredProjectRow { + owner_id: string; + project_id: string; +} + +interface FileCleanupRow { + byte_size: number; + cleanup_id: string; + counts_toward_managed: 0 | 1; + managed_file_id: string | null; + relative_path: string; +} + +const resourceScope = JSON.stringify([ + "project_state", "generation", "generated_image", "reference", "location", "latest_export", +]); + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +export class ProjectPurgeCleanup { + private readonly clock: () => number; + private readonly dataRoot: string | undefined; + private readonly database: BetterSqlite3.Database; + + constructor(input: { clock?: () => number; dataRoot?: string; databasePath: string }) { + this.clock = input.clock ?? Date.now; + this.dataRoot = input.dataRoot ? resolve(input.dataRoot) : undefined; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("busy_timeout = 5000"); + } + + close() { + this.database.close(); + } + + run() { + const expired = this.sweepExpired(); + const relational = this.processPending(); + const physical = this.processFileCleanup(); + return { expired, physical, relational }; + } + + sweepExpired() { + if (!this.tableExists("projects") || !this.tableExists("project_cleanup_queue")) return 0; + const now = this.clock(); + const rows = this.database.prepare(` + SELECT project_id, owner_id FROM projects + WHERE status = 'trashed' AND purge_at <= ? + ORDER BY purge_at, project_id + `).all(now) as ExpiredProjectRow[]; + let expired = 0; + for (const row of rows) { + const transaction = this.database.transaction(() => { + const changed = this.database.prepare(` + UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND status = 'trashed' + `).run(now, row.project_id); + if (changed.changes !== 1) return false; + this.queueManagedFiles(row.project_id, now); + this.database.prepare(` + INSERT OR IGNORE INTO project_cleanup_queue ( + cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL) + `).run(randomUUID(), row.project_id, row.owner_id, resourceScope, now); + return true; + }); + if (transaction.immediate()) expired += 1; + } + return expired; + } + + processPending() { + if (!this.tableExists("project_cleanup_queue") || !this.tableExists("projects")) return { completed: 0, failed: 0 }; + const rows = this.database.prepare(` + SELECT cleanup_id, project_id FROM project_cleanup_queue + WHERE status IN ('pending', 'failed') ORDER BY created_at, project_id + `).all() as ProjectCleanupRow[]; + let completed = 0; + let failed = 0; + for (const row of rows) { + try { + const transaction = this.database.transaction(() => { + this.database.prepare("DELETE FROM projects WHERE project_id = ? AND status = 'purged'").run(row.project_id); + this.database.prepare(` + UPDATE project_cleanup_queue + SET status = 'completed', completed_at = ?, last_error = NULL + WHERE cleanup_id = ? + `).run(this.clock(), row.cleanup_id); + }); + transaction.immediate(); + completed += 1; + } catch { + this.database.prepare(` + UPDATE project_cleanup_queue SET status = 'failed', last_error = 'project_cleanup_failed' + WHERE cleanup_id = ? + `).run(row.cleanup_id); + failed += 1; + } + } + return { completed, failed }; + } + + processFileCleanup() { + if (!this.dataRoot || !this.tableExists("file_cleanup_queue") || !this.tableExists("managed_files")) { + return { completed: 0, failed: 0 }; + } + const rows = this.database.prepare(` + SELECT cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed + FROM file_cleanup_queue WHERE status IN ('pending', 'failed') ORDER BY created_at, cleanup_id + `).all() as FileCleanupRow[]; + let completed = 0; + let failed = 0; + for (const row of rows) { + try { + const path = this.resolveManagedPath(row.relative_path); + rmSync(path, { force: true }); + const transaction = this.database.transaction(() => { + if (row.counts_toward_managed === 1 && row.managed_file_id) { + if (this.tableExists("project_asset_refs")) { + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id); + } + if (this.tableExists("project_resource_files")) { + this.database.prepare("DELETE FROM project_resource_files WHERE managed_file_id = ?").run(row.managed_file_id); + } + if (this.tableExists("asset_cleanup_request_items")) { + const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?") + .all(row.managed_file_id) as Array<{ request_id: string }>; + this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id); + if (this.tableExists("asset_cleanup_requests")) { + for (const request of requests) { + const pending = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?") + .get(request.request_id) as { count: number }; + if (pending.count === 0) { + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'completed' WHERE request_id = ? AND status = 'queued'") + .run(request.request_id); + } + } + } + } + this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id); + if (this.tableExists("local_backend_storage_state")) this.decrementManagedCapacity(row.byte_size); + } + this.database.prepare(` + UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL + WHERE cleanup_id = ? + `).run(iso(this.clock()), row.cleanup_id); + }); + transaction.immediate(); + completed += 1; + } catch { + this.database.prepare(` + UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed' + WHERE cleanup_id = ? + `).run(row.cleanup_id); + failed += 1; + } + } + return { completed, failed }; + } + + private queueManagedFiles(projectId: string, now: number) { + if (!["project_resource_files", "managed_files", "project_asset_refs", "file_cleanup_queue"] + .every((table) => this.tableExists(table))) return; + const files = this.database.prepare(` + SELECT mf.file_id, mf.relative_path, mf.byte_size + FROM project_resource_files prf + JOIN managed_files mf ON mf.file_id = prf.managed_file_id + WHERE prf.project_id = ? AND mf.status = 'committed' + ORDER BY mf.file_id + `).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>; + for (const file of files) { + this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?") + .run(`project:${projectId}:${file.file_id}`); + const otherProject = this.database.prepare(` + SELECT 1 FROM project_resource_files prf + JOIN projects p ON p.project_id = prf.project_id + WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged' + LIMIT 1 + `).get(file.file_id, projectId); + const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id); + if (otherProject || otherReference) continue; + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'") + .run(iso(now), file.file_id); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) + `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now)); + } + } + + private tableExists(name: string) { + return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + } + + private resolveManagedPath(relativePath: string) { + const path = resolve(this.dataRoot!, relativePath); + const scoped = relative(this.dataRoot!, path); + if (!scoped || scoped.startsWith("..") || isAbsolute(scoped)) throw new Error("managed_path_outside_data_root"); + return path; + } + + private decrementManagedCapacity(bytes: number) { + const current = this.database.prepare(` + SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1 + `).get() as { managed_content_bytes: number } | undefined; + if (!current) return; + const managed = Math.max(0, current.managed_content_bytes - bytes); + const notice = managed < 4_294_967_296 ? "normal" : managed < 4_831_838_208 ? "warning" : "critical"; + const reservations = this.tableExists("storage_reservations") + ? (this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }).bytes + : 0; + const status = managed + reservations >= 5_368_709_120 ? "full" : "active"; + this.database.prepare(` + UPDATE local_backend_storage_state + SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1 + WHERE singleton = 1 + `).run(managed, notice, status, iso(this.clock())); + } +} diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts index dc5d3cd..9bc3ff2 100644 --- a/apps/worker/src/worker.ts +++ b/apps/worker/src/worker.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { WorkerAiCallGate } from "./ai-call-gate.js"; import { readConfiguredLocalDataRoot } from "./runtime-config.js"; import { RetentionCleanup } from "./retention-cleanup.js"; +import { ProjectPurgeCleanup } from "./project-purge-cleanup.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { WorkerStorageStatus } from "./storage-status.js"; import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js"; @@ -28,11 +29,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) { const keepAlive = setInterval(() => undefined, 30_000); let storage: WorkerStorageStatus | undefined; let retention: RetentionCleanup | undefined; + let projectCleanup: ProjectPurgeCleanup | undefined; let retentionTimer: ReturnType | undefined; const control = attachWorkerSupervisorControl(controlPipe, () => { clearInterval(keepAlive); if (retentionTimer) clearInterval(retentionTimer); retention?.close(); + projectCleanup?.close(); storage?.close(); }); let storageStatus: "active" | "unavailable" = "active"; @@ -41,9 +44,11 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) { const databasePath = join(dataRoot, "db", "dada.sqlite3"); storage = new WorkerStorageStatus(databasePath); retention = new RetentionCleanup({ databasePath }); + projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath }); const runRetentionCleanup = () => { try { retention?.purgeExpired(); + projectCleanup?.run(); } catch { storageStatus = "unavailable"; storage?.markLogUnavailable(); diff --git a/openapi/openapi.json b/openapi/openapi.json index 458068a..7ffa240 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -2306,6 +2306,25 @@ ], "type": "object" }, + "ProjectPurgeResponse": { + "additionalProperties": false, + "properties": { + "project_id": { + "$ref": "#/components/schemas/ProjectId" + }, + "status": { + "enum": [ + "purged" + ], + "type": "string" + } + }, + "required": [ + "project_id", + "status" + ], + "type": "object" + }, "ProjectRatio": { "anyOf": [ { @@ -2374,6 +2393,33 @@ ], "type": "object" }, + "ProjectRestoreResponse": { + "additionalProperties": false, + "properties": { + "deleted_at": { + "type": "null" + }, + "project_id": { + "$ref": "#/components/schemas/ProjectId" + }, + "purge_at": { + "type": "null" + }, + "status": { + "enum": [ + "active" + ], + "type": "string" + } + }, + "required": [ + "deleted_at", + "project_id", + "purge_at", + "status" + ], + "type": "object" + }, "ProjectStateConflictResponse": { "additionalProperties": false, "properties": { @@ -2517,6 +2563,35 @@ ], "type": "object" }, + "ProjectTrashResponse": { + "additionalProperties": false, + "properties": { + "deleted_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" + }, + "project_id": { + "$ref": "#/components/schemas/ProjectId" + }, + "purge_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" + }, + "status": { + "enum": [ + "trashed" + ], + "type": "string" + } + }, + "required": [ + "deleted_at", + "project_id", + "purge_at", + "status" + ], + "type": "object" + }, "ProjectViewStatus": { "anyOf": [ { @@ -5124,6 +5199,166 @@ ] } }, + "/api/v1/projects/{projectId}/purge": { + "post": { + "operationId": "purgeProject", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPurgeResponse" + } + } + }, + "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": { + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + } + }, + "/api/v1/projects/{projectId}/restore": { + "post": { + "operationId": "restoreProject", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRestoreResponse" + } + } + }, + "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": { + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + } + }, "/api/v1/projects/{projectId}/state": { "put": { "operationId": "saveProjectState", @@ -5250,6 +5485,86 @@ ] } }, + "/api/v1/projects/{projectId}/trash": { + "post": { + "operationId": "trashProject", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectTrashResponse" + } + } + }, + "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": { + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + } + }, "/api/v1/projects/failed-empty/trash": { "post": { "operationId": "trashFailedEmptyProjects", diff --git a/package.json b/package.json index 86927cf..7a694ab 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.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 tests/e2e/project-trash.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", @@ -55,7 +55,9 @@ "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-02": "node scripts/run-wp2-02-validation.mjs", - "test:wp2-02:red": "node scripts/run-wp2-02-validation.mjs --phase red" + "test:wp2-02:red": "node scripts/run-wp2-02-validation.mjs --phase red", + "test:wp2-03": "node scripts/run-wp2-03-validation.mjs", + "test:wp2-03:red": "node scripts/run-wp2-03-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/projects.ts b/packages/shared-contracts/src/projects.ts index a14f7ef..66fd5ca 100644 --- a/packages/shared-contracts/src/projects.ts +++ b/packages/shared-contracts/src/projects.ts @@ -106,6 +106,31 @@ export const FailedEmptyTrashResponseSchema = Type.Object( }, { additionalProperties: false, $id: "FailedEmptyTrashResponse" }, ); +export const ProjectTrashResponseSchema = Type.Object( + { + deleted_at: Type.String({ pattern: isoTimestampPattern }), + project_id: Type.Ref(ProjectIdSchema), + purge_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("trashed"), + }, + { additionalProperties: false, $id: "ProjectTrashResponse" }, +); +export const ProjectRestoreResponseSchema = Type.Object( + { + deleted_at: Type.Null(), + project_id: Type.Ref(ProjectIdSchema), + purge_at: Type.Null(), + status: Type.Literal("active"), + }, + { additionalProperties: false, $id: "ProjectRestoreResponse" }, +); +export const ProjectPurgeResponseSchema = Type.Object( + { + project_id: Type.Ref(ProjectIdSchema), + status: Type.Literal("purged"), + }, + { additionalProperties: false, $id: "ProjectPurgeResponse" }, +); export type ProjectListQuery = Static; export type ProjectParams = Static; diff --git a/scripts/run-wp2-03-validation.mjs b/scripts/run-wp2-03-validation.mjs new file mode 100644 index 0000000..e697b91 --- /dev/null +++ b/scripts/run-wp2-03-validation.mjs @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, 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-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const caseId = "TDD-WP2-PROJ-003-trash-restore-purge"; +const caseDirectory = resolve(runDirectory, "cases", caseId); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(caseDirectory, { recursive: true }); + +const commands = phase === "red" + ? [ + ["integration", ["exec", "vitest", "run", "tests/integration/wp2-03-project-lifecycle.test.ts"]], + ["api", ["exec", "vitest", "run", "tests/api/wp2-03-project-lifecycle.test.ts"]], + ["worker", ["exec", "vitest", "run", "tests/worker/wp2-03-project-cleanup.test.ts"]], + ["e2e", ["exec", "playwright", "test", "tests/e2e/project-trash.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["integration", ["test:integration"]], ["api", ["test:api"]], + ["worker", ["test:worker"]], ["e2e", ["test:e2e"]], ["tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { ...process.env, DADA_EVIDENCE_DIR_PROJECT_TRASH: caseDirectory }; +const commandResults = []; +for (const [name, args] of commands) { + const command = `pnpm ${args.join(" ")}`; + const started_at = new Date().toISOString(); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} +writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +const commandState = phase === "red" + ? commandResults.every((result) => result.exit_code !== 0) + : commandResults.every((result) => result.exit_code === 0); +if (phase === "red") { + writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({ + expected_failure: "Project lifecycle routes, exact restore boundary, purge queue, worker cleanup and trash UI are absent", + status: commandState ? "red_confirmed" : "failed", + }, null, 2)}\n`); +} +const evidenceRefs = phase === "red" ? ["red-observation.json"] : ["response.json", "db-diff.json", "worker-events.json"]; +const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(caseDirectory, file))); +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +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 status = commandState && missingEvidence.length === 0 ? targetStatus : "failed"; +const result = { + acceptance_criteria: ["AC-21"], automation: ["automated"], commit, + evidence_refs: evidenceRefs, layer: ["DB", "API", "WRK", "E2E"], manifest, missing_evidence: missingEvidence, + phase, requirements: ["PROJECT-03", "PROJECT-08"], run_id: runId, status, + task_id: "TASK-WP2-03", test_id: caseId, work_package: "WP-2", + worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", +}; +writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); +const summary = { cases: [{ missing_evidence: missingEvidence, status, test_id: caseId }], phase, run_id: runId, status }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (status !== targetStatus) process.exit(1); diff --git a/tests/api/wp0-04-resource-boundary.test.ts b/tests/api/wp0-04-resource-boundary.test.ts index 285183c..b348ba9 100644 --- a/tests/api/wp0-04-resource-boundary.test.ts +++ b/tests/api/wp0-04-resource-boundary.test.ts @@ -114,10 +114,12 @@ describe("TDD-WP0-DATA-002-no-backup-migration route inventory", () => { it("contains no product backup, archive, business import, migration or recovery route", async () => { const app = await createApp({ browserGate: false }); try { - const routes = app.printRoutes(); - for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "restore", "recovery"]) { - expect(routes.toLowerCase()).not.toContain(forbidden); + await app.ready(); + const routes = Object.keys(app.swagger().paths ?? {}).map((route) => route.toLowerCase()); + for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "recovery"]) { + expect(routes.some((route) => route.includes(forbidden))).toBe(false); } + expect(routes.filter((route) => route.includes("restore"))).toEqual(["/api/v1/projects/{projectid}/restore"]); const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NO_TRANSFER; if (evidenceDirectory) { mkdirSync(evidenceDirectory, { recursive: true }); diff --git a/tests/api/wp2-03-project-lifecycle.test.ts b/tests/api/wp2-03-project-lifecycle.test.ts new file mode 100644 index 0000000..1ce3e95 --- /dev/null +++ b/tests/api/wp2-03-project-lifecycle.test.ts @@ -0,0 +1,91 @@ +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-02T09: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[] = []; + +function writeEvidence(value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH; + if (!root) return; + mkdirSync(root, { recursive: true }); + writeFileSync(resolve(root, "response.json"), `${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 }); +}); + +describe("TDD-WP2-PROJ-003 lifecycle API", () => { + it("exposes owner-only trash, restore, and irreversible purge routes", async () => { + const root = mkdtempSync(join(tmpdir(), "dada-wp2-03-api-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x71), clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath, + invitePepper: Buffer.alloc(32, 0x72), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x73), + }); + 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 (?, 'trash@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Trash User', '@trash_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"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, projects: projectService, registration }); + const cookie = `dada_session=${session.sessionToken}`; + const sessionResponse = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/auth/session" }); + const csrf = sessionResponse.json().csrf_token as string; + const mutationHeaders = { ...baseHeaders, cookie, "x-csrf-token": csrf }; + const target = projectService.createProjectForGeneration({ ownerId, prompt: "API 回收站", ratio: "3:4", status: "failed" }); + const lifecycle = (action: "trash" | "restore" | "purge") => app.inject({ + headers: mutationHeaders, method: "POST", url: `/api/v1/projects/${target.project.projectId}/${action}`, + }); + + const trashed = await lifecycle("trash"); + expect(trashed.statusCode).toBe(200); + expect(trashed.json()).toMatchObject({ project_id: target.project.projectId, status: "trashed" }); + const firstTimes = { deleted_at: trashed.json().deleted_at, purge_at: trashed.json().purge_at }; + for (let index = 0; index < 20; index += 1) { + projectService.createProjectForGeneration({ ownerId, prompt: `API 名额 ${index}`, ratio: "1:1", status: "failed" }); + } + const blocked = await lifecycle("restore"); + expect(blocked.statusCode).toBe(409); + expect(blocked.body).toBe("null"); + const trashedList = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/projects?status=trashed" }); + expect(trashedList.json().projects[0]).toMatchObject(firstTimes); + + const fillerId = projectService.listProjects(ownerId, "active")[0].projectId; + projectService.trashProject(ownerId, fillerId); + const restored = await lifecycle("restore"); + expect(restored.statusCode).toBe(200); + expect(restored.json()).toMatchObject({ deleted_at: null, purge_at: null, status: "active" }); + await lifecycle("trash"); + const purged = await lifecycle("purge"); + expect(purged.statusCode).toBe(200); + expect(purged.json()).toEqual({ project_id: target.project.projectId, status: "purged" }); + const hidden = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: `/api/v1/projects/${target.project.projectId}` }); + const cannotRestore = await lifecycle("restore"); + expect(hidden.statusCode).toBe(404); + expect(cannotRestore.statusCode).toBe(404); + writeEvidence({ blocked_restore: blocked.statusCode, first_times: firstTimes, hidden_after_purge: hidden.statusCode, purged: purged.json(), restored: restored.json() }); + await app.close(); + }, 20_000); +}); diff --git a/tests/e2e/project-trash.spec.ts b/tests/e2e/project-trash.spec.ts new file mode 100644 index 0000000..5d45fa0 --- /dev/null +++ b/tests/e2e/project-trash.spec.ts @@ -0,0 +1,96 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } 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 targetId = "00000000-0000-4000-8000-000000000501"; +const doomedId = "00000000-0000-4000-8000-000000000502"; +const deletedAt = "2026-08-02T09:00:00.000Z"; +const purgeAt = "2026-09-01T09:00:00.000Z"; +const session = { + audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, + csrf_token: "csrf-project-trash-fixture-000000000000000000000000000000000", + expires_at: "2026-09-02T09:00:00.000Z", + user: { creator_name: "Trash User", role: "user", social_id: "@trash_user", status: "active", user_id: "00000000-0000-4000-8000-000000000503" }, +}; + +function summary(projectId: string, name: string, status: "active" | "trashed") { + return { + current_image_id: null, deleted_at: status === "trashed" ? deletedAt : null, name, project_id: projectId, + purge_at: status === "trashed" ? purgeAt : null, ratio: "3:4", state_version: 2, status, + successful_image_count: 0, updated_at: deletedAt, + }; +} + +test("TDD-WP2-PROJ-003 exposes safe trash, blocked restore, restore, and permanent purge actions", async ({ page }) => { + let activeCount = 20; + let targetStatus: "active" | "trashed" = "active"; + let restored = false; + let purged = false; + await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 })); + await page.route("**/api/v1/projects?status=*", (route) => { + const status = new URL(route.request().url()).searchParams.get("status"); + const projects = status === "active" + ? targetStatus === "active" ? [summary(targetId, "日落海报", "active")] : [] + : [ + ...(targetStatus === "trashed" && !restored ? [summary(targetId, "日落海报", "trashed")] : []), + ...(!purged ? [summary(doomedId, "待永久删除", "trashed")] : []), + ]; + return route.fulfill({ body: JSON.stringify({ active_count: activeCount, active_limit: 20, projects }), contentType: "application/json", status: 200 }); + }); + await page.route(`**/api/v1/projects/${targetId}/trash`, (route) => { + targetStatus = "trashed"; + activeCount = 19; + return route.fulfill({ body: JSON.stringify({ ...summary(targetId, "日落海报", "trashed") }), contentType: "application/json", status: 200 }); + }); + await page.route(`**/api/v1/projects/${targetId}/restore`, (route) => { + restored = true; + targetStatus = "active"; + activeCount = 20; + return route.fulfill({ body: JSON.stringify({ ...summary(targetId, "日落海报", "active") }), contentType: "application/json", status: 200 }); + }); + await page.route(`**/api/v1/projects/${doomedId}/purge`, (route) => { + purged = true; + return route.fulfill({ body: JSON.stringify({ project_id: doomedId, status: "purged" }), contentType: "application/json", status: 200 }); + }); + + await page.goto(`${webUrl}/app/projects`); + await page.getByRole("button", { name: "移入回收站:日落海报" }).click(); + await expect(page.getByText("项目已移入回收站")).toBeVisible(); + activeCount = 20; + await page.getByRole("tab", { name: "回收站" }).click(); + await expect(page.getByRole("button", { name: "恢复项目:日落海报" })).toBeDisabled(); + await expect(page.getByText("活动项目已达 20 个,请先释放名额")).toBeVisible(); + + activeCount = 19; + await page.getByRole("tab", { name: "项目", exact: true }).click(); + await page.getByRole("tab", { name: "回收站" }).click(); + await page.getByRole("button", { name: "恢复项目:日落海报" }).click(); + await expect(page.getByText("项目已恢复")).toBeVisible(); + await page.getByRole("button", { name: "永久删除:待永久删除" }).click(); + await expect(page.getByRole("dialog", { name: "永久删除项目" })).toBeVisible(); + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH; + if (evidenceRoot) { + const directory = resolve(evidenceRoot, "screenshots"); + mkdirSync(directory, { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(directory, "trash-confirmation.png") }); + } + await page.getByRole("button", { name: "确认永久删除" }).click(); + await expect(page.getByText("项目已永久删除,物理清理将在后台完成")).toBeVisible(); + expect(restored).toBe(true); + expect(purged).toBe(true); +}); diff --git a/tests/integration/wp2-03-project-lifecycle.test.ts b/tests/integration/wp2-03-project-lifecycle.test.ts new file mode 100644 index 0000000..fd0f55c --- /dev/null +++ b/tests/integration/wp2-03-project-lifecycle.test.ts @@ -0,0 +1,86 @@ +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 { ProjectError, ProjectService } from "../../apps/api/src/projects.js"; + +const baseNow = Date.parse("2026-08-02T09:00:00.000Z"); +const retentionMs = 720 * 60 * 60 * 1_000; +const roots: string[] = []; +const services: ProjectService[] = []; + +function writeEvidence(file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH; + if (!root) return; + mkdirSync(root, { recursive: true }); + writeFileSync(resolve(root, file), `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP2-PROJ-003-trash-restore-purge", () => { + it("keeps exact trash timestamps, blocks restore at twenty, and revokes access at purge_at", () => { + let now = baseNow; + const root = mkdtempSync(join(tmpdir(), "dada-wp2-03-lifecycle-")); + roots.push(root); + const service = new ProjectService({ clock: () => now, databasePath: join(root, "dada.sqlite3") }); + services.push(service); + const ownerId = randomUUID(); + const target = service.createProjectForGeneration({ ownerId, prompt: "待恢复项目", ratio: "3:4", status: "failed" }); + + const firstTrash = service.trashProject(ownerId, target.project.projectId); + expect(firstTrash).toMatchObject({ + deletedAt: new Date(baseNow).toISOString(), + purgeAt: new Date(baseNow + retentionMs).toISOString(), + status: "trashed", + }); + expect(service.activeProjectCount(ownerId)).toBe(0); + + const fillers = Array.from({ length: 20 }, (_, index) => service.createProjectForGeneration({ + ownerId, prompt: `占位项目 ${index}`, ratio: "1:1", status: "failed", + })); + expect(() => service.restoreProject(ownerId, target.project.projectId)).toThrowError( + expect.objectContaining({ code: "project_active_limit" }), + ); + expect(service.listProjects(ownerId, "trashed")[0]).toMatchObject({ + deletedAt: firstTrash.deletedAt, purgeAt: firstTrash.purgeAt, status: "trashed", + }); + + service.trashProject(ownerId, fillers[0].project.projectId); + const restored = service.restoreProject(ownerId, target.project.projectId); + expect(restored).toMatchObject({ deletedAt: null, purgeAt: null, status: "failed_empty" }); + + now += 60 * 60 * 1_000; + const secondTrash = service.trashProject(ownerId, target.project.projectId); + service.purgeProject(ownerId, target.project.projectId); + expect(() => service.getProject(ownerId, target.project.projectId)).toThrowError( + expect.objectContaining({ code: "project_not_found" }), + ); + + const expiryTarget = service.createProjectForGeneration({ ownerId, prompt: "自然到期项目", ratio: "4:3", status: "failed" }); + const expiryTrash = service.trashProject(ownerId, expiryTarget.project.projectId); + now = Date.parse(expiryTrash.purgeAt!); + expect(service.listProjects(ownerId, "trashed").some((project) => project.projectId === expiryTarget.project.projectId)).toBe(false); + expect(() => service.restoreProject(ownerId, expiryTarget.project.projectId)).toThrowError( + expect.objectContaining({ code: "project_not_found" }), + ); + const cleanup = service.database.prepare("SELECT project_id, status FROM project_cleanup_queue ORDER BY created_at, project_id").all(); + expect(cleanup).toEqual(expect.arrayContaining([ + { project_id: target.project.projectId, status: "pending" }, + { project_id: expiryTarget.project.projectId, status: "pending" }, + ])); + writeEvidence("db-diff.json", { + active_limit_restore_preserved_timestamps: true, + cleanup_queue: cleanup, + explicit_purge_deleted_at: secondTrash.deletedAt, + natural_expiry_revoked_at_boundary: true, + retention_milliseconds: retentionMs, + }); + }, 15_000); +}); diff --git a/tests/worker/wp2-03-project-cleanup.test.ts b/tests/worker/wp2-03-project-cleanup.test.ts new file mode 100644 index 0000000..48352f2 --- /dev/null +++ b/tests/worker/wp2-03-project-cleanup.test.ts @@ -0,0 +1,85 @@ +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 { Readable } from "node:stream"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { ProjectService } from "../../apps/api/src/projects.js"; +import { ProjectPurgeCleanup } from "../../apps/worker/src/project-purge-cleanup.js"; + +const now = Date.parse("2026-08-02T09:00:00.000Z"); +const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); +const roots: string[] = []; + +function writeEvidence(value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH; + if (!root) return; + mkdirSync(root, { recursive: true }); + writeFileSync(resolve(root, "worker-events.json"), `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP2-PROJ-003 project cleanup worker", () => { + it("queues all associated managed files without reducing capacity, then physically removes them", async () => { + const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-03-worker-")); + roots.push(dataRoot); + mkdirSync(join(dataRoot, "db"), { recursive: true }); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); + const storage = new ManagedStorage({ dataRoot, databasePath }); + const projects = new ProjectService({ clock: () => now, databasePath }); + const ownerId = randomUUID(); + const project = projects.createProjectForGeneration({ ownerId, prompt: "清理项目", ratio: "3:4", status: "failed" }); + const fileKinds = ["generated", "reference", "export"] as const; + const files = []; + for (const fileKind of fileKinds) { + const committed = await storage.commitStream({ + content: Readable.from(png), expectedMimeType: "image/png", fileKind, + fileName: `${fileKind}.png`, operationId: randomUUID(), ownerRef: ownerId, projectedWriteBytes: png.byteLength, + }); + files.push(committed); + projects.linkManagedResource(ownerId, project.project.projectId, committed.file_id, fileKind); + } + projects.saveProjectState({ + expectedStateVersion: 1, idempotencyKey: "wp2-03-location-state-00000000000001", ownerId, + projectId: project.project.projectId, + state: { + canvas_state: { + background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null }, + elements: [{ + coordinates: { latitude: 30.2741, longitude: 120.1551 }, created_at: new Date(now).toISOString(), + element_id: randomUUID(), opacity: 1, position: { x: 0, y: 0 }, resource_version: "v1", rotation: 0, + scale: { x: 1, y: 1 }, template_or_asset_id: "DYN004", type: "dynamic_sticker", z_index: 0, + }], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, + }, + name: "含定位信息的项目", + }, + }); + const bytesBeforePurge = storage.getState().managed_content_bytes; + projects.trashProject(ownerId, project.project.projectId); + projects.purgeProject(ownerId, project.project.projectId); + expect(storage.getState().managed_content_bytes).toBe(bytesBeforePurge); + expect(projects.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status = 'pending'").get()).toEqual({ count: 3 }); + + const worker = new ProjectPurgeCleanup({ clock: () => now, dataRoot, databasePath }); + const cleanup = worker.run(); + worker.close(); + expect(cleanup).toEqual({ + expired: 0, + physical: { completed: 3, failed: 0 }, + relational: { completed: 1, failed: 0 }, + }); + expect(projects.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE project_id = ?").get(project.project.projectId)).toEqual({ count: 0 }); + expect(projects.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(project.project.projectId)).toEqual({ count: 0 }); + expect(storage.getState().managed_content_bytes).toBe(0); + expect(files.every((file) => storage.resolveManagedFile(file.file_id) === undefined)).toBe(true); + writeEvidence({ bytes_before_queue: bytesBeforePurge, bytes_while_queued: bytesBeforePurge, cleanup, resource_kinds: [...fileKinds, "location"] }); + projects.close(); + storage.close(); + }, 20_000); +});