feat: complete TASK-WP2-03 project lifecycle
This commit is contained in:
@@ -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