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
@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]>
91 lines
5.2 KiB
TypeScript
91 lines
5.2 KiB
TypeScript
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
|
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
import type { Pool } from "pg";
|
|
|
|
/**
|
|
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
|
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
|
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
|
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
|
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
|
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
|
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
|
*
|
|
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
|
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
|
* `query` (a relational-query builder tied to one schema, which would have to span every
|
|
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
|
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
|
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
|
* objects to each call, so there is no global relational schema spanning every installed module for
|
|
* this type to leak.
|
|
*
|
|
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
|
|
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
|
|
* the Module SDK never exposes this facade's type.
|
|
*
|
|
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
|
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
|
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
|
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
|
* malicious module, which this facade cannot do and does not attempt to do.
|
|
*/
|
|
export type ModuleDatabase = Pick<
|
|
NodePgDatabase,
|
|
| "select"
|
|
| "selectDistinct"
|
|
| "selectDistinctOn"
|
|
| "insert"
|
|
| "update"
|
|
| "delete"
|
|
| "execute"
|
|
| "transaction"
|
|
>;
|
|
|
|
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
|
|
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
|
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
|
|
export type AnyModuleRuntimeServices =
|
|
| LegacyModuleRuntimeServices
|
|
| ModuleDatabaseAbiRuntimeServices;
|
|
|
|
/**
|
|
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
|
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
|
* `createAuthorizationDrizzleView` (`core/src/authorization/database.ts`): the same "one pool, one
|
|
* typed view per domain" composition, extended to a third domain. Never constructs, registers an
|
|
* error listener on, or closes a pool itself — the caller (`apps/api/src/database.ts`) retains that
|
|
* responsibility exactly once, regardless of how many typed views it creates over the same pool.
|
|
*
|
|
* Carries no schema, unlike the identity and authorization views: a module brings its own Drizzle
|
|
* table objects to each call rather than selecting against a schema this facade would otherwise
|
|
* have to know about, so there is no `TFullSchema` for `drizzle(...)` to accept here. Building this
|
|
* view performs no query and no migration — `drizzle(pool)` only wraps the given client, the same
|
|
* synchronous, I/O-free construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
|
|
* already rely on.
|
|
*
|
|
* Deliberately returns a fresh object with each operation explicitly bound to the underlying
|
|
* Drizzle instance, rather than the `drizzle(pool)` instance itself: Drizzle's own query methods
|
|
* are plain prototype methods that read `this.session`/`this.dialect` internally (not
|
|
* arrow-function instance properties), so handing that instance straight to a module would both
|
|
* break the moment a module destructured a method off it (`const { select } = database` loses
|
|
* `this`) and, more importantly, would leave every non-public property (`$client`, `_`, `query`,
|
|
* `$cache`, `with`) physically reachable on the object at runtime — reachable to a module written
|
|
* in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not just hidden
|
|
* by TypeScript. Building this literal, `Object.freeze`d object closes both gaps: only the eight
|
|
* intended operations exist on it at all, each correctly bound, and nothing else does.
|
|
*/
|
|
export function createModuleDatabaseView(pool: Pool): ModuleDatabase {
|
|
const db = drizzle(pool);
|
|
return Object.freeze({
|
|
delete: db.delete.bind(db),
|
|
execute: db.execute.bind(db),
|
|
insert: db.insert.bind(db),
|
|
select: db.select.bind(db),
|
|
selectDistinct: db.selectDistinct.bind(db),
|
|
selectDistinctOn: db.selectDistinctOn.bind(db),
|
|
transaction: db.transaction.bind(db),
|
|
update: db.update.bind(db),
|
|
});
|
|
}
|