feat: complete TASK-WP2-07 latest exports
This commit is contained in:
+202
-1
@@ -1,5 +1,5 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createReadStream, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
@@ -51,11 +51,17 @@ import {
|
||||
ModelRuntimeSseEventSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ExportFormatSchema,
|
||||
GenerationProjectItemSchema,
|
||||
LatestExportItemSchema,
|
||||
LatestExportMultipartBodySchema,
|
||||
LatestExportParamsSchema,
|
||||
LatestExportSaveResponseSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectEditableStateSchema,
|
||||
ProjectIdSchema,
|
||||
ProjectImageItemSchema,
|
||||
ProjectImageParamsSchema,
|
||||
ProjectListQuerySchema,
|
||||
ProjectListResponseSchema,
|
||||
ProjectParamsSchema,
|
||||
@@ -94,6 +100,8 @@ import {
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type FailedEmptyTrashRequest,
|
||||
type LatestExportParams,
|
||||
type ProjectImageParams,
|
||||
type GenerationCreateHeaders,
|
||||
type GenerationParams,
|
||||
type ProjectListQuery,
|
||||
@@ -136,6 +144,8 @@ import type {
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
import type { ProjectService } from "./projects.js";
|
||||
import { LatestExportError } from "./latest-export-errors.js";
|
||||
import type { LatestExportService } from "./latest-exports.js";
|
||||
import {
|
||||
RegistrationError,
|
||||
registrationFieldError,
|
||||
@@ -162,6 +172,7 @@ export interface CreateAppOptions {
|
||||
credits?: CreditService;
|
||||
eventHub?: EventHub;
|
||||
generations?: GenerationSubmissionService;
|
||||
latestExports?: LatestExportService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
projects?: ProjectService;
|
||||
@@ -251,6 +262,45 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
|
||||
return reply.code(mapping[error.code]).send(null);
|
||||
}
|
||||
|
||||
function latestExportResponse(item: ReturnType<LatestExportService["getLatest"]> | Awaited<ReturnType<LatestExportService["saveLatest"]>>) {
|
||||
return {
|
||||
byte_size: item.byteSize,
|
||||
created_at: item.createdAt,
|
||||
download_url: item.downloadUrl,
|
||||
export_id: item.exportId,
|
||||
format: item.format,
|
||||
pixel_height: item.pixelHeight,
|
||||
pixel_width: item.pixelWidth,
|
||||
sha256: item.sha256,
|
||||
state_version: item.stateVersion,
|
||||
status: "saved" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function latestExportFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (error instanceof LatestExportError) {
|
||||
return reply.code(error.code === "not_found" ? 404 : error.code === "conflict" ? 409 : 400).send(null);
|
||||
}
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "STORAGE_CAPACITY_EXCEEDED") {
|
||||
const details = "details" in error && error.details && typeof error.details === "object"
|
||||
? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number }
|
||||
: undefined;
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: { capacity_status: details?.capacity_status ?? "full", remaining_bytes: details?.remaining_bytes ?? 0 },
|
||||
}));
|
||||
}
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "storage_unavailable") {
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: { capacity_status: "unavailable", remaining_bytes: 0 },
|
||||
}));
|
||||
}
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
|
||||
function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (!(error instanceof CreditError)) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
@@ -475,6 +525,17 @@ function projectDetailResponse(project: ProjectDetailView) {
|
||||
generation_id: image.generationId,
|
||||
image_id: image.imageId,
|
||||
})),
|
||||
latest_exports: project.latestExports.map((item) => ({
|
||||
byte_size: item.byteSize,
|
||||
created_at: item.createdAt,
|
||||
download_url: item.downloadUrl,
|
||||
export_id: item.exportId,
|
||||
format: item.format,
|
||||
pixel_height: item.pixelHeight,
|
||||
pixel_width: item.pixelWidth,
|
||||
sha256: item.sha256,
|
||||
state_version: item.stateVersion,
|
||||
})),
|
||||
pixel_height: project.pixelHeight,
|
||||
pixel_width: project.pixelWidth,
|
||||
save_status: project.saveStatus,
|
||||
@@ -619,6 +680,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ProjectParamsSchema,
|
||||
GenerationProjectItemSchema,
|
||||
ProjectImageItemSchema,
|
||||
ExportFormatSchema,
|
||||
LatestExportItemSchema,
|
||||
LatestExportMultipartBodySchema,
|
||||
LatestExportParamsSchema,
|
||||
LatestExportSaveResponseSchema,
|
||||
ProjectImageParamsSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectEditableStateSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
@@ -1708,6 +1775,140 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/projects/:projectId/latest-exports/:format",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Optional(Type.Ref(LatestExportMultipartBodySchema)),
|
||||
consumes: ["multipart/form-data"],
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "saveLatestExport",
|
||||
params: Type.Ref(LatestExportParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(LatestExportSaveResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
409: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
507: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Projects"],
|
||||
},
|
||||
validatorCompiler: () => (data) => ({ value: data }),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.latestExports) {
|
||||
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 params = request.params as LatestExportParams;
|
||||
const fields = new Map<string, string>();
|
||||
let saved: Awaited<ReturnType<LatestExportService["saveLatest"]>> | undefined;
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
if (saved || fields.has(part.fieldname) || typeof part.value !== "string") throw new LatestExportError("invalid");
|
||||
fields.set(part.fieldname, part.value);
|
||||
continue;
|
||||
}
|
||||
if (saved || part.fieldname !== "export_file" || !part.filename) throw new LatestExportError("invalid");
|
||||
const format = fields.get("format");
|
||||
const expectedMime = params.format === "png" ? "image/png" : "image/jpeg";
|
||||
if (format !== params.format || part.mimetype !== expectedMime) throw new LatestExportError("invalid");
|
||||
saved = await options.latestExports.saveLatest({
|
||||
byteSize: Number(fields.get("byte_size")),
|
||||
content: part.file,
|
||||
exportId: fields.get("export_id") ?? "",
|
||||
format: params.format,
|
||||
ownerId: owner.userId,
|
||||
pixelHeight: Number(fields.get("pixel_height")),
|
||||
pixelWidth: Number(fields.get("pixel_width")),
|
||||
projectId: params.projectId,
|
||||
sha256: fields.get("sha256") ?? "",
|
||||
stateVersion: Number(fields.get("state_version")),
|
||||
});
|
||||
if (part.file.truncated) throw new LatestExportError("invalid");
|
||||
}
|
||||
if (!saved) throw new LatestExportError("invalid");
|
||||
return latestExportResponse(saved);
|
||||
} catch (error) {
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/projects/:projectId/latest-exports/:format",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "downloadLatestExport",
|
||||
params: Type.Ref(LatestExportParamsSchema),
|
||||
produces: ["application/octet-stream"],
|
||||
response: { 200: Type.String({ format: "binary" }), 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.latestExports) 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 {
|
||||
const params = request.params as LatestExportParams;
|
||||
const item = options.latestExports.getLatest(session.userId, params.projectId, params.format);
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
reply.header("Content-Disposition", `attachment; filename="dada-latest.${params.format === "jpg" ? "jpg" : "png"}"`);
|
||||
reply.type(item.mimeType);
|
||||
return reply.send(createReadStream(item.path));
|
||||
} catch (error) {
|
||||
return latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/private-assets/projects/:projectId/images/:imageId",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "downloadOriginalGeneration",
|
||||
params: Type.Ref(ProjectImageParamsSchema),
|
||||
produces: ["application/octet-stream"],
|
||||
response: { 200: Type.String({ format: "binary" }), 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.latestExports) 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 {
|
||||
const params = request.params as ProjectImageParams;
|
||||
const item = options.latestExports.getOriginal(session.userId, params.projectId, params.imageId);
|
||||
const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png";
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
reply.header("Content-Disposition", `attachment; filename="dada-original.${extension}"`);
|
||||
reply.type(item.mime_type);
|
||||
return reply.send(createReadStream(item.path));
|
||||
} catch (error) {
|
||||
return latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/projects/:projectId/state",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export class LatestExportError extends Error {
|
||||
constructor(readonly code: "invalid" | "not_found" | "conflict") {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { ManagedStorage } from "./managed-storage.js";
|
||||
import { stableJson } from "./projects.js";
|
||||
import { LatestExportError } from "./latest-export-errors.js";
|
||||
export { LatestExportError } from "./latest-export-errors.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export type ExportFormat = "jpg" | "png";
|
||||
|
||||
interface ExportRow {
|
||||
byte_size: number;
|
||||
created_at: number;
|
||||
export_id: string;
|
||||
format: ExportFormat;
|
||||
managed_file_id: string;
|
||||
pixel_height: number;
|
||||
pixel_width: number;
|
||||
project_id: string;
|
||||
sha256: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
interface ManagedFileRow {
|
||||
byte_size: number;
|
||||
file_id: string;
|
||||
mime_type: string;
|
||||
relative_path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
type LatestExportView = ReturnType<LatestExportService["view"]>;
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function validSha256(value: string) {
|
||||
return /^[0-9a-f]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export class LatestExportService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
private readonly storage: ManagedStorage;
|
||||
|
||||
constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.storage = input.storage;
|
||||
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");
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
async saveLatest(input: {
|
||||
byteSize: number;
|
||||
content: Readable;
|
||||
exportId: string;
|
||||
format: ExportFormat;
|
||||
ownerId: string;
|
||||
pixelHeight: number;
|
||||
pixelWidth: number;
|
||||
projectId: string;
|
||||
sha256: string;
|
||||
stateVersion: number;
|
||||
}) {
|
||||
this.validateInput(input);
|
||||
const requestHash = createHash("sha256").update(stableJson({
|
||||
byte_size: input.byteSize, export_id: input.exportId, format: input.format,
|
||||
owner_id: input.ownerId,
|
||||
pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId,
|
||||
sha256: input.sha256, state_version: input.stateVersion,
|
||||
})).digest("hex");
|
||||
const replay = this.database.prepare("SELECT owner_id, request_hash, response_json FROM latest_export_receipts WHERE export_id = ?")
|
||||
.get(input.exportId) as { owner_id: string; request_hash: string; response_json: string | null } | undefined;
|
||||
if (replay) {
|
||||
if (replay.owner_id !== input.ownerId || replay.request_hash !== requestHash) throw new LatestExportError("conflict");
|
||||
const replayHash = createHash("sha256");
|
||||
let replayBytes = 0;
|
||||
for await (const chunk of input.content) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
replayBytes += bytes.byteLength;
|
||||
replayHash.update(bytes);
|
||||
}
|
||||
if (replayBytes !== input.byteSize || replayHash.digest("hex") !== input.sha256) throw new LatestExportError("conflict");
|
||||
return replay.response_json ? JSON.parse(replay.response_json) as LatestExportView : this.readByExportId(input.ownerId, input.exportId);
|
||||
}
|
||||
this.assertWritableProject(input);
|
||||
const stored = await this.storage.commitStream({
|
||||
content: input.content,
|
||||
expectedMimeType: input.format === "png" ? "image/png" : "image/jpeg",
|
||||
expectedSha256: input.sha256,
|
||||
fileKind: "export",
|
||||
fileName: `latest.${input.format === "jpg" ? "jpg" : "png"}`,
|
||||
operationId: input.exportId,
|
||||
ownerRef: input.ownerId,
|
||||
projectedWriteBytes: input.byteSize,
|
||||
});
|
||||
try {
|
||||
if (stored.bytes !== input.byteSize) throw new LatestExportError("invalid");
|
||||
const createdAt = this.clock();
|
||||
const response = this.view({
|
||||
byte_size: stored.bytes,
|
||||
created_at: createdAt,
|
||||
export_id: input.exportId,
|
||||
format: input.format,
|
||||
managed_file_id: stored.file_id,
|
||||
pixel_height: input.pixelHeight,
|
||||
pixel_width: input.pixelWidth,
|
||||
project_id: input.projectId,
|
||||
sha256: stored.sha256,
|
||||
state_version: input.stateVersion,
|
||||
});
|
||||
const transaction = this.database.transaction(() => {
|
||||
this.assertWritableProject(input);
|
||||
const previous = this.database.prepare("SELECT managed_file_id FROM latest_exports WHERE project_id = ? AND format = ?")
|
||||
.get(input.projectId, input.format) as { managed_file_id: string } | undefined;
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
|
||||
VALUES (?, ?, 'export', ?)
|
||||
`).run(input.projectId, stored.file_id, createdAt);
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
|
||||
VALUES (?, ?, 'project', ?)
|
||||
`).run(`project:${input.projectId}:${stored.file_id}`, stored.file_id, iso(createdAt));
|
||||
this.database.prepare(`
|
||||
INSERT INTO latest_exports (
|
||||
project_id, format, export_id, managed_file_id, state_version, sha256,
|
||||
byte_size, pixel_width, pixel_height, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, format) DO UPDATE SET
|
||||
export_id = excluded.export_id, managed_file_id = excluded.managed_file_id,
|
||||
state_version = excluded.state_version, sha256 = excluded.sha256,
|
||||
byte_size = excluded.byte_size, pixel_width = excluded.pixel_width,
|
||||
pixel_height = excluded.pixel_height, created_at = excluded.created_at
|
||||
`).run(
|
||||
input.projectId, input.format, input.exportId, stored.file_id, input.stateVersion,
|
||||
stored.sha256, stored.bytes, input.pixelWidth, input.pixelHeight, createdAt,
|
||||
);
|
||||
this.database.prepare("INSERT INTO latest_export_receipts (export_id, owner_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(input.exportId, input.ownerId, requestHash, stableJson(response), createdAt);
|
||||
if (previous && previous.managed_file_id !== stored.file_id) this.retireReplacedFile(previous.managed_file_id, createdAt);
|
||||
});
|
||||
transaction.immediate();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.storage.retireManagedFile(stored.file_id, "compensation");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getLatest(ownerId: string, projectId: string, format: ExportFormat) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT le.*, mf.mime_type, mf.relative_path
|
||||
FROM latest_exports le
|
||||
JOIN projects p ON p.project_id = le.project_id
|
||||
JOIN managed_files mf ON mf.file_id = le.managed_file_id AND mf.status = 'committed'
|
||||
WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND le.format = ?
|
||||
`).get(ownerId, projectId, format) as (ExportRow & ManagedFileRow) | undefined;
|
||||
if (!row) throw new LatestExportError("not_found");
|
||||
const path = this.storage.resolveManagedFile(row.managed_file_id);
|
||||
if (!path) throw new LatestExportError("not_found");
|
||||
return { ...this.view(row), mimeType: row.mime_type, path };
|
||||
}
|
||||
|
||||
getOriginal(ownerId: string, projectId: string, imageId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT mf.file_id, mf.relative_path, mf.byte_size, mf.mime_type, mf.sha256
|
||||
FROM project_images pi
|
||||
JOIN projects p ON p.project_id = pi.project_id
|
||||
JOIN managed_files mf ON mf.file_id = pi.image_id AND mf.status = 'committed'
|
||||
WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND pi.image_id = ?
|
||||
`).get(ownerId, projectId, imageId) as ManagedFileRow | undefined;
|
||||
if (!row) throw new LatestExportError("not_found");
|
||||
const path = this.storage.resolveManagedFile(row.file_id);
|
||||
if (!path) throw new LatestExportError("not_found");
|
||||
return { ...row, path };
|
||||
}
|
||||
|
||||
private assertWritableProject(input: Pick<Parameters<LatestExportService["saveLatest"]>[0], "ownerId" | "pixelHeight" | "pixelWidth" | "projectId" | "stateVersion">) {
|
||||
const project = this.database.prepare(`
|
||||
SELECT state_version, pixel_width, pixel_height FROM projects
|
||||
WHERE owner_id = ? AND project_id = ? AND status = 'active'
|
||||
`).get(input.ownerId, input.projectId) as { pixel_height: number; pixel_width: number; state_version: number } | undefined;
|
||||
if (!project) throw new LatestExportError("not_found");
|
||||
if (project.state_version !== input.stateVersion) throw new LatestExportError("conflict");
|
||||
if (project.pixel_width !== input.pixelWidth || project.pixel_height !== input.pixelHeight) throw new LatestExportError("invalid");
|
||||
}
|
||||
|
||||
private readByExportId(ownerId: string, exportId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT le.* FROM latest_exports le JOIN projects p ON p.project_id = le.project_id
|
||||
WHERE p.owner_id = ? AND le.export_id = ?
|
||||
`).get(ownerId, exportId) as ExportRow | undefined;
|
||||
if (!row) throw new LatestExportError("not_found");
|
||||
return this.view(row);
|
||||
}
|
||||
|
||||
private retireReplacedFile(fileId: string, timestamp: number) {
|
||||
const file = this.database.prepare("SELECT relative_path, byte_size FROM managed_files WHERE file_id = ? AND status = 'committed'")
|
||||
.get(fileId) as { byte_size: number; relative_path: string } | undefined;
|
||||
if (!file) return;
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(iso(timestamp), fileId);
|
||||
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(), fileId, file.relative_path, file.byte_size, iso(timestamp));
|
||||
}
|
||||
|
||||
private validateInput(input: Parameters<LatestExportService["saveLatest"]>[0]) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.exportId) || !["jpg", "png"].includes(input.format)
|
||||
|| !validSha256(input.sha256) || ![input.byteSize, input.pixelHeight, input.pixelWidth, input.stateVersion].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
||||
throw new LatestExportError("invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private view(row: ExportRow) {
|
||||
return {
|
||||
byteSize: row.byte_size,
|
||||
createdAt: iso(row.created_at),
|
||||
downloadUrl: `/api/v1/projects/${row.project_id}/latest-exports/${row.format}`,
|
||||
exportId: row.export_id,
|
||||
format: row.format,
|
||||
pixelHeight: row.pixel_height,
|
||||
pixelWidth: row.pixel_width,
|
||||
projectId: row.project_id,
|
||||
sha256: row.sha256,
|
||||
stateVersion: row.state_version,
|
||||
};
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS latest_export_receipts (
|
||||
export_id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||
response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
const columns = this.database.prepare("PRAGMA table_info(latest_export_receipts)").all() as Array<{ name: string }>;
|
||||
if (!columns.some((column) => column.name === "response_json")) {
|
||||
this.database.exec("ALTER TABLE latest_export_receipts ADD COLUMN response_json TEXT");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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 { LatestExportService } from "./latest-exports.js";
|
||||
import { CreditService } from "./credits.js";
|
||||
import { ProjectService } from "./projects.js";
|
||||
import { RegistrationService } from "./registration.js";
|
||||
@@ -19,6 +20,8 @@ const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"
|
||||
let registration: RegistrationService | undefined;
|
||||
let projects: ProjectService | undefined;
|
||||
let credits: CreditService | undefined;
|
||||
let storage: ManagedStorage | undefined;
|
||||
let latestExports: LatestExportService | undefined;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
if (credentialChannelEnabled) {
|
||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||
@@ -39,8 +42,14 @@ if (credentialChannelEnabled) {
|
||||
});
|
||||
projects = new ProjectService({ databasePath });
|
||||
credits = new CreditService({ databasePath });
|
||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
latestExports?.close();
|
||||
latestExports = undefined;
|
||||
storage?.close();
|
||||
storage = undefined;
|
||||
credits?.close();
|
||||
credits = undefined;
|
||||
projects?.close();
|
||||
@@ -57,6 +66,7 @@ const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"))
|
||||
const app = await createApp({
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
});
|
||||
@@ -70,9 +80,9 @@ const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
||||
if (controlPipeIndex >= 0) {
|
||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||
let storage: ManagedStorage | undefined;
|
||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||
await app.close();
|
||||
latestExports?.close();
|
||||
credits?.close();
|
||||
projects?.close();
|
||||
registration?.close();
|
||||
@@ -80,7 +90,7 @@ if (controlPipeIndex >= 0) {
|
||||
});
|
||||
try {
|
||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||
storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
||||
if (!storage) storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
||||
const logger = new StructuredJsonlLogger({
|
||||
component: "api",
|
||||
directory: join(dataRoot, "logs", "api"),
|
||||
|
||||
@@ -645,6 +645,26 @@ export class ManagedStorage {
|
||||
return row ? resolvePathWithinRoot(this.dataRoot, row.relative_path) : undefined;
|
||||
}
|
||||
|
||||
retireManagedFile(fileId: string, reason: "compensation" | "purge" = "compensation") {
|
||||
const transaction = this.database.transaction(() => {
|
||||
const file = this.database.prepare(`
|
||||
SELECT file_id, relative_path, byte_size FROM managed_files
|
||||
WHERE file_id = ? AND status = 'committed'
|
||||
`).get(fileId) as { byte_size: number; file_id: string; relative_path: string } | undefined;
|
||||
if (!file) return;
|
||||
const retiredAt = now();
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(retiredAt, fileId);
|
||||
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, ?, 'pending', ?, NULL, NULL)
|
||||
`).run(randomUUID(), fileId, file.relative_path, file.byte_size, reason, retiredAt);
|
||||
});
|
||||
transaction.immediate();
|
||||
}
|
||||
|
||||
addAssetReference(fileId: string, referenceType: "project" | "release") {
|
||||
if (this.inspectAction("project_json_write") !== "allow") throw new StorageUnavailableError();
|
||||
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, ?, ?)`)
|
||||
|
||||
@@ -72,6 +72,17 @@ interface ProjectStateRow {
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
interface LatestExportRow {
|
||||
byte_size: number;
|
||||
created_at: number;
|
||||
export_id: string;
|
||||
format: "jpg" | "png";
|
||||
pixel_height: number;
|
||||
pixel_width: number;
|
||||
sha256: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
function isProjectRatio(value: string): value is ProjectRatio {
|
||||
return projectRatios.includes(value as ProjectRatio);
|
||||
}
|
||||
@@ -510,6 +521,10 @@ export class ProjectService {
|
||||
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 latestExports = this.database.prepare(`
|
||||
SELECT export_id, format, sha256, byte_size, pixel_width, pixel_height, state_version, created_at
|
||||
FROM latest_exports WHERE project_id = ? ORDER BY format
|
||||
`).all(projectId) as LatestExportRow[];
|
||||
const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
|
||||
const projectState = this.readProjectState(projectId);
|
||||
return {
|
||||
@@ -519,6 +534,17 @@ export class ProjectService {
|
||||
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 })),
|
||||
latestExports: latestExports.map((item) => ({
|
||||
byteSize: item.byte_size,
|
||||
createdAt: iso(item.created_at),
|
||||
downloadUrl: `/api/v1/projects/${projectId}/latest-exports/${item.format}`,
|
||||
exportId: item.export_id,
|
||||
format: item.format,
|
||||
pixelHeight: item.pixel_height,
|
||||
pixelWidth: item.pixel_width,
|
||||
sha256: item.sha256,
|
||||
stateVersion: item.state_version,
|
||||
})),
|
||||
pixelHeight: row.pixel_height,
|
||||
pixelWidth: row.pixel_width,
|
||||
saveStatus: "saved" as const,
|
||||
@@ -748,6 +774,20 @@ export class ProjectService {
|
||||
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 latest_exports (
|
||||
project_id TEXT NOT NULL,
|
||||
format TEXT NOT NULL CHECK (format IN ('jpg', 'png')),
|
||||
export_id TEXT NOT NULL UNIQUE,
|
||||
managed_file_id TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||
sha256 TEXT NOT NULL CHECK (length(sha256) = 64),
|
||||
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||
pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
|
||||
pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, format),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project_cleanup_queue (
|
||||
cleanup_id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
@@ -1210,6 +1210,10 @@ export class RegistrationService {
|
||||
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);
|
||||
const exportReceiptsTable = this.database.prepare(`
|
||||
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'latest_export_receipts'
|
||||
`).get();
|
||||
if (exportReceiptsTable) this.database.prepare("DELETE FROM latest_export_receipts 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 = ?")
|
||||
|
||||
Reference in New Issue
Block a user