feat: complete TASK-WP2-06 generation terminal states
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type { GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
|
||||
export interface GenerationAdapterRequest {
|
||||
configSnapshot: Readonly<Record<string, unknown>>;
|
||||
generationId: string;
|
||||
modelId: string;
|
||||
prompt: string;
|
||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||
referenceAssetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface NormalizedGenerationOutput {
|
||||
bytes: Buffer;
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||
pixelHeight: number;
|
||||
pixelWidth: number;
|
||||
}
|
||||
|
||||
export type GenerationAdapterResult =
|
||||
| { outputs: readonly NormalizedGenerationOutput[]; status: "completed" }
|
||||
| {
|
||||
balanceSignal?: { gatewayAccountRef: string; impactScope: "model" | "account" | "unknown" };
|
||||
category: GenerationErrorCategory;
|
||||
sourceCategory: string;
|
||||
status: "failed";
|
||||
};
|
||||
|
||||
export interface GenerationAdapter {
|
||||
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
||||
}
|
||||
|
||||
type MockResult = GenerationAdapterResult & { unsafeRaw?: string };
|
||||
|
||||
export class MockGenerationAdapter implements GenerationAdapter {
|
||||
readonly calls: Array<{ generationId: string; modelId: string }> = [];
|
||||
private readonly result: MockResult;
|
||||
|
||||
constructor(result: MockResult) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
|
||||
this.calls.push({ generationId: request.generationId, modelId: request.modelId });
|
||||
if (this.result.status === "completed") {
|
||||
return { outputs: this.result.outputs.map((output) => ({ ...output, bytes: Buffer.from(output.bytes) })), status: "completed" };
|
||||
}
|
||||
return {
|
||||
...(this.result.balanceSignal ? { balanceSignal: { ...this.result.balanceSignal } } : {}),
|
||||
category: this.result.category,
|
||||
sourceCategory: this.result.sourceCategory,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||
|
||||
const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||
const accountRefPattern = /^[a-z][a-z0-9_-]{2,119}$/;
|
||||
const sourceCategoryPattern = /^[a-z][a-z0-9_]{0,79}$/;
|
||||
|
||||
interface RuntimeRow {
|
||||
available_for_new_jobs: number;
|
||||
balance_blocked: number;
|
||||
gateway_account_ref: string;
|
||||
model_id: string;
|
||||
runtime_reason: string;
|
||||
runtime_version: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface BalanceRow {
|
||||
affected_model_ids_json: string;
|
||||
balance_status: "available" | "insufficient" | "unknown";
|
||||
detected_at: number;
|
||||
gateway_account_ref: string;
|
||||
impact_scope: "model" | "account" | "unknown";
|
||||
last_confirmed_at: number | null;
|
||||
recovery_status: "not_required" | "awaiting_confirmation" | "confirmed";
|
||||
runtime_unavailable_model_ids_json: string;
|
||||
source_category: string;
|
||||
}
|
||||
|
||||
export interface GatewayBalanceView {
|
||||
affectedModelIds: string[];
|
||||
balanceStatus: "available" | "insufficient" | "unknown";
|
||||
detectedAt: string;
|
||||
gatewayAccountRef: string;
|
||||
impactScope: "model" | "account" | "unknown";
|
||||
lastConfirmedAt: string | null;
|
||||
recoveryStatus: "not_required" | "awaiting_confirmation" | "confirmed";
|
||||
runtimeUnavailableModelIds: string[];
|
||||
sourceCategory: string;
|
||||
}
|
||||
|
||||
export class GatewayBalanceRuntime {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
private readonly ownsDatabase: boolean;
|
||||
|
||||
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||
if (!input.database && !input.databasePath) throw new Error("gateway_balance_database_required");
|
||||
this.database = input.database ?? new Database(input.databasePath!);
|
||||
this.ownsDatabase = !input.database;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
if (this.ownsDatabase) configureWorkerDatabase(this.database);
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ownsDatabase) this.database.close();
|
||||
}
|
||||
|
||||
seedModels(models: Array<{ gatewayAccountRef: string; modelId: string }>) {
|
||||
const now = this.clock();
|
||||
this.immediate(() => {
|
||||
for (const model of models) {
|
||||
if (!accountRefPattern.test(model.gatewayAccountRef) || !model.modelId) throw new Error("gateway_balance_model_invalid");
|
||||
this.database.prepare(`
|
||||
INSERT INTO model_runtime_availability (
|
||||
model_id, gateway_account_ref, balance_blocked, available_for_new_jobs, runtime_reason, runtime_version, updated_at
|
||||
) VALUES (?, ?, 0, 1, 'available', 1, ?)
|
||||
ON CONFLICT(model_id) DO UPDATE SET gateway_account_ref = excluded.gateway_account_ref
|
||||
`).run(model.modelId, model.gatewayAccountRef, now);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recordInsufficient(input: {
|
||||
eventId: string;
|
||||
gatewayAccountRef: string;
|
||||
impactScope: "model" | "account" | "unknown";
|
||||
modelId: string;
|
||||
sourceCategory: string;
|
||||
}) {
|
||||
if (!input.eventId || !accountRefPattern.test(input.gatewayAccountRef) || !input.modelId
|
||||
|| !sourceCategoryPattern.test(input.sourceCategory)) throw new Error("gateway_balance_signal_invalid");
|
||||
return this.immediate(() => {
|
||||
const receipt = this.database.prepare("SELECT response_json FROM gateway_balance_event_receipts WHERE event_id = ?")
|
||||
.get(input.eventId) as { response_json: string } | undefined;
|
||||
if (receipt) return JSON.parse(receipt.response_json) as GatewayBalanceView;
|
||||
this.database.prepare(`
|
||||
INSERT INTO model_runtime_availability (
|
||||
model_id, gateway_account_ref, balance_blocked, available_for_new_jobs, runtime_reason, runtime_version, updated_at
|
||||
) VALUES (?, ?, 0, 1, 'available', 1, ?)
|
||||
ON CONFLICT(model_id) DO UPDATE SET gateway_account_ref = excluded.gateway_account_ref
|
||||
`).run(input.modelId, input.gatewayAccountRef, this.clock());
|
||||
const rows = this.database.prepare("SELECT model_id FROM model_runtime_availability WHERE gateway_account_ref = ? ORDER BY model_id")
|
||||
.all(input.gatewayAccountRef) as Array<{ model_id: string }>;
|
||||
const newlyAffected = input.impactScope === "model" ? [input.modelId] : rows.map((row) => row.model_id);
|
||||
const existing = this.database.prepare("SELECT runtime_unavailable_model_ids_json FROM gateway_balance_states WHERE gateway_account_ref = ?")
|
||||
.get(input.gatewayAccountRef) as { runtime_unavailable_model_ids_json: string } | undefined;
|
||||
const unavailable = [...new Set([...(existing ? JSON.parse(existing.runtime_unavailable_model_ids_json) as string[] : []), ...newlyAffected])].toSorted();
|
||||
const now = this.clock();
|
||||
for (const modelId of unavailable) {
|
||||
this.database.prepare(`
|
||||
UPDATE model_runtime_availability
|
||||
SET balance_blocked = 1, available_for_new_jobs = 0, runtime_reason = 'gateway_balance_insufficient',
|
||||
runtime_version = runtime_version + 1, updated_at = ?
|
||||
WHERE model_id = ? AND gateway_account_ref = ? AND balance_blocked = 0
|
||||
`).run(now, modelId, input.gatewayAccountRef);
|
||||
}
|
||||
this.database.prepare(`
|
||||
INSERT INTO gateway_balance_states (
|
||||
gateway_account_ref, balance_status, impact_scope, affected_model_ids_json,
|
||||
runtime_unavailable_model_ids_json, detected_at, last_confirmed_at, recovery_status, source_category
|
||||
) VALUES (?, 'insufficient', ?, ?, ?, ?, NULL, 'awaiting_confirmation', ?)
|
||||
ON CONFLICT(gateway_account_ref) DO UPDATE SET
|
||||
balance_status = 'insufficient', impact_scope = excluded.impact_scope,
|
||||
affected_model_ids_json = excluded.affected_model_ids_json,
|
||||
runtime_unavailable_model_ids_json = excluded.runtime_unavailable_model_ids_json,
|
||||
detected_at = excluded.detected_at, last_confirmed_at = NULL,
|
||||
recovery_status = 'awaiting_confirmation', source_category = excluded.source_category
|
||||
`).run(input.gatewayAccountRef, input.impactScope, JSON.stringify(newlyAffected.toSorted()), JSON.stringify(unavailable), now, input.sourceCategory);
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'system', 'generation_worker', 'gateway_balance_detected', 'gateway_balance_state', ?, 'succeeded', NULL, ?, ?, ?)
|
||||
`).run(randomUUID(), input.gatewayAccountRef, JSON.stringify({ impact_scope: input.impactScope, runtime_unavailable_model_ids: unavailable }), now, now + auditRetentionMilliseconds);
|
||||
const result = this.readState(input.gatewayAccountRef)!;
|
||||
this.database.prepare("INSERT INTO gateway_balance_event_receipts (event_id, gateway_account_ref, response_json, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(input.eventId, input.gatewayAccountRef, JSON.stringify(result), now);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
readState(gatewayAccountRef: string) {
|
||||
const row = this.database.prepare("SELECT * FROM gateway_balance_states WHERE gateway_account_ref = ?").get(gatewayAccountRef) as BalanceRow | undefined;
|
||||
return row ? this.view(row) : undefined;
|
||||
}
|
||||
|
||||
listRuntime() {
|
||||
const rows = this.database.prepare("SELECT * FROM model_runtime_availability ORDER BY model_id").all() as RuntimeRow[];
|
||||
return rows.map((row) => ({
|
||||
availableForNewJobs: row.available_for_new_jobs === 1,
|
||||
balanceBlocked: row.balance_blocked === 1,
|
||||
gatewayAccountRef: row.gateway_account_ref,
|
||||
modelId: row.model_id,
|
||||
runtimeReason: row.runtime_reason,
|
||||
runtimeVersion: row.runtime_version,
|
||||
updatedAt: new Date(row.updated_at).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
restoreWithoutConfirmedRecovery(gatewayAccountRef: string, _actorId: string) {
|
||||
const state = this.readState(gatewayAccountRef);
|
||||
if (!state || state.recoveryStatus !== "confirmed") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||
throw new Error("gateway_balance_recovery_owned_by_wp3");
|
||||
}
|
||||
|
||||
private view(row: BalanceRow): GatewayBalanceView {
|
||||
return {
|
||||
affectedModelIds: JSON.parse(row.affected_model_ids_json) as string[],
|
||||
balanceStatus: row.balance_status,
|
||||
detectedAt: new Date(row.detected_at).toISOString(),
|
||||
gatewayAccountRef: row.gateway_account_ref,
|
||||
impactScope: row.impact_scope,
|
||||
lastConfirmedAt: row.last_confirmed_at === null ? null : new Date(row.last_confirmed_at).toISOString(),
|
||||
recoveryStatus: row.recovery_status,
|
||||
runtimeUnavailableModelIds: JSON.parse(row.runtime_unavailable_model_ids_json) as string[],
|
||||
sourceCategory: row.source_category,
|
||||
};
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T) {
|
||||
if (this.database.inTransaction) return action();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
gateway_account_ref TEXT NOT NULL,
|
||||
balance_blocked INTEGER NOT NULL CHECK (balance_blocked IN (0, 1)),
|
||||
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||
runtime_reason TEXT NOT NULL CHECK (runtime_reason IN (
|
||||
'available', 'configured_disabled', 'contract_unverified', 'contract_blocked',
|
||||
'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded'
|
||||
)),
|
||||
runtime_version INTEGER NOT NULL CHECK (runtime_version > 0),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gateway_balance_states (
|
||||
gateway_account_ref TEXT PRIMARY KEY,
|
||||
balance_status TEXT NOT NULL CHECK (balance_status IN ('available', 'insufficient', 'unknown')),
|
||||
impact_scope TEXT NOT NULL CHECK (impact_scope IN ('model', 'account', 'unknown')),
|
||||
affected_model_ids_json TEXT NOT NULL CHECK (json_valid(affected_model_ids_json)),
|
||||
runtime_unavailable_model_ids_json TEXT NOT NULL CHECK (json_valid(runtime_unavailable_model_ids_json)),
|
||||
detected_at INTEGER NOT NULL,
|
||||
last_confirmed_at INTEGER,
|
||||
recovery_status TEXT NOT NULL CHECK (recovery_status IN ('not_required', 'awaiting_confirmation', 'confirmed')),
|
||||
source_category TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gateway_balance_event_receipts (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
gateway_account_ref TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||
log_id TEXT PRIMARY KEY,
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||
actor_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(actor_ref) = 1),
|
||||
operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1),
|
||||
target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1),
|
||||
target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1),
|
||||
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||
before_summary TEXT CHECK (before_summary IS NULL OR dada_audit_summary_is_safe(before_summary) = 1),
|
||||
after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1),
|
||||
occurred_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||
BEFORE DELETE ON admin_operation_logs
|
||||
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
|
||||
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const generationErrorCategories = [
|
||||
"upstream_timeout",
|
||||
"upstream_failed",
|
||||
"safety_rejected",
|
||||
"model_disabled",
|
||||
"gateway_balance_insufficient",
|
||||
"gateway_contract_invalid",
|
||||
"reference_invalid",
|
||||
"unknown_retryable",
|
||||
"unknown_non_retryable",
|
||||
] as const;
|
||||
|
||||
export type GenerationErrorCategory = typeof generationErrorCategories[number];
|
||||
export type GenerationUserAction = "retry_same_input" | "wait_and_retry" | "edit_input" | "choose_model_or_contact_admin" | "contact_admin";
|
||||
|
||||
export interface GenerationErrorDefinition {
|
||||
creditBehavior: "no_reserve" | "release_if_reserved";
|
||||
messageKey: string;
|
||||
retryPolicy: "immediate" | "after_wait" | "after_edit" | "after_model_change" | "none";
|
||||
taskOutcome: "not_created" | "failed" | "rejected" | "failed_or_not_created";
|
||||
userAction: GenerationUserAction;
|
||||
}
|
||||
|
||||
export const generationErrorRegistry: Readonly<Record<GenerationErrorCategory, Readonly<GenerationErrorDefinition>>> = Object.freeze({
|
||||
upstream_timeout: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.upstream_timeout", retryPolicy: "immediate", taskOutcome: "failed", userAction: "retry_same_input" }),
|
||||
upstream_failed: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.upstream_failed", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" }),
|
||||
safety_rejected: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.safety_rejected", retryPolicy: "after_edit", taskOutcome: "rejected", userAction: "edit_input" }),
|
||||
model_disabled: Object.freeze({ creditBehavior: "no_reserve", messageKey: "generation.error.model_disabled", retryPolicy: "after_model_change", taskOutcome: "not_created", userAction: "choose_model_or_contact_admin" }),
|
||||
gateway_balance_insufficient: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.gateway_balance_insufficient", retryPolicy: "after_model_change", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" }),
|
||||
gateway_contract_invalid: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.gateway_contract_invalid", retryPolicy: "after_model_change", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" }),
|
||||
reference_invalid: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.reference_invalid", retryPolicy: "after_edit", taskOutcome: "failed_or_not_created", userAction: "edit_input" }),
|
||||
unknown_retryable: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.unknown_retryable", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" }),
|
||||
unknown_non_retryable: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.unknown_non_retryable", retryPolicy: "none", taskOutcome: "failed", userAction: "contact_admin" }),
|
||||
});
|
||||
|
||||
export function isGenerationErrorCategory(value: unknown): value is GenerationErrorCategory {
|
||||
return typeof value === "string" && generationErrorCategories.includes(value as GenerationErrorCategory);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
||||
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||
|
||||
const hardLimitBytes = 5_368_709_120;
|
||||
const warningLimitBytes = 4_294_967_296;
|
||||
const criticalLimitBytes = 4_831_838_208;
|
||||
const maximumOutputBytes = 20 * 1_024 * 1_024;
|
||||
const safeSourceCategory = /^[a-z][a-z0-9_]{0,79}$/;
|
||||
|
||||
interface JobRow {
|
||||
config_snapshot_json: string;
|
||||
error_category: GenerationErrorCategory | null;
|
||||
final_credit_state: "committed" | "released" | null;
|
||||
generation_id: string;
|
||||
model_id: string;
|
||||
owner_id: string;
|
||||
project_id: string;
|
||||
prompt: string;
|
||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
}
|
||||
|
||||
interface ReservationRow {
|
||||
amount: number;
|
||||
model_id: string;
|
||||
status: "reserved" | "committed" | "released";
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface GenerationProcessingResult {
|
||||
category: GenerationErrorCategory | null;
|
||||
generationId: string;
|
||||
outputAssetId: string | null;
|
||||
projectId: string;
|
||||
status: "succeeded" | "failed" | "rejected";
|
||||
}
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function extensionFor(mimeType: NormalizedGenerationOutput["mimeType"]) {
|
||||
return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : ".webp";
|
||||
}
|
||||
|
||||
function hasExpectedMagic(output: NormalizedGenerationOutput) {
|
||||
const bytes = output.bytes;
|
||||
if (output.mimeType === "image/png") return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||
if (output.mimeType === "image/jpeg") return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
return bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
|
||||
}
|
||||
|
||||
function capacityClass(bytes: number) {
|
||||
return {
|
||||
capacity: bytes < warningLimitBytes ? "normal" : bytes < criticalLimitBytes ? "warning" : "critical",
|
||||
status: bytes >= hardLimitBytes ? "full" : "active",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export class GenerationProcessor {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly adapter: GenerationAdapter;
|
||||
private readonly clock: () => number;
|
||||
private readonly dataRoot: string;
|
||||
private readonly gatewayBalance: GatewayBalanceRuntime;
|
||||
private readonly workerId: string;
|
||||
|
||||
constructor(input: { adapter: GenerationAdapter; clock?: () => number; dataRoot: string; databasePath: string; workerId: string }) {
|
||||
if (!input.workerId) throw new Error("generation_worker_id_required");
|
||||
this.adapter = input.adapter;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.dataRoot = resolve(input.dataRoot);
|
||||
this.workerId = input.workerId;
|
||||
this.database = new Database(input.databasePath);
|
||||
configureWorkerDatabase(this.database);
|
||||
this.migrate();
|
||||
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
async processNext() {
|
||||
const row = this.database.prepare(`
|
||||
SELECT generation_id FROM generation_jobs
|
||||
WHERE status = 'queued' AND submission_ready = 1
|
||||
ORDER BY created_at, generation_id LIMIT 1
|
||||
`).get() as { generation_id: string } | undefined;
|
||||
return row ? this.processGeneration(row.generation_id) : undefined;
|
||||
}
|
||||
|
||||
async processGeneration(generationId: string): Promise<GenerationProcessingResult> {
|
||||
const replay = this.readReceipt(generationId);
|
||||
if (replay) return replay;
|
||||
const job = this.claim(generationId);
|
||||
if (["succeeded", "failed", "rejected"].includes(job.status)) return this.terminalResult(job);
|
||||
|
||||
const references = this.database.prepare(`
|
||||
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||
`).all(generationId) as Array<{ managed_file_id: string }>;
|
||||
let adapterResult: GenerationAdapterResult;
|
||||
try {
|
||||
adapterResult = await this.adapter.start({
|
||||
configSnapshot: JSON.parse(job.config_snapshot_json) as Record<string, unknown>,
|
||||
generationId,
|
||||
modelId: job.model_id,
|
||||
prompt: job.prompt,
|
||||
ratio: job.ratio,
|
||||
referenceAssetIds: references.map((row) => row.managed_file_id),
|
||||
});
|
||||
} catch {
|
||||
adapterResult = { category: "unknown_retryable", sourceCategory: "adapter_exception", status: "failed" };
|
||||
}
|
||||
|
||||
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
||||
const output = adapterResult.outputs.length === 1 ? adapterResult.outputs[0] : undefined;
|
||||
if (!output || !this.isSafeOutput(job, output)) {
|
||||
return this.completeFailure(job, adapterResult.outputs.length === 1 ? "unknown_retryable" : "gateway_contract_invalid", "output_validation_failed", undefined, true);
|
||||
}
|
||||
try {
|
||||
return this.completeSuccess(job, output);
|
||||
} catch {
|
||||
return this.completeFailure(job, "unknown_retryable", "output_persist_failed", undefined, true);
|
||||
}
|
||||
}
|
||||
|
||||
private claim(generationId: string) {
|
||||
return this.immediate(() => {
|
||||
const row = this.readJob(generationId);
|
||||
if (row.status === "queued") {
|
||||
const now = this.clock();
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE generation_jobs
|
||||
SET status = 'running', lease_owner = ?, lease_expires_at = ?, heartbeat_at = ?, started_at = COALESCE(started_at, ?),
|
||||
attempt_no = attempt_no + 1, updated_at = ?
|
||||
WHERE generation_id = ? AND status = 'queued' AND submission_ready = 1
|
||||
`).run(this.workerId, now + 30_000, now, now, now, generationId);
|
||||
if (changed.changes !== 1) throw new Error("generation_claim_conflict");
|
||||
return this.readJob(generationId);
|
||||
}
|
||||
if (row.status === "running" && row.final_credit_state === null) return row;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
private completeFailure(
|
||||
job: JobRow,
|
||||
category: GenerationErrorCategory,
|
||||
sourceCategory: string,
|
||||
balanceSignal?: { gatewayAccountRef: string; impactScope: "model" | "account" | "unknown" },
|
||||
upstreamOutcomeKnown = false,
|
||||
) {
|
||||
return this.immediate(() => {
|
||||
const replay = this.readReceipt(job.generation_id);
|
||||
if (replay) return replay;
|
||||
const latest = this.readJob(job.generation_id);
|
||||
if (["succeeded", "failed", "rejected"].includes(latest.status)) return this.terminalResult(latest);
|
||||
const status = category === "safety_rejected" ? "rejected" as const : "failed" as const;
|
||||
this.finalizeCredit(job.generation_id, status);
|
||||
if (balanceSignal) {
|
||||
this.gatewayBalance.seedModels([{ gatewayAccountRef: balanceSignal.gatewayAccountRef, modelId: job.model_id }]);
|
||||
this.gatewayBalance.recordInsufficient({
|
||||
eventId: `generation-${job.generation_id}`,
|
||||
gatewayAccountRef: balanceSignal.gatewayAccountRef,
|
||||
impactScope: balanceSignal.impactScope,
|
||||
modelId: job.model_id,
|
||||
sourceCategory: "adapter_balance_signal",
|
||||
});
|
||||
}
|
||||
const now = this.clock();
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs
|
||||
SET status = ?, error_category = ?, diagnostic_source_category = ?, final_credit_state = 'released',
|
||||
upstream_outcome_known = ?, upstream_cost_reconciliation = ?, finished_at = ?, updated_at = ?,
|
||||
lease_owner = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||||
WHERE generation_id = ? AND status = 'running'
|
||||
`).run(
|
||||
status,
|
||||
category,
|
||||
safeSourceCategory.test(sourceCategory) ? sourceCategory : "adapter_error",
|
||||
upstreamOutcomeKnown ? 1 : 0,
|
||||
upstreamOutcomeKnown ? "pending_manual_review" : "not_required",
|
||||
now,
|
||||
now,
|
||||
job.generation_id,
|
||||
);
|
||||
const result = this.terminalResult(this.readJob(job.generation_id));
|
||||
this.writeReceipt(result, now);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private completeSuccess(job: JobRow, output: NormalizedGenerationOutput) {
|
||||
const fileId = randomUUID();
|
||||
const operationId = randomUUID();
|
||||
const extension = extensionFor(output.mimeType);
|
||||
const relativePath = `content/generated/${job.owner_id}/${fileId}${extension}`;
|
||||
const destinationPath = resolve(this.dataRoot, relativePath);
|
||||
const stagingDirectory = resolve(this.dataRoot, "staging", operationId);
|
||||
const stagingPath = join(stagingDirectory, "payload.tmp");
|
||||
if (!destinationPath.startsWith(`${this.dataRoot}\\`) && !destinationPath.startsWith(`${this.dataRoot}/`)) throw new Error("generation_output_path_invalid");
|
||||
|
||||
this.reserveStorage(operationId, output.bytes.byteLength);
|
||||
let renamed = false;
|
||||
try {
|
||||
mkdirSync(stagingDirectory, { recursive: true });
|
||||
writeFileSync(stagingPath, output.bytes, { flag: "wx" });
|
||||
mkdirSync(dirname(destinationPath), { recursive: true });
|
||||
renameSync(stagingPath, destinationPath);
|
||||
renamed = true;
|
||||
rmSync(stagingDirectory, { force: true, recursive: true });
|
||||
return this.immediate(() => {
|
||||
const replay = this.readReceipt(job.generation_id);
|
||||
if (replay) return replay;
|
||||
const now = this.clock();
|
||||
const project = this.database.prepare("SELECT name, state_version, current_image_id FROM projects WHERE project_id = ? AND status = 'active'")
|
||||
.get(job.project_id) as { current_image_id: string | null; name: string; state_version: number } | undefined;
|
||||
if (!project) throw new Error("generation_project_unavailable");
|
||||
const state = this.database.prepare("SELECT canvas_json FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1")
|
||||
.get(job.project_id) as { canvas_json: string } | undefined;
|
||||
if (!state) throw new Error("generation_project_state_unavailable");
|
||||
const canvas = JSON.parse(state.canvas_json) as { background: { asset_id: string | null }; [key: string]: unknown };
|
||||
if (project.current_image_id === null) canvas.background.asset_id = fileId;
|
||||
this.database.prepare(`
|
||||
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||
VALUES (?, 'generated', ?, ?, ?, ?, ?, 'committed', ?)
|
||||
`).run(fileId, job.owner_id, relativePath, output.bytes.byteLength, output.mimeType, createHash("sha256").update(output.bytes).digest("hex"), iso(now));
|
||||
this.database.prepare("INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'generated', ?)")
|
||||
.run(job.project_id, fileId, now);
|
||||
this.database.prepare("INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)")
|
||||
.run(`project:${job.project_id}:${fileId}`, fileId, iso(now));
|
||||
this.database.prepare(`
|
||||
INSERT INTO generation_output_assets (generation_id, managed_file_id, pixel_width, pixel_height, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(job.generation_id, fileId, output.pixelWidth, output.pixelHeight, now);
|
||||
this.database.prepare("INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(fileId, job.project_id, job.generation_id, 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(fileId, now, job.project_id);
|
||||
this.database.prepare("INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(job.project_id, project.state_version + 1, project.name, JSON.stringify(canvas), now);
|
||||
this.consumeStorage(operationId, output.bytes.byteLength, now);
|
||||
this.finalizeCredit(job.generation_id, "succeeded");
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs
|
||||
SET status = 'succeeded', error_category = NULL, final_credit_state = 'committed', output_asset_id = ?,
|
||||
upstream_outcome_known = 1, upstream_cost_reconciliation = 'not_required', finished_at = ?, updated_at = ?,
|
||||
lease_owner = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||||
WHERE generation_id = ? AND status = 'running'
|
||||
`).run(fileId, now, now, job.generation_id);
|
||||
const result = this.terminalResult(this.readJob(job.generation_id));
|
||||
this.writeReceipt(result, now);
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
if (renamed && existsSync(destinationPath)) this.queueCompensation(relativePath, output.bytes.byteLength);
|
||||
else rmSync(stagingDirectory, { force: true, recursive: true });
|
||||
this.releaseStorage(operationId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isSafeOutput(job: JobRow, output: NormalizedGenerationOutput) {
|
||||
const pixels = { "1:1": [1080, 1080], "3:4": [1080, 1440], "4:3": [1440, 1080], "9:16": [1080, 1920] }[job.ratio];
|
||||
return output.bytes.byteLength > 0 && output.bytes.byteLength <= maximumOutputBytes && hasExpectedMagic(output)
|
||||
&& output.pixelWidth === pixels[0] && output.pixelHeight === pixels[1];
|
||||
}
|
||||
|
||||
private finalizeCredit(generationId: string, outcome: "succeeded" | "failed" | "rejected") {
|
||||
const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||
.get(generationId) as ReservationRow | undefined;
|
||||
if (!reservation) throw new Error("generation_credit_reservation_missing");
|
||||
if (reservation.status !== "reserved") return;
|
||||
const account = this.database.prepare("SELECT available_balance, reserved_balance FROM credit_accounts WHERE user_id = ?")
|
||||
.get(reservation.user_id) as { available_balance: number; reserved_balance: number } | undefined;
|
||||
if (!account || account.reserved_balance < reservation.amount) throw new Error("generation_credit_invariant_failed");
|
||||
const committed = outcome === "succeeded";
|
||||
const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount;
|
||||
const reservedAfter = account.reserved_balance - reservation.amount;
|
||||
const now = this.clock();
|
||||
const operationKey = `generation:${generationId}:${committed ? "commit" : "release"}`;
|
||||
this.database.prepare("UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ?")
|
||||
.run(availableAfter, reservedAfter, now, reservation.user_id);
|
||||
this.database.prepare("UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved'")
|
||||
.run(committed ? "committed" : "released", now, generationId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_ledger (
|
||||
ledger_id, user_id, operation_key, entry_type, amount, available_before, available_after,
|
||||
reserved_before, reserved_after, created_at, reference_type, reference_id, model_id, reason, entry_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'generation', ?, ?, NULL, ?)
|
||||
`).run(
|
||||
randomUUID(), reservation.user_id, operationKey, committed ? "generation_commit" : "generation_release",
|
||||
committed ? -reservation.amount : reservation.amount, account.available_balance, availableAfter,
|
||||
account.reserved_balance, reservedAfter, now, generationId, reservation.model_id, committed ? "committed" : "released",
|
||||
);
|
||||
this.database.prepare(`
|
||||
INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at)
|
||||
VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL)
|
||||
`).run(randomUUID(), operationKey, committed ? "generation_credit_committed" : "generation_credit_released", generationId, JSON.stringify({ outcome }), now);
|
||||
}
|
||||
|
||||
private reserveStorage(operationId: string, bytes: number) {
|
||||
this.immediate(() => {
|
||||
const state = this.database.prepare("SELECT managed_content_bytes, storage_status FROM local_backend_storage_state WHERE singleton = 1")
|
||||
.get() as { managed_content_bytes: number; storage_status: string } | undefined;
|
||||
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'")
|
||||
.get() as { bytes: number };
|
||||
if (!state || state.storage_status !== "active" || state.managed_content_bytes + active.bytes + bytes > hardLimitBytes) {
|
||||
throw new Error("generation_storage_unavailable");
|
||||
}
|
||||
this.database.prepare("INSERT INTO storage_reservations (reservation_id, operation_id, projected_bytes, status, created_at) VALUES (?, ?, ?, 'active', ?)")
|
||||
.run(randomUUID(), operationId, bytes, iso(this.clock()));
|
||||
});
|
||||
}
|
||||
|
||||
private consumeStorage(operationId: string, bytes: number, now: number) {
|
||||
this.database.prepare("UPDATE storage_reservations SET status = 'consumed', resolved_at = ? WHERE operation_id = ? AND status = 'active'")
|
||||
.run(iso(now), operationId);
|
||||
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1")
|
||||
.get() as { managed_content_bytes: number };
|
||||
const next = state.managed_content_bytes + bytes;
|
||||
const classification = capacityClass(next);
|
||||
this.database.prepare(`
|
||||
UPDATE local_backend_storage_state
|
||||
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||
WHERE singleton = 1
|
||||
`).run(next, classification.capacity, classification.status, iso(now));
|
||||
}
|
||||
|
||||
private releaseStorage(operationId: string) {
|
||||
try {
|
||||
this.database.prepare("UPDATE storage_reservations SET status = 'released', resolved_at = ? WHERE operation_id = ? AND status = 'active'")
|
||||
.run(iso(this.clock()), operationId);
|
||||
} catch {
|
||||
// Startup storage reconciliation remains the fallback if SQLite is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private queueCompensation(relativePath: string, bytes: number) {
|
||||
try {
|
||||
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
|
||||
) VALUES (?, NULL, ?, ?, 0, 'compensation', 'pending', ?)
|
||||
`).run(randomUUID(), relativePath, bytes, iso(this.clock()));
|
||||
} catch {
|
||||
// Startup reconciliation detects unindexed generated files as a final fallback.
|
||||
}
|
||||
}
|
||||
|
||||
private readJob(generationId: string) {
|
||||
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1").get(generationId) as JobRow | undefined;
|
||||
if (!row) throw new Error("generation_not_found");
|
||||
return row;
|
||||
}
|
||||
|
||||
private terminalResult(row: JobRow): GenerationProcessingResult {
|
||||
if (!["succeeded", "failed", "rejected"].includes(row.status)) throw new Error("generation_not_terminal");
|
||||
const output = this.database.prepare("SELECT managed_file_id FROM generation_output_assets WHERE generation_id = ?")
|
||||
.get(row.generation_id) as { managed_file_id: string } | undefined;
|
||||
return {
|
||||
category: row.error_category,
|
||||
generationId: row.generation_id,
|
||||
outputAssetId: output?.managed_file_id ?? null,
|
||||
projectId: row.project_id,
|
||||
status: row.status as GenerationProcessingResult["status"],
|
||||
};
|
||||
}
|
||||
|
||||
private readReceipt(generationId: string) {
|
||||
const row = this.database.prepare("SELECT response_json FROM generation_processor_receipts WHERE generation_id = ?")
|
||||
.get(generationId) as { response_json: string } | undefined;
|
||||
return row ? JSON.parse(row.response_json) as GenerationProcessingResult : undefined;
|
||||
}
|
||||
|
||||
private writeReceipt(result: GenerationProcessingResult, now: number) {
|
||||
this.database.prepare("INSERT INTO generation_processor_receipts (generation_id, response_json, created_at) VALUES (?, ?, ?)")
|
||||
.run(result.generationId, JSON.stringify(result), now);
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T) {
|
||||
if (this.database.inTransaction) return action();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureColumn(table: string, column: string, definition: string) {
|
||||
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!columns.some((entry) => entry.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.ensureColumn("generation_jobs", "lease_owner", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "lease_expires_at", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "heartbeat_at", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "started_at", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "attempt_no", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.ensureColumn("generation_jobs", "output_asset_id", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "diagnostic_source_category", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "upstream_outcome_known", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.ensureColumn("generation_jobs", "upstream_cost_reconciliation", "TEXT NOT NULL DEFAULT 'not_applicable'");
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS generation_output_assets (
|
||||
generation_id TEXT PRIMARY KEY REFERENCES generation_jobs(generation_id),
|
||||
managed_file_id TEXT NOT NULL UNIQUE REFERENCES managed_files(file_id),
|
||||
pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
|
||||
pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_processor_receipts (
|
||||
generation_id TEXT PRIMARY KEY REFERENCES generation_jobs(generation_id),
|
||||
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||
const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/;
|
||||
const forbiddenKeys = new Set([
|
||||
"absolute_path", "api_key", "body", "code_hmac", "content", "credential", "email", "image",
|
||||
"image_content", "password", "path", "prompt", "secret", "session_token", "verification_code", "whitelist",
|
||||
]);
|
||||
const forbiddenKeyFragments = ["content", "credential", "email", "image", "password", "path", "prompt", "secret", "token"];
|
||||
|
||||
function safeSummaryValue(value: unknown, depth: number): boolean {
|
||||
if (depth > 3) return false;
|
||||
if (value === null || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isSafeInteger(value);
|
||||
if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@");
|
||||
if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => safeSummaryValue(entry, depth + 1));
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entries = Object.entries(value);
|
||||
return entries.length <= 32 && entries.every(([key, entry]) => (
|
||||
auditRefPattern.test(key)
|
||||
&& !forbiddenKeys.has(key.toLowerCase())
|
||||
&& !forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||
&& safeSummaryValue(entry, depth + 1)
|
||||
));
|
||||
}
|
||||
|
||||
export function configureWorkerDatabase(database: BetterSqlite3.Database) {
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.pragma("foreign_keys = ON");
|
||||
database.pragma("synchronous = FULL");
|
||||
database.pragma("busy_timeout = 5000");
|
||||
database.function("dada_audit_ref_is_safe", { deterministic: true }, (value: unknown) => (
|
||||
typeof value === "string" && auditRefPattern.test(value) ? 1 : 0
|
||||
));
|
||||
database.function("dada_audit_summary_is_safe", { deterministic: true }, (value: unknown) => {
|
||||
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
|
||||
try {
|
||||
return safeSummaryValue(JSON.parse(value), 0) ? 1 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||
database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||
database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||
database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||
}
|
||||
Reference in New Issue
Block a user