import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; import { type AuthorizationDrizzleDatabase, createAuthorizationDrizzleView, } from "@ekklesia/core/authorization"; import { createDashboardDrizzleView, type DashboardDrizzleDatabase, } from "@ekklesia/core/dashboard"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { createModuleConfigurationView, createModuleDatabaseExecutor, createModuleDatabaseView, createModuleLifecycleView, type ModuleConfigurationDrizzleDatabase, type ModuleDatabase, type ModuleLifecycleDrizzleDatabase, } from "@ekklesia/core/modules"; export interface ApiDatabaseHandle { readonly authorization: AuthorizationDrizzleDatabase; readonly devices: DeviceDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase; readonly identity: IdentityDrizzleDatabase; readonly module: ModuleDatabase; /** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */ readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor; readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; /** * One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1` * runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL * cancels a stalled statement itself rather than the caller merely abandoning it; the pool's * session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements * travel as one simple-protocol message, so the check costs a single round trip. On any failure * the pool discards that connection instead of returning it, so a transaction left open by a * cancelled statement never reaches another caller. Rejects with whatever the driver raised; no * caller may surface it. */ readonly ping: (statementTimeoutMs: number) => Promise; /** Closes the one shared pool exactly once, regardless of how many typed views were created * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */ readonly close: () => Promise; } function pingStatement(statementTimeoutMs: number): string { // Interpolated rather than bound: neither SET nor a multi-statement simple query accepts // parameters. Only a positive safe integer can ever reach the SQL text. if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) { throw new RangeError("The ping statement timeout must be a positive integer."); } return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`; } /** * Composes the one physical PostgreSQL pool this process uses for Core's identity and * authorization schemas plus the module database facade: one validated, timeout-bounded * `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent * typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`, * `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`, * `createModuleDatabaseView`). No domain * owns or closes the pool in this composition — `close()` * here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/ * `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable * for callers that only need one domain. */ export function createApiDatabase( databaseUrl: string, options: PostgresPoolOptions = {}, ): ApiDatabaseHandle { const { close, pool } = createValidatedPostgresPool(databaseUrl, options); return Object.freeze({ authorization: createAuthorizationDrizzleView(pool), close, devices: createDeviceDrizzleView(pool), dashboard: createDashboardDrizzleView(pool), identity: createIdentityDrizzleView(pool), module: createModuleDatabaseView(pool), moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }), moduleConfiguration: createModuleConfigurationView(pool), moduleLifecycle: createModuleLifecycleView(pool), ping: async (statementTimeoutMs: number) => { await pool.query(pingStatement(statementTimeoutMs)); }, }); }