feat: add direct PostgreSQL and ACK deployment support
This commit is contained in:
158
lib/server/database.ts
Normal file
158
lib/server/database.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import "server-only";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Pool, type PoolClient, type PoolConfig, type QueryResult, type QueryResultRow } from "pg";
|
||||
|
||||
export type DataBackend = "local" | "postgres";
|
||||
|
||||
let pool: Pool | undefined;
|
||||
|
||||
export function getDataBackend(): DataBackend {
|
||||
const configured = process.env.ZHINIAN_DATA_BACKEND?.trim().toLowerCase();
|
||||
if (configured === "local" || configured === "postgres") return configured;
|
||||
if (!configured && process.env.NODE_ENV !== "production") return "local";
|
||||
throw new Error("ZHINIAN_DATA_BACKEND must be explicitly set to 'local' or 'postgres'");
|
||||
}
|
||||
|
||||
export function isPostgresBackend(): boolean {
|
||||
return getDataBackend() === "postgres";
|
||||
}
|
||||
|
||||
export async function queryDatabase<T extends QueryResultRow>(
|
||||
text: string,
|
||||
values: readonly unknown[] = []
|
||||
): Promise<QueryResult<T>> {
|
||||
return getPool().query<T>(text, [...values]);
|
||||
}
|
||||
|
||||
export async function withDatabaseTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDatabaseReadiness(): Promise<void> {
|
||||
if (!isPostgresBackend()) return;
|
||||
const { rows } = await queryDatabase<{ ready: boolean }>(`
|
||||
WITH required_table_privileges(table_name, privilege_name) AS (
|
||||
VALUES
|
||||
('assets', 'SELECT'), ('assets', 'INSERT'), ('assets', 'DELETE'),
|
||||
('generation_jobs', 'SELECT'), ('generation_jobs', 'INSERT'), ('generation_jobs', 'UPDATE'), ('generation_jobs', 'DELETE'),
|
||||
('usage_events', 'SELECT'), ('usage_events', 'INSERT'), ('usage_events', 'UPDATE'),
|
||||
('projects', 'SELECT'), ('projects', 'UPDATE'),
|
||||
('image_templates', 'SELECT'), ('image_templates', 'INSERT'), ('image_templates', 'UPDATE'), ('image_templates', 'DELETE'),
|
||||
('platform_organizations', 'SELECT'), ('platform_organizations', 'INSERT'), ('platform_organizations', 'UPDATE'), ('platform_organizations', 'DELETE'),
|
||||
('platform_users', 'SELECT'), ('platform_users', 'INSERT'), ('platform_users', 'UPDATE'), ('platform_users', 'DELETE'),
|
||||
('platform_account_migrations', 'SELECT'), ('platform_account_migrations', 'INSERT'), ('platform_account_migrations', 'UPDATE'),
|
||||
('billing_price_rules', 'SELECT'), ('billing_price_rules', 'INSERT'), ('billing_price_rules', 'UPDATE'),
|
||||
('billing_wallets', 'SELECT'), ('billing_wallets', 'INSERT'), ('billing_wallets', 'UPDATE'),
|
||||
('billing_ledger', 'SELECT'), ('billing_ledger', 'INSERT')
|
||||
)
|
||||
SELECT
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM required_table_privileges
|
||||
WHERE to_regclass('public.' || table_name) IS NULL
|
||||
OR NOT has_table_privilege(current_user, 'public.' || table_name, privilege_name)
|
||||
)
|
||||
AND has_function_privilege(
|
||||
current_user,
|
||||
'public.claim_generation_jobs(text,integer,integer)',
|
||||
'EXECUTE'
|
||||
)
|
||||
AND has_function_privilege(
|
||||
current_user,
|
||||
'public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)',
|
||||
'EXECUTE'
|
||||
) AS ready
|
||||
`);
|
||||
if (!rows[0]?.ready) {
|
||||
throw new Error("PostgreSQL schema or application privileges are not ready");
|
||||
}
|
||||
}
|
||||
|
||||
export function getDatabaseStatus(): { backend: DataBackend; configured: boolean } {
|
||||
const backend = getDataBackend();
|
||||
return { backend, configured: backend === "local" || Boolean(process.env.DATABASE_URL?.trim()) };
|
||||
}
|
||||
|
||||
export async function closeDatabasePool(): Promise<void> {
|
||||
const current = pool;
|
||||
pool = undefined;
|
||||
if (current) await current.end();
|
||||
}
|
||||
|
||||
function getPool(): Pool {
|
||||
if (!isPostgresBackend()) throw new Error("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=local");
|
||||
if (pool) return pool;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL?.trim();
|
||||
if (!connectionString) throw new Error("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres");
|
||||
assertConnectionStringContract(connectionString);
|
||||
const config: PoolConfig = {
|
||||
connectionString,
|
||||
max: positiveInteger("DATABASE_POOL_MAX", 10),
|
||||
idleTimeoutMillis: nonNegativeInteger("DATABASE_IDLE_TIMEOUT_MS", 30_000),
|
||||
connectionTimeoutMillis: positiveInteger("DATABASE_CONNECTION_TIMEOUT_MS", 10_000),
|
||||
statement_timeout: positiveInteger("DATABASE_STATEMENT_TIMEOUT_MS", 30_000),
|
||||
application_name: process.env.DATABASE_APPLICATION_NAME?.trim() || "zhinian-web"
|
||||
};
|
||||
const sslMode = process.env.DATABASE_SSL_MODE?.trim().toLowerCase() || "disable";
|
||||
if (sslMode === "verify-full") {
|
||||
const caPath = process.env.DATABASE_CA_CERT_PATH?.trim();
|
||||
if (!caPath) throw new Error("DATABASE_CA_CERT_PATH is required when DATABASE_SSL_MODE=verify-full");
|
||||
config.ssl = { ca: readFileSync(caPath, "utf8"), rejectUnauthorized: true };
|
||||
} else if (sslMode !== "disable") {
|
||||
throw new Error("DATABASE_SSL_MODE must be 'disable' or 'verify-full'");
|
||||
}
|
||||
pool = new Pool(config);
|
||||
pool.on("error", (error) => console.error("Unexpected PostgreSQL pool error", error));
|
||||
return pool;
|
||||
}
|
||||
|
||||
function assertConnectionStringContract(connectionString: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(connectionString);
|
||||
} catch {
|
||||
throw new Error("DATABASE_URL must be a valid PostgreSQL connection URI");
|
||||
}
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new Error("DATABASE_URL must use the postgres:// or postgresql:// scheme");
|
||||
}
|
||||
const sslParameters = [...parsed.searchParams.keys()].filter((key) => key.toLowerCase().startsWith("ssl"));
|
||||
if (sslParameters.length > 0) {
|
||||
throw new Error(
|
||||
`DATABASE_URL must not contain SSL query parameters (${sslParameters.join(", ")}); use DATABASE_SSL_MODE and DATABASE_CA_CERT_PATH`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function positiveInteger(name: string, fallback: number): number {
|
||||
const value = integer(name, fallback);
|
||||
if (value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(name: string, fallback: number): number {
|
||||
const value = integer(name, fallback);
|
||||
if (value < 0) throw new Error(`${name} must be a non-negative integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`);
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user