feat: add direct PostgreSQL and ACK deployment support
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user