feat: complete TASK-WP2-01 project foundations

This commit is contained in:
suyx
2026-07-28 20:12:12 +08:00
parent 59e566885b
commit 4d38530361
19 changed files with 3696 additions and 12 deletions
+250
View File
@@ -29,6 +29,20 @@ import {
LogoutResponseSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
GenerationProjectItemSchema,
ProjectDetailResponseSchema,
ProjectIdSchema,
ProjectImageItemSchema,
ProjectListQuerySchema,
ProjectListResponseSchema,
ProjectParamsSchema,
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
@@ -48,6 +62,10 @@ import {
type AccountProfileUpdateRequest,
type LoginCompleteRequest,
type LoginSendRequest,
type FailedEmptyTrashRequest,
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -70,6 +88,8 @@ import {
import { EventHub } from "./event-hub.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import { ProjectError } from "./project-errors.js";
import type { ProjectService } from "./projects.js";
import {
RegistrationError,
registrationFieldError,
@@ -96,6 +116,7 @@ export interface CreateAppOptions {
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
projects?: ProjectService;
registration?: RegistrationService;
}
@@ -163,6 +184,64 @@ function registrationValidationFailure(reply: FastifyReply, correlationId: strin
);
}
function projectFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (!(error instanceof ProjectError)) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
const mapping = {
generation_state_invalid: 400,
project_active_limit: 409,
project_history_limit: 409,
project_name_invalid: 400,
project_not_found: 404,
project_ratio_fixed: 409,
project_retry_not_allowed: 409,
} as const;
return reply.code(mapping[error.code]).send(null);
}
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
function projectSummaryResponse(project: ProjectSummaryView) {
return {
current_image_id: project.currentImageId,
deleted_at: project.deletedAt,
name: project.name,
project_id: project.projectId,
purge_at: project.purgeAt,
ratio: project.ratio,
state_version: project.stateVersion,
status: project.status,
successful_image_count: project.successfulImageCount,
updated_at: project.updatedAt,
};
}
function projectDetailResponse(project: ProjectDetailView) {
return {
...projectSummaryResponse(project),
created_at: project.createdAt,
draft_prompt: project.draftPrompt,
generations: project.generations.map((generation) => ({
created_at: generation.createdAt,
error_category: generation.errorCategory,
generation_id: generation.generationId,
prompt: generation.prompt,
ratio: generation.ratio,
status: generation.status,
updated_at: generation.updatedAt,
})),
images: project.images.map((image) => ({
created_at: image.createdAt,
generation_id: image.generationId,
image_id: image.imageId,
})),
pixel_height: project.pixelHeight,
pixel_width: project.pixelWidth,
};
}
function isSupportGateRequest(method: string, path: string) {
if (method === "POST" && path === "/api/v1/support/check") return true;
if (method !== "GET" && method !== "HEAD") return false;
@@ -261,6 +340,20 @@ export async function createApp(options: CreateAppOptions = {}) {
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
SseEventSchema,
ProjectIdSchema,
ProjectRatioSchema,
ProjectViewStatusSchema,
ProjectSummarySchema,
ProjectListQuerySchema,
ProjectListResponseSchema,
ProjectParamsSchema,
GenerationProjectItemSchema,
ProjectImageItemSchema,
ProjectDetailResponseSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
]) {
app.addSchema(schema);
}
@@ -920,6 +1013,163 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/projects",
{
attachValidation: true,
schema: {
operationId: "listProjects",
querystring: Type.Ref(ProjectListQuerySchema),
response: {
200: Type.Ref(ProjectListResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
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 session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
const query = request.query as ProjectListQuery;
const status = query.status ?? "active";
return {
active_count: options.projects.activeProjectCount(session.userId),
active_limit: 20 as const,
projects: options.projects.listProjects(session.userId, status).slice(0, 20).map(projectSummaryResponse),
};
},
);
app.get(
"/api/v1/projects/:projectId",
{
attachValidation: true,
schema: {
operationId: "getProject",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectDetailResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: 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 session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
return projectDetailResponse(options.projects.getProject(session.userId, (request.params as ProjectParams).projectId));
} catch (error) {
return projectFailure(reply, request.id, error);
}
},
);
app.patch(
"/api/v1/projects/:projectId",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectRenameRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "renameProject",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectRenameResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: 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 renamed = options.projects.renameProject(
owner.userId,
(request.params as ProjectParams).projectId,
(request.body as ProjectRenameRequest).name,
);
return { name: renamed.name, state_version: renamed.stateVersion, status: "renamed" as const };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/projects/failed-empty/trash",
{
attachValidation: true,
schema: {
body: Type.Ref(FailedEmptyTrashRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "trashFailedEmptyProjects",
response: {
200: Type.Ref(FailedEmptyTrashResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
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 result = options.projects.trashFailedEmpty(owner.userId, (request.body as FailedEmptyTrashRequest).project_ids);
return { ignored_project_ids: result.ignoredProjectIds, trashed_project_ids: result.trashedProjectIds };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/support/check",
{
+9 -1
View File
@@ -7,6 +7,7 @@ import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js";
import { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js";
import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
@@ -15,6 +16,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
let projects: ProjectService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -23,17 +25,21 @@ if (credentialChannelEnabled) {
.update(`Dada/P0A/${purpose}/v1`, "utf8")
.digest();
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
const databasePath = join(dataRoot, "db", "dada.sqlite3");
registration = new RegistrationService({
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
challengePepper: derivePepper("challenge-pepper"),
currentPrivacyNoticeVersion: registrationNotice.version,
databasePath: join(dataRoot, "db", "dada.sqlite3"),
databasePath,
invitePepper: derivePepper("invite-pepper"),
resend: new MockResendAdapter(),
sessionPepper: derivePepper("session-pepper"),
});
projects = new ProjectService({ databasePath });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
projects?.close();
projects = undefined;
registration?.close();
registration = undefined;
throw error;
@@ -45,6 +51,7 @@ if (credentialChannelEnabled) {
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp({
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(projects ? { projects } : {}),
...(registration ? { registration } : {}),
});
@@ -60,6 +67,7 @@ if (controlPipeIndex >= 0) {
let storage: ManagedStorage | undefined;
const control = attachApiSupervisorControl(controlPipe, async () => {
await app.close();
projects?.close();
registration?.close();
storage?.close();
});
+17
View File
@@ -0,0 +1,17 @@
export type ProjectErrorCode =
| "generation_state_invalid"
| "project_active_limit"
| "project_history_limit"
| "project_name_invalid"
| "project_not_found"
| "project_ratio_fixed"
| "project_retry_not_allowed";
export class ProjectError extends Error {
readonly code: ProjectErrorCode;
constructor(code: ProjectErrorCode) {
super(code);
this.code = code;
}
}
+463
View File
@@ -0,0 +1,463 @@
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
import { ProjectError } from "./project-errors.js";
export { ProjectError } from "./project-errors.js";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
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";
const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
"3:4": { height: 1440, width: 1080 },
"1:1": { height: 1080, width: 1080 },
"4:3": { height: 1080, width: 1440 },
"9:16": { height: 1920, width: 1080 },
};
const projectLimit = 20;
const historyLimit = 10;
const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000;
const generationErrorCategories = new Set([
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
"unknown_retryable", "unknown_non_retryable",
]);
interface ProjectRow {
created_at: number;
current_image_id: string | null;
deleted_at: number | null;
draft_prompt: string;
name: string;
owner_id: string;
pixel_height: number;
pixel_width: number;
project_id: string;
purge_at: number | null;
ratio: ProjectRatio;
state_version: number;
status: "active" | "trashed" | "purged";
updated_at: number;
}
interface GenerationRow {
created_at: number;
error_category: string | null;
generation_id: string;
prompt: string;
project_id: string;
ratio: ProjectRatio;
status: GenerationStatus;
updated_at: number;
}
interface ImageRow {
created_at: number;
generation_id: string;
image_id: string;
}
function isProjectRatio(value: string): value is ProjectRatio {
return projectRatios.includes(value as ProjectRatio);
}
function normalizePrompt(value: string) {
const normalized = value.trim().replace(/\s+/gu, " ");
if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid");
return normalized;
}
function takeGraphemes(value: string, count: number) {
const Segmenter = Intl.Segmenter;
if (Segmenter) {
return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)]
.slice(0, count)
.map((entry) => entry.segment)
.join("");
}
return Array.from(value).slice(0, count).join("");
}
function localDate(timestamp: number) {
const date = new Date(timestamp);
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
.map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0"))
.join("-");
}
function defaultProjectName(prompt: string, timestamp: number) {
const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作";
return `${summary} ${localDate(timestamp)}`;
}
function normalizeProjectName(value: string) {
const normalized = value.trim().replace(/\s+/gu, " ");
if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid");
return normalized;
}
function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
export class ProjectService {
readonly database: BetterSqlite3.Database;
private readonly clock: () => number;
constructor(input: { clock?: () => number; databasePath: string }) {
this.clock = input.clock ?? Date.now;
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("synchronous = FULL");
this.database.pragma("busy_timeout = 5000");
this.migrate();
}
close() {
this.database.close();
}
createProjectForGeneration(input: {
ownerId: string;
prompt: string;
ratio: ProjectRatio;
status: Exclude<GenerationStatus, "succeeded">;
}) {
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
const prompt = normalizePrompt(input.prompt);
const projectId = randomUUID();
const generationId = randomUUID();
const now = this.clock();
const pixels = ratioPixels[input.ratio];
const transaction = this.database.transaction(() => {
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
.get(input.ownerId) as { count: number };
if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
this.database.prepare(`
INSERT INTO projects (
project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
`).run(
projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
pixels.width, pixels.height, now, now,
);
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
});
transaction.immediate();
return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) };
}
continueProjectGeneration(input: {
ownerId: string;
projectId: string;
prompt: string;
ratio: ProjectRatio;
status: Exclude<GenerationStatus, "succeeded">;
}) {
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
const generationId = randomUUID();
const prompt = normalizePrompt(input.prompt);
const now = this.clock();
const transaction = this.database.transaction(() => {
const project = this.readOwnedProject(input.ownerId, input.projectId);
if (project.status !== "active") throw new ProjectError("project_not_found");
if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed");
if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit");
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now);
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?")
.run(prompt, now, input.projectId);
});
transaction.immediate();
return this.readGeneration(generationId);
}
retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) {
const project = this.getProject(input.ownerId, input.projectId);
const latest = project.generations.at(-1);
if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) {
throw new ProjectError("project_retry_not_allowed");
}
return this.continueProjectGeneration({
ownerId: input.ownerId,
projectId: input.projectId,
prompt: input.prompt,
ratio: project.ratio,
status: "queued",
});
}
recordSuccessfulImage(input: { generationId: string; imageId: string }) {
const now = this.clock();
const transaction = this.database.transaction(() => {
const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?")
.get(input.generationId) as GenerationRow | undefined;
if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid");
if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit");
this.database.prepare(`
UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
`).run(now, input.generationId);
this.database.prepare(`
INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)
`).run(input.imageId, generation.project_id, input.generationId, now);
this.database.prepare(`
UPDATE projects
SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
WHERE project_id = ?
`).run(input.imageId, now, generation.project_id);
});
transaction.immediate();
}
markGenerationFailed(generationId: string, errorCategory: string) {
if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid");
const status = errorCategory === "safety_rejected" ? "rejected" : "failed";
const changed = this.database.prepare(`
UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ?
WHERE generation_id = ? AND status IN ('queued', 'running')
`).run(status, errorCategory, this.clock(), generationId);
if (changed.changes !== 1) throw new ProjectError("generation_state_invalid");
}
renameProject(ownerId: string, projectId: string, name: string) {
const normalized = normalizeProjectName(name);
const now = this.clock();
const changed = this.database.prepare(`
UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
WHERE owner_id = ? AND project_id = ? AND status = 'active'
`).run(normalized, now, ownerId, projectId);
if (changed.changes !== 1) throw new ProjectError("project_not_found");
return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
}
trashFailedEmpty(ownerId: string, projectIds: string[]) {
const uniqueIds = [...new Set(projectIds)];
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
const trashedProjectIds: string[] = [];
const ignoredProjectIds: string[] = [];
const now = this.clock();
const transaction = this.database.transaction(() => {
for (const projectId of uniqueIds) {
const row = this.database.prepare(`
SELECT p.project_id,
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active'
`).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined;
if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) {
ignoredProjectIds.push(projectId);
continue;
}
this.database.prepare(`
UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
WHERE project_id = ?
`).run(now, now + trashRetentionMilliseconds, now, projectId);
trashedProjectIds.push(projectId);
}
});
transaction.immediate();
return { ignoredProjectIds, trashedProjectIds };
}
listProjects(ownerId: string, status: "active" | "trashed") {
const rows = this.database.prepare(`
SELECT p.*,
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
FROM projects p
WHERE p.owner_id = ? AND p.status = ?
ORDER BY p.updated_at DESC, p.project_id DESC
`).all(ownerId, status) as Array<ProjectRow & { image_count: number; latest_status: string | null }>;
return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status));
}
getProject(ownerId: string, projectId: string) {
const row = this.readOwnedProject(ownerId, projectId);
if (row.status === "purged") throw new ProjectError("project_not_found");
const generations = this.database.prepare(`
SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid
`).all(projectId) as GenerationRow[];
const images = this.database.prepare(`
SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid
`).all(projectId) as ImageRow[];
const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
return {
...summary,
createdAt: iso(row.created_at),
draftPrompt: row.draft_prompt,
generations: generations.map((generation) => this.generationView(generation)),
images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })),
pixelHeight: row.pixel_height,
pixelWidth: row.pixel_width,
};
}
activeProjectCount(ownerId: string) {
const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
.get(ownerId) as { count: number };
return row.count;
}
private insertGeneration(input: {
generationId: string;
ownerId: string;
projectId: string;
prompt: string;
ratio: ProjectRatio;
status: Exclude<GenerationStatus, "succeeded">;
}, now: number) {
const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null;
this.database.prepare(`
INSERT INTO generation_jobs (
generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now);
}
private readGeneration(generationId: string) {
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined;
if (!row) throw new ProjectError("generation_state_invalid");
return this.generationView(row);
}
private generationView(row: GenerationRow) {
return {
createdAt: iso(row.created_at),
errorCategory: row.error_category,
generationId: row.generation_id,
projectId: row.project_id,
prompt: row.prompt,
ratio: row.ratio,
status: row.status,
updatedAt: iso(row.updated_at),
};
}
private readOwnedProject(ownerId: string, projectId: string) {
const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?")
.get(ownerId, projectId) as ProjectRow | undefined;
if (!row) throw new ProjectError("project_not_found");
return row;
}
private successfulImageCount(projectId: string) {
const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
.get(projectId) as { count: number };
return row.count;
}
private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) {
const status: ProjectViewStatus = row.status === "trashed"
? "trashed"
: imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus)
? "failed_empty"
: "active";
return {
currentImageId: row.current_image_id,
deletedAt: row.deleted_at === null ? null : iso(row.deleted_at),
name: row.name,
projectId: row.project_id,
purgeAt: row.purge_at === null ? null : iso(row.purge_at),
ratio: row.ratio,
stateVersion: row.state_version,
status,
successfulImageCount: imageCount,
updatedAt: iso(row.updated_at),
};
}
private migrate() {
this.database.exec(`
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000),
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
pixel_width INTEGER NOT NULL,
pixel_height INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')),
state_version INTEGER NOT NULL CHECK (state_version >= 1),
current_image_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
purge_at INTEGER,
UNIQUE(project_id, ratio),
CHECK (
(ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR
(ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR
(ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR
(ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920)
),
CHECK (
(status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR
(status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR
(status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC);
DROP TRIGGER IF EXISTS projects_active_insert_limit;
CREATE TRIGGER projects_active_insert_limit
BEFORE INSERT ON projects
WHEN NEW.status = 'active' AND (
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
) >= ${projectLimit}
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
DROP TRIGGER IF EXISTS projects_active_restore_limit;
CREATE TRIGGER projects_active_restore_limit
BEFORE UPDATE OF status ON projects
WHEN OLD.status <> 'active' AND NEW.status = 'active' AND (
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
) >= ${projectLimit}
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
CREATE TABLE IF NOT EXISTS generation_jobs (
generation_id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
project_id TEXT NOT NULL,
prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000),
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')),
error_category TEXT CHECK (error_category IS NULL OR error_category IN (
'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
'unknown_retryable', 'unknown_non_retryable'
)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(generation_id, project_id),
FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE,
CHECK (
(status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR
(status IN ('failed', 'rejected') AND error_category IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id);
CREATE TABLE IF NOT EXISTS project_images (
image_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
generation_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id);
DROP TRIGGER IF EXISTS projects_ratio_immutable;
CREATE TRIGGER projects_ratio_immutable
BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects
WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height
BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END;
DROP TRIGGER IF EXISTS project_images_history_limit;
CREATE TRIGGER project_images_history_limit
BEFORE INSERT ON project_images
WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
`);
}
}
+9
View File
@@ -985,6 +985,11 @@ export class RegistrationService {
});
}
authorizeUserMutation(input: { csrfToken: string; sessionToken: string }) {
const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, this.options.clock());
return { userId: session.user_id };
}
logoutUser(input: { csrfToken: string; sessionToken: string }) {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
@@ -1196,6 +1201,10 @@ export class RegistrationService {
this.database.prepare("DELETE FROM privacy_consents WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM credit_accounts WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM user_profiles WHERE user_id = ?").run(session.user_id);
const projectsTable = this.database.prepare(`
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects'
`).get();
if (projectsTable) this.database.prepare("DELETE FROM projects WHERE owner_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email);
this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?")