feat: complete TASK-WP2-03 project lifecycle
This commit is contained in:
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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<ProjectRatio, { height: number; width: number }> = {
|
||||
"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<ProjectService["projectSummary"]>;
|
||||
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,
|
||||
|
||||
@@ -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<LogoutRes
|
||||
return response.json() as Promise<LogoutResponse>;
|
||||
}
|
||||
|
||||
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
|
||||
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<ProjectPurgeResponse>;
|
||||
}
|
||||
|
||||
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
|
||||
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<ProjectRenameResponse>;
|
||||
}
|
||||
|
||||
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
|
||||
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<ProjectRestoreResponse>;
|
||||
}
|
||||
|
||||
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
|
||||
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<FailedEmptyTrashResponse>;
|
||||
}
|
||||
|
||||
export async function trashProject(options: ClientOptions = {}): Promise<ProjectTrashResponse> {
|
||||
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<ProjectTrashResponse>;
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }: {
|
||||
<div className="project-card-body">
|
||||
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
||||
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
||||
{project.status === "trashed" && project.purge_at ? <p className="trash-expiry">将在 {formatUpdatedAt(project.purge_at)} 永久删除</p> : null}
|
||||
<time dateTime={project.updated_at}>{formatUpdatedAt(project.updated_at)}</time>
|
||||
<a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}>打开</a>
|
||||
<div className="project-card-actions">
|
||||
{project.status !== "trashed" ? <a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}>打开</a> : null}
|
||||
{onTrash ? <button aria-label={`移入回收站:${project.name}`} disabled={busy} onClick={onTrash} type="button">移入回收站</button> : null}
|
||||
{onRestore ? <button aria-label={`恢复项目:${project.name}`} disabled={busy || activeLimitReached} onClick={onRestore} type="button">恢复</button> : null}
|
||||
{onPurge ? <button aria-label={`永久删除:${project.name}`} disabled={busy} onClick={onPurge} type="button">永久删除</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -300,6 +311,8 @@ export function ProjectsPage() {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busyProjectId, setBusyProjectId] = useState<string>();
|
||||
const [pendingPurge, setPendingPurge] = useState<ProjectSummary>();
|
||||
|
||||
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 <LoadingPage label="正在读取项目" />;
|
||||
|
||||
return (
|
||||
@@ -371,6 +414,9 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
{loadingFailed ? <p className="projects-stale" role="alert">项目列表读取失败。{payload ? "当前显示上次读取的数据。" : ""}</p> : null}
|
||||
{notice ? <p className="projects-notice" role="status">{notice}</p> : null}
|
||||
{status === "trashed" && payload && payload.active_count === payload.active_limit ? (
|
||||
<p className="projects-stale">活动项目已达 20 个,请先释放名额</p>
|
||||
) : null}
|
||||
{visible.length === 0 ? (
|
||||
<section className="projects-empty">
|
||||
<h2>{query ? "没有符合条件的项目" : status === "active" ? "还没有项目" : "回收站为空"}</h2>
|
||||
@@ -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() {
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
{pendingPurge ? (
|
||||
<div className="project-purge-overlay">
|
||||
<section aria-labelledby="project-purge-title" aria-modal="true" className="project-purge-dialog" role="dialog">
|
||||
<p>IRREVERSIBLE ACTION</p>
|
||||
<h2 id="project-purge-title">永久删除项目</h2>
|
||||
<p>“{pendingPurge.name}”将立即无法恢复。关联数据和文件会进入后台物理清理队列。</p>
|
||||
<div>
|
||||
<button disabled={busyProjectId === pendingPurge.project_id} onClick={() => runLifecycle(pendingPurge, "purge")} type="button">确认永久删除</button>
|
||||
<button disabled={Boolean(busyProjectId)} onClick={() => setPendingPurge(undefined)} type="button">取消</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<LocalOnlyFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
@@ -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<typeof ProjectListQuerySchema>;
|
||||
export type ProjectParams = Static<typeof ProjectParamsSchema>;
|
||||
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<ProjectError>({ 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<ProjectError>({ 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<ProjectError>({ 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user