feat: complete TASK-WP2-03 project lifecycle

This commit is contained in:
suyx
2026-08-02 20:00:18 +08:00
parent da6fa25e60
commit 9b4860cfc2
17 changed files with 1477 additions and 10 deletions
+187
View File
@@ -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,