Files
Ekklesia/apps/api/src/database.ts
T
ekklesiaandClaude Opus 5.5 aaa7ebfd84
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
fix: keep Drizzle out of every published declaration (ADR 0053)
@ekklesia/contracts still emitted a type-only import of
drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a
standalone module type-checked only because skipLibCheck hid it.

- ModuleDatabase, LegacyModuleRuntimeServices and
  AnyModuleRuntimeServices move to core/src/modules/database.ts, beside
  the facade Core builds; the facade itself is unchanged.
- contracts' runtime, route, and services generics no longer name an
  ORM: the database type is a plain parameter defaulting to the ABI v1
  executor, and the legacy definition's database is opaque until Core
  specializes it. Core and apps/api name the legacy types from Core.
- contracts drops its drizzle-orm optional peer and devDependency;
  Drizzle stays public only through @ekklesia/module-database/drizzle.
- The facade's type-level test moves from contracts to Core.
- Regression proof: the standalone fixture type-checks with
  skipLibCheck: false against the packed tarballs, with no drizzle-orm
  installed (the smoke asserts both). Against the previous contracts
  tarball the same check fails with TS2307 on drizzle-orm/node-postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:40:43 -05:00

91 lines
4.4 KiB
TypeScript

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<void>;
/** 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<void>;
}
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));
},
});
}