feat: add direct PostgreSQL and ACK deployment support

This commit is contained in:
2026-08-12 19:12:00 +08:00
parent d0192081c7
commit c44274f098
55 changed files with 3317 additions and 1064 deletions

View File

@@ -1,7 +1,9 @@
import "server-only";
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import type { AppState, Asset, BillingParameterSnapshot, BillingPriceSource, BillingQuantitySource, BillingRuleConditions, BillingSelectedParameterTier, GenerationCapability, GenerationJob, GenerationStatus, ImageTemplate, Project, UsageContext, UsageEvent, UsageSource } from "@/lib/types";
import { isPostgresBackend, queryDatabase, withDatabaseTransaction } from "@/lib/server/database";
import { createId } from "@/lib/server/ids";
import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime";
@@ -36,37 +38,27 @@ export type ClaimGenerationJobsInput = {
};
export async function listAssets(ownerId = DEFAULT_OWNER_ID): Promise<Asset[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("assets")
.select("*")
.eq("owner_id", ownerId)
.order("created_at", { ascending: false });
if (error) throw new Error(error.message);
return (data || []).map(assetFromRow);
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM assets WHERE owner_id = $1 ORDER BY created_at DESC", [ownerId]);
return rows.map(assetFromRow);
}
const state = await readState();
return state.assets.filter((asset) => asset.ownerId === ownerId).sort(sortNewest);
}
export async function getAsset(id: string): Promise<Asset | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("assets").select("*").eq("id", id).maybeSingle();
if (error) throw new Error(error.message);
return data ? assetFromRow(data) : null;
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM assets WHERE id = $1 LIMIT 1", [id]);
return rows[0] ? assetFromRow(rows[0]) : null;
}
const state = await readState();
return state.assets.find((asset) => asset.id === id) || null;
}
export async function getAssetByStoragePath(storagePath: string): Promise<Asset | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("assets").select("*").eq("storage_path", storagePath).maybeSingle();
if (error) throw new Error(error.message);
return data ? assetFromRow(data) : null;
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM assets WHERE storage_path = $1 LIMIT 1", [storagePath]);
return rows[0] ? assetFromRow(rows[0]) : null;
}
const state = await readState();
return state.assets.find((asset) => asset.storagePath === storagePath) || null;
@@ -83,11 +75,8 @@ export async function createAsset(input: AssetInput): Promise<Asset> {
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("assets").insert(assetToRow(asset)).select("*").single();
if (error) throw new Error(error.message);
return assetFromRow(data);
if (isPostgresBackend()) {
return assetFromRow(await insertRow("assets", assetToRow(asset)));
}
return mutateLocalState((state) => {
state.assets.unshift(asset);
@@ -98,10 +87,8 @@ export async function createAsset(input: AssetInput): Promise<Asset> {
export async function deleteAsset(id: string): Promise<Asset | null> {
const existing = await getAsset(id);
if (!existing) return null;
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("assets").delete().eq("id", id);
if (error) throw new Error(error.message);
if (isPostgresBackend()) {
await queryDatabase("DELETE FROM assets WHERE id = $1", [id]);
return existing;
}
return mutateLocalState((state) => {
@@ -122,21 +109,16 @@ export async function listGenerationJobs(ownerId = DEFAULT_OWNER_ID, limit = 200
export async function listGenerationJobsFiltered(filters: GenerationJobListFilters = {}): Promise<GenerationJob[]> {
const ownerId = filters.ownerId || DEFAULT_OWNER_ID;
const limit = filters.limit || 200;
const supabase = getSupabaseAdmin();
if (supabase) {
let query = supabase
.from("generation_jobs")
.select("*")
.eq("owner_id", ownerId)
.order("created_at", { ascending: false })
.limit(limit);
if (filters.externalClientId) query = query.eq("external_client_id", filters.externalClientId);
if (filters.status) query = query.eq("status", filters.status);
if (filters.capability) query = query.eq("capability", filters.capability);
if (filters.before) query = query.lt("created_at", filters.before);
const { data, error } = await query;
if (error) throw new Error(error.message);
return (data || []).map(jobFromRow);
if (isPostgresBackend()) {
const clauses = ["owner_id = $1"];
const values: unknown[] = [ownerId];
addFilter(clauses, values, "external_client_id", filters.externalClientId);
addFilter(clauses, values, "status", filters.status);
addFilter(clauses, values, "capability", filters.capability);
if (filters.before) { values.push(filters.before); clauses.push(`created_at < $${values.length}`); }
values.push(limit);
const { rows } = await queryDatabase(`SELECT * FROM generation_jobs WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC LIMIT $${values.length}`, values);
return rows.map(jobFromRow);
}
const state = await readState();
return state.generationJobs
@@ -150,11 +132,9 @@ export async function listGenerationJobsFiltered(filters: GenerationJobListFilte
}
export async function getGenerationJob(id: string): Promise<GenerationJob | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("generation_jobs").select("*").eq("id", id).maybeSingle();
if (error) throw new Error(error.message);
return data ? jobFromRow(data) : null;
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM generation_jobs WHERE id = $1 LIMIT 1", [id]);
return rows[0] ? jobFromRow(rows[0]) : null;
}
const state = await readState();
return state.generationJobs.find((job) => job.id === id) || null;
@@ -178,11 +158,8 @@ export async function createGenerationJob(input: JobInput): Promise<GenerationJo
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("generation_jobs").insert(jobToRow(job)).select("*").single();
if (error) throw new Error(error.message);
return jobFromRow(data);
if (isPostgresBackend()) {
return jobFromRow(await insertRow("generation_jobs", jobToRow(job)));
}
return mutateLocalState((state) => {
state.generationJobs.unshift(job);
@@ -195,17 +172,9 @@ export async function findGenerationJobByIdempotency(
idempotencyKey: string,
ownerId = DEFAULT_OWNER_ID
): Promise<GenerationJob | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("generation_jobs")
.select("*")
.eq("owner_id", ownerId)
.eq("external_client_id", externalClientId)
.eq("idempotency_key", idempotencyKey)
.maybeSingle();
if (error) throw new Error(error.message);
return data ? jobFromRow(data) : null;
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM generation_jobs WHERE owner_id = $1 AND external_client_id = $2 AND idempotency_key = $3 LIMIT 1", [ownerId, externalClientId, idempotencyKey]);
return rows[0] ? jobFromRow(rows[0]) : null;
}
const state = await readState();
return state.generationJobs.find((job) => (
@@ -218,15 +187,9 @@ export async function findGenerationJobByIdempotency(
export async function claimGenerationJobs(input: ClaimGenerationJobsInput): Promise<GenerationJob[]> {
const limit = Math.max(1, Math.min(input.limit || 1, 20));
const lockTimeoutMs = input.lockTimeoutMs ?? 5 * 60 * 1000;
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.rpc("claim_generation_jobs", {
p_worker_id: input.workerId,
p_limit: limit,
p_lock_timeout_seconds: Math.ceil(lockTimeoutMs / 1000)
});
if (error) throw new Error(`claim_generation_jobs failed: ${error.message}`);
return (Array.isArray(data) ? data : []).map(jobFromRow);
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM claim_generation_jobs($1, $2, $3)", [input.workerId, limit, Math.ceil(lockTimeoutMs / 1000)]);
return rows.map(jobFromRow);
}
return mutateLocalState((state) => {
@@ -253,21 +216,11 @@ export async function clearGenerationJobLock(
options: { clearProviderTaskId?: boolean } = {}
): Promise<GenerationJob> {
const updatedAt = new Date().toISOString();
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("generation_jobs")
.update({
...jobToRow({ ...patch, updatedAt } as GenerationJob),
locked_at: null,
locked_by: null,
...(options.clearProviderTaskId ? { provider_task_id: null } : {})
})
.eq("id", id)
.select("*")
.single();
if (error) throw new Error(error.message);
return jobFromRow(data);
if (isPostgresBackend()) {
return jobFromRow(await updateRow("generation_jobs", id, {
...jobToRow({ ...patch, updatedAt } as GenerationJob), locked_at: null, locked_by: null,
...(options.clearProviderTaskId ? { provider_task_id: null } : {})
}));
}
return mutateLocalState((state) => {
const index = state.generationJobs.findIndex((job) => job.id === id);
@@ -286,16 +239,8 @@ export async function clearGenerationJobLock(
export async function updateGenerationJob(id: string, patch: Partial<GenerationJob>): Promise<GenerationJob> {
const updatedAt = new Date().toISOString();
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("generation_jobs")
.update(jobToRow({ ...patch, updatedAt } as GenerationJob))
.eq("id", id)
.select("*")
.single();
if (error) throw new Error(error.message);
return jobFromRow(data);
if (isPostgresBackend()) {
return jobFromRow(await updateRow("generation_jobs", id, jobToRow({ ...patch, updatedAt } as GenerationJob)));
}
return mutateLocalState((state) => {
const index = state.generationJobs.findIndex((job) => job.id === id);
@@ -308,10 +253,8 @@ export async function updateGenerationJob(id: string, patch: Partial<GenerationJ
export async function deleteGenerationJob(id: string): Promise<GenerationJob | null> {
const existing = await getGenerationJob(id);
if (!existing) return null;
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("generation_jobs").delete().eq("id", id);
if (error) throw new Error(error.message);
if (isPostgresBackend()) {
await queryDatabase("DELETE FROM generation_jobs WHERE id = $1", [id]);
return existing;
}
return mutateLocalState((state) => {
@@ -327,17 +270,18 @@ export async function recordUsageEvent(input: UsageInput): Promise<UsageEvent> {
ownerId: input.ownerId || DEFAULT_OWNER_ID,
createdAt: input.createdAt || new Date().toISOString()
};
const supabase = getSupabaseAdmin();
if (supabase) {
const existing = await findSupabaseUsageEventByJobId(supabase, usage.jobId);
if (isPostgresBackend()) {
const existing = await findDatabaseUsageEventByJobId(usage.jobId);
if (existing) return existing;
const { data, error } = await supabase.from("usage_events").insert(usageToRow(usage)).select("*").single();
if (error?.code === "23505") {
const raced = await findSupabaseUsageEventByJobId(supabase, usage.jobId);
if (raced) return raced;
try {
return usageFromRow(await insertRow("usage_events", usageToRow(usage)));
} catch (error) {
if (isUniqueViolation(error)) {
const raced = await findDatabaseUsageEventByJobId(usage.jobId);
if (raced) return raced;
}
throw error;
}
if (error) throw new Error(error.message);
return usageFromRow(data);
}
return mutateLocalState((state) => {
const existing = state.usageEvents.find((event) => event.jobId === usage.jobId);
@@ -369,26 +313,15 @@ export async function recordUsageForJob(job: GenerationJob): Promise<UsageEvent
}
export async function listUsageEvents(filters: UsageEventListFilters = {}): Promise<UsageEvent[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
const rows: Record<string, unknown>[] = [];
const pageSize = 1000;
for (let offset = 0; ; offset += pageSize) {
let query = supabase
.from("usage_events")
.select("*")
.order("created_at", { ascending: false })
.range(offset, offset + pageSize - 1);
if (filters.ownerId) query = query.eq("owner_id", filters.ownerId);
if (filters.source) query = query.eq("source", filters.source);
if (filters.from) query = query.gte("created_at", filters.from);
if (filters.to) query = query.lt("created_at", filters.to);
const { data, error } = await query;
if (error) throw new Error(error.message);
const page = (data || []) as Record<string, unknown>[];
rows.push(...page);
if (page.length < pageSize) break;
}
if (isPostgresBackend()) {
const clauses: string[] = [];
const values: unknown[] = [];
addFilter(clauses, values, "owner_id", filters.ownerId);
addFilter(clauses, values, "source", filters.source);
if (filters.from) { values.push(filters.from); clauses.push(`created_at >= $${values.length}`); }
if (filters.to) { values.push(filters.to); clauses.push(`created_at < $${values.length}`); }
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await queryDatabase(`SELECT * FROM usage_events${where} ORDER BY created_at DESC`, values);
return dedupeUsageEvents(rows.map(usageFromRow));
}
@@ -403,21 +336,18 @@ export async function listUsageEvents(filters: UsageEventListFilters = {}): Prom
}
export async function listProjects(ownerId = DEFAULT_OWNER_ID): Promise<Project[]> {
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM projects WHERE owner_id = $1 ORDER BY created_at DESC", [ownerId]);
return rows.map(projectFromRow);
}
const state = await readState();
return state.projects.filter((project) => project.ownerId === ownerId).sort(sortNewest);
}
export async function listImageTemplates(ownerId = DEFAULT_OWNER_ID): Promise<ImageTemplate[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("image_templates")
.select("*")
.eq("owner_id", ownerId)
.order("sort_order", { ascending: true })
.order("updated_at", { ascending: false });
if (error) throw new Error(error.message);
return (data || []).map(imageTemplateFromRow);
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM image_templates WHERE owner_id = $1 ORDER BY sort_order ASC, updated_at DESC", [ownerId]);
return rows.map(imageTemplateFromRow);
}
const state = await readState();
return state.imageTemplates
@@ -426,11 +356,9 @@ export async function listImageTemplates(ownerId = DEFAULT_OWNER_ID): Promise<Im
}
export async function getImageTemplate(id: string): Promise<ImageTemplate | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("image_templates").select("*").eq("id", id).maybeSingle();
if (error) throw new Error(error.message);
return data ? imageTemplateFromRow(data) : null;
if (isPostgresBackend()) {
const { rows } = await queryDatabase("SELECT * FROM image_templates WHERE id = $1 LIMIT 1", [id]);
return rows[0] ? imageTemplateFromRow(rows[0]) : null;
}
const state = await readState();
return state.imageTemplates.find((template) => template.id === id) || null;
@@ -447,11 +375,8 @@ export async function createImageTemplate(input: ImageTemplateInput): Promise<Im
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("image_templates").insert(imageTemplateToRow(template)).select("*").single();
if (error) throw new Error(error.message);
return imageTemplateFromRow(data);
if (isPostgresBackend()) {
return imageTemplateFromRow(await insertRow("image_templates", imageTemplateToRow(template)));
}
return mutateLocalState((state) => {
state.imageTemplates.unshift(template);
@@ -473,17 +398,14 @@ export async function updateImageTemplate(
settings: patch.settings || existing.settings,
updatedAt: new Date().toISOString()
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("image_templates")
.update(imageTemplateToRow(updated))
.eq("id", id)
.eq("owner_id", ownerId)
.select("*")
.maybeSingle();
if (error) throw new Error(error.message);
return data ? imageTemplateFromRow(data) : null;
if (isPostgresBackend()) {
const row = imageTemplateToRow(updated);
const entries = Object.entries(row);
const values = entries.map(([, value]) => value);
values.push(id, ownerId);
const assignments = entries.map(([column], index) => `${column} = $${index + 1}`).join(", ");
const { rows } = await queryDatabase(`UPDATE image_templates SET ${assignments} WHERE id = $${values.length - 1} AND owner_id = $${values.length} RETURNING *`, values);
return rows[0] ? imageTemplateFromRow(rows[0]) : null;
}
return mutateLocalState((state) => {
const index = state.imageTemplates.findIndex((template) => template.id === id && template.ownerId === ownerId);
@@ -497,10 +419,8 @@ export async function updateImageTemplate(
export async function deleteImageTemplate(id: string, ownerId: string): Promise<ImageTemplate | null> {
const existing = await getImageTemplate(id);
if (!existing || existing.ownerId !== ownerId) return null;
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("image_templates").delete().eq("id", id).eq("owner_id", ownerId);
if (error) throw new Error(error.message);
if (isPostgresBackend()) {
await queryDatabase("DELETE FROM image_templates WHERE id = $1 AND owner_id = $2", [id, ownerId]);
return existing;
}
return mutateLocalState((state) => {
@@ -511,12 +431,12 @@ export async function deleteImageTemplate(id: string, ownerId: string): Promise<
export async function reassignOwnerData(fromOwnerId: string, toOwnerId: string): Promise<void> {
if (!fromOwnerId || !toOwnerId || fromOwnerId === toOwnerId) return;
const supabase = getSupabaseAdmin();
if (supabase) {
for (const table of ["assets", "generation_jobs", "projects", "image_templates"] as const) {
const { error } = await supabase.from(table).update({ owner_id: toOwnerId }).eq("owner_id", fromOwnerId);
if (error) throw new Error(error.message);
}
if (isPostgresBackend()) {
await withDatabaseTransaction(async (client) => {
for (const table of ["assets", "generation_jobs", "projects", "image_templates", "usage_events"] as const) {
await client.query(`UPDATE ${table} SET owner_id = $1 WHERE owner_id = $2`, [toOwnerId, fromOwnerId]);
}
});
return;
}
await mutateLocalState((state) => {
@@ -571,15 +491,6 @@ function normalizeState(raw: Partial<AppState>): AppState {
};
}
function getSupabaseAdmin(): SupabaseClient | null {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceRoleKey) return null;
return createClient(url, serviceRoleKey, {
auth: { persistSession: false }
});
}
function sortNewest<T extends { createdAt: string }>(a: T, b: T): number {
return b.createdAt.localeCompare(a.createdAt);
}
@@ -631,8 +542,8 @@ function assetFromRow(row: Record<string, unknown>): Asset {
source: row.source as Asset["source"],
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
metadata: isRecord(row.metadata) ? row.metadata : {},
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
createdAt: requiredTimestamp(row.created_at, "assets.created_at"),
updatedAt: requiredTimestamp(row.updated_at, "assets.updated_at")
};
}
@@ -697,11 +608,11 @@ function jobFromRow(row: Record<string, unknown>): GenerationJob {
priority: optionalNumber(row.priority),
attempts: optionalNumber(row.attempts),
maxAttempts: optionalNumber(row.max_attempts),
scheduledAt: optionalString(row.scheduled_at),
lockedAt: optionalString(row.locked_at),
scheduledAt: optionalTimestamp(row.scheduled_at),
lockedAt: optionalTimestamp(row.locked_at),
lockedBy: optionalString(row.locked_by),
startedAt: optionalString(row.started_at),
completedAt: optionalString(row.completed_at),
startedAt: optionalTimestamp(row.started_at),
completedAt: optionalTimestamp(row.completed_at),
webhookUrl: optionalString(row.webhook_url),
webhookAttempts: optionalNumber(row.webhook_attempts),
webhookLastStatus: isRecord(row.webhook_last_status)
@@ -715,8 +626,8 @@ function jobFromRow(row: Record<string, unknown>): GenerationJob {
: undefined,
usageContext: usageContextFromValue(row.usage_context),
billing: billingJobChargeFromValue(row.billing),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
createdAt: requiredTimestamp(row.created_at, "generation_jobs.created_at"),
updatedAt: requiredTimestamp(row.updated_at, "generation_jobs.updated_at")
};
}
@@ -760,14 +671,13 @@ function usageFromRow(row: Record<string, unknown>): UsageEvent {
estimatedUnit: row.estimated_unit === "video_second" || row.estimated_unit === "image" ? row.estimated_unit : "job",
chargedAmountFen: optionalNumber(row.charged_amount_fen),
currency: row.currency === "CNY" ? "CNY" : undefined,
createdAt: String(row.created_at)
createdAt: requiredTimestamp(row.created_at, "usage_events.created_at")
};
}
async function findSupabaseUsageEventByJobId(supabase: SupabaseClient, jobId: string): Promise<UsageEvent | null> {
const { data, error } = await supabase.from("usage_events").select("*").eq("job_id", jobId).maybeSingle();
if (error) throw new Error(error.message);
return data ? usageFromRow(data as Record<string, unknown>) : null;
async function findDatabaseUsageEventByJobId(jobId: string): Promise<UsageEvent | null> {
const { rows } = await queryDatabase("SELECT * FROM usage_events WHERE job_id = $1 LIMIT 1", [jobId]);
return rows[0] ? usageFromRow(rows[0]) : null;
}
function enrichLegacyUsageEvent(event: UsageEvent, job?: GenerationJob): UsageEvent {
@@ -901,11 +811,61 @@ function imageTemplateFromRow(row: Record<string, unknown>): ImageTemplate {
quality: row.settings.quality === "low" || row.settings.quality === "medium" || row.settings.quality === "high" ? row.settings.quality : undefined
} : {},
sortOrder: optionalNumber(row.sort_order) ?? 0,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
createdAt: requiredTimestamp(row.created_at, "image_templates.created_at"),
updatedAt: requiredTimestamp(row.updated_at, "image_templates.updated_at")
};
}
function projectFromRow(row: Record<string, unknown>): Project {
return {
id: String(row.id),
ownerId: String(row.owner_id),
name: String(row.name || ""),
brief: String(row.brief || ""),
type: isProjectType(row.type) ? row.type : "custom",
assetIds: Array.isArray(row.asset_ids) ? row.asset_ids.map(String) : [],
createdAt: requiredTimestamp(row.created_at, "projects.created_at"),
updatedAt: requiredTimestamp(row.updated_at, "projects.updated_at")
};
}
function isProjectType(value: unknown): value is Project["type"] {
return value === "brand" || value === "store" || value === "commerce" || value === "event" || value === "course" || value === "ip" || value === "custom";
}
async function insertRow(table: string, row: Record<string, unknown>): Promise<Record<string, unknown>> {
const entries = Object.entries(row);
const columns = entries.map(([column]) => column).join(", ");
const placeholders = entries.map((_, index) => `$${index + 1}`).join(", ");
const { rows } = await queryDatabase(`INSERT INTO ${table} (${columns}) VALUES (${placeholders}) RETURNING *`, entries.map(([, value]) => value));
return rows[0];
}
async function updateRow(table: string, id: string, row: Record<string, unknown>): Promise<Record<string, unknown>> {
const entries = Object.entries(row);
if (!entries.length) {
const { rows } = await queryDatabase(`SELECT * FROM ${table} WHERE id = $1`, [id]);
if (!rows[0]) throw new Error(`${table} row not found: ${id}`);
return rows[0];
}
const values = entries.map(([, value]) => value);
values.push(id);
const assignments = entries.map(([column], index) => `${column} = $${index + 1}`).join(", ");
const { rows } = await queryDatabase(`UPDATE ${table} SET ${assignments} WHERE id = $${values.length} RETURNING *`, values);
if (!rows[0]) throw new Error(`${table} row not found: ${id}`);
return rows[0];
}
function addFilter(clauses: string[], values: unknown[], column: string, value: unknown): void {
if (value === undefined || value === null || value === "") return;
values.push(value);
clauses.push(`${column} = $${values.length}`);
}
function isUniqueViolation(error: unknown): boolean {
return isRecord(error) && error.code === "23505";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -916,6 +876,20 @@ function optionalString(value: unknown): string | undefined {
return trimmed || undefined;
}
function requiredTimestamp(value: unknown, field: string): string {
const timestamp = optionalTimestamp(value);
if (!timestamp) throw new Error(`Invalid PostgreSQL timestamp: ${field}`);
return timestamp;
}
function optionalTimestamp(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
if (typeof value !== "string") return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
function optionalNumber(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") return undefined;
const parsed = Number(value);