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,
|
||||
|
||||
Reference in New Issue
Block a user