Files
Ekklesia/packages/module-database/src/drizzle.ts
T
ekklesiaandClaude Opus 5.5 89d5d0f2da
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s
feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.

- Curated @ekklesia/module-sdk: an explicit author API, no contracts
  barrel, no identity/authorization internals, no Drizzle and no legacy
  ModuleDatabase type. defineModuleRuntime returns the SDK's own named
  interfaces so a module's emitted declarations never reach into
  @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
  defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
  the legacy Drizzle schema validator (moved to core/src/modules) and
  its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
  checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
  Every public package carries "ekklesia.distribution":
  "module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
  and recognizes Ekklesia packages by name plus that marker, so
  registry-installed packages are incorporated, SDK-compatibility
  checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
  and inspects the nine public tarballs; scripts/test-standalone-module.sh
  (pnpm test:standalone-module, new CI job) installs them into a fixture
  module outside the repository and proves it typechecks, builds,
  packages, and verifies.
- Public-package boundary tests and module-development docs.

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

127 lines
4.9 KiB
TypeScript

import type {
ModuleDatabaseExecutor,
ModuleDatabaseParameter,
ModuleDatabaseQueryable,
ModuleDatabaseTransaction,
ModuleId,
ModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
import {
moduleTableName,
moduleTablePrefix,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
import { type AnyPgTable, getTableConfig, pgTableCreator } from "drizzle-orm/pg-core";
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
import type { DrizzleConfig } from "drizzle-orm/utils";
type MaybePromise<T> = Promise<T> | T;
type DrizzleSurface<TSchema extends Record<string, unknown>> = Pick<
PgRemoteDatabase<TSchema>,
"delete" | "insert" | "select" | "update"
>;
export type ModuleDrizzleTransaction<
TSchema extends Record<string, unknown> = Record<string, never>,
> = DrizzleSurface<TSchema> & {
transaction<T>(
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
): Promise<T>;
};
export type ModuleDrizzleDatabase<TSchema extends Record<string, unknown> = Record<string, never>> =
DrizzleSurface<TSchema> & {
transaction<T>(
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
): Promise<T>;
};
type TransactionRunner<TSchema extends Record<string, unknown>> = <T>(
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
) => Promise<T>;
function remoteDatabase<TSchema extends Record<string, unknown>>(
executor: ModuleDatabaseQueryable,
config: DrizzleConfig<TSchema>,
runTransaction: TransactionRunner<TSchema>,
): ModuleDrizzleDatabase<TSchema> {
const database = drizzle<TSchema>(async (text, parameters) => {
const result = await executor.query({
text,
// Drizzle performs column-specific parameter encoding before this callback. Values outside
// ABI v1 remain unchanged so the ABI executor, not the adapter, rejects them.
values: parameters as readonly ModuleDatabaseParameter[],
// Drizzle maps selected fields by position. Array rows also remain correct when a join returns
// duplicate PostgreSQL column names.
rowMode: "array",
});
return { rows: result.rows as unknown as unknown[][] };
}, config);
return Object.freeze({
delete: database.delete.bind(database),
insert: database.insert.bind(database),
select: database.select.bind(database),
transaction: runTransaction,
update: database.update.bind(database),
}) as ModuleDrizzleDatabase<TSchema>;
}
function transactionDatabase<TSchema extends Record<string, unknown>>(
transaction: ModuleDatabaseTransaction,
config: DrizzleConfig<TSchema>,
): ModuleDrizzleTransaction<TSchema> {
return remoteDatabase(transaction, config, (callback) =>
transaction.savepoint((savepoint) => callback(transactionDatabase(savepoint, config))),
);
}
/**
* Creates a module-owned Drizzle database backed exclusively by Module Database ABI v1 calls.
*
* Drizzle compiles and decodes on the module side. Core receives only SQL text, ordered parameter
* values, and `rowMode: "array"`. Top-level transactions use the ABI transaction callback; nested
* Drizzle transactions use ABI savepoints.
*/
export function moduleDrizzle<TSchema extends Record<string, unknown> = Record<string, never>>(
executor: ModuleDatabaseExecutor,
config: DrizzleConfig<TSchema> = {},
): ModuleDrizzleDatabase<TSchema> {
return remoteDatabase(executor, config, (callback) =>
executor.transaction((transaction) => callback(transactionDatabase(transaction, config))),
);
}
/** Derives the plain ABI-v1 schema entrypoint from a module's own Drizzle tables. Drizzle stays on
* this module-side adapter path; Core consumes only the returned strings and arrays. */
export function moduleDrizzleSchemaMetadata(
moduleId: ModuleId,
tables: Readonly<Record<string, AnyPgTable>>,
): ModuleSchemaMetadataDefinition {
const metadata = Object.entries(tables)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([_exportName, table]) => {
const config = getTableConfig(table);
if (config.schema !== undefined && config.schema !== "public") {
throw new TypeError("Module schema metadata supports only the public PostgreSQL schema.");
}
return {
name: config.name,
foreignKeys: config.foreignKeys.map((foreignKey) => ({
table: getTableConfig(foreignKey.reference().foreignTable).name,
})),
};
});
return parseModuleSchemaMetadataDefinition({ moduleId, tables: metadata });
}
/**
* A Drizzle table factory for this module's tables: every table it creates gets the module's
* mandatory namespace (ADR 0007), so `createModuleTable("care-team")("members", …)` is the physical
* table `care_team_members`.
*/
export function createModuleTable(moduleId: ModuleId) {
moduleTablePrefix(moduleId);
return pgTableCreator((localName) => moduleTableName(moduleId, localName));
}