feat: define module database access
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView) over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and wires it into startApi's fixed startup order ahead of Identity/Authorization construction. Registration stays database-free; a module reaches its database only through an authorized start, stop, or HTTP handler invocation.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
type ModuleRuntimeServices,
|
||||
moduleRouteShapeKey,
|
||||
parseModuleRuntimeDefinition,
|
||||
type RegisteredCapability,
|
||||
@@ -70,7 +71,12 @@ export interface StartedModuleRuntime {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {}
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
}
|
||||
|
||||
interface LoadedModule {
|
||||
readonly definition: ModuleRuntimeDefinition;
|
||||
@@ -201,6 +207,7 @@ class StagedCapabilityRegistry {
|
||||
createRuntimeContext(
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
): ModuleRuntimeContext {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
@@ -208,7 +215,7 @@ class StagedCapabilityRegistry {
|
||||
return this.#registered.get(capabilityId);
|
||||
},
|
||||
});
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id });
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
|
||||
}
|
||||
|
||||
publish(): CapabilityRegistry {
|
||||
@@ -466,11 +473,14 @@ async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const issues: ModuleRuntimeIssue[] = [];
|
||||
for (const module of [...modules].reverse()) {
|
||||
try {
|
||||
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
|
||||
await module.definition.stop?.(
|
||||
registry.createRuntimeContext(module.discovered, phase, services),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
issues.push(error.issue);
|
||||
@@ -492,11 +502,14 @@ async function stopModules(
|
||||
async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
): Promise<void> {
|
||||
const started: LoadedModule[] = [];
|
||||
for (const module of modules) {
|
||||
try {
|
||||
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
|
||||
await module.definition.start?.(
|
||||
registry.createRuntimeContext(module.discovered, "start", services),
|
||||
);
|
||||
started.push(module);
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
@@ -508,7 +521,7 @@ async function startModules(
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
||||
);
|
||||
const rollbackIssues = await stopModules(started, registry, "rollback");
|
||||
const rollbackIssues = await stopModules(started, registry, "rollback", services);
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
}
|
||||
}
|
||||
@@ -524,12 +537,13 @@ async function startModules(
|
||||
*/
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||
const httpRoutes = new StagedModuleHttpRoutes();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||
await startModules(modules, capabilityRegistry);
|
||||
await startModules(modules, capabilityRegistry, services);
|
||||
|
||||
const capabilities = capabilityRegistry.publish();
|
||||
const routes = httpRoutes.publish();
|
||||
@@ -549,7 +563,7 @@ export async function startDiscoveredModuleRuntime(
|
||||
routes,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop");
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
@@ -563,5 +577,5 @@ export async function startModuleRuntime(
|
||||
options: StartModuleRuntimeOptions,
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
return startDiscoveredModuleRuntime(discovered);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createModuleDatabaseView } from "../src/modules/database.js";
|
||||
|
||||
const testTable = pgTable("core_module_database_test", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
});
|
||||
|
||||
function fakePool(): Pool {
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
end: vi.fn(),
|
||||
on: vi.fn(),
|
||||
query: vi.fn(),
|
||||
} as unknown as Pool;
|
||||
}
|
||||
|
||||
describe("createModuleDatabaseView", () => {
|
||||
it("wraps an externally owned pool without constructing, connecting to, registering an error listener on, or closing one itself", () => {
|
||||
const pool = fakePool();
|
||||
|
||||
const database = createModuleDatabaseView(pool);
|
||||
|
||||
expect(database).toBeDefined();
|
||||
expect(vi.mocked(pool.connect)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(pool.end)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(pool.on)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(pool.query)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes exactly the intended query surface, frozen, with no raw pool/client/session/dialect escape hatch", () => {
|
||||
const database = createModuleDatabaseView(fakePool());
|
||||
|
||||
expect(Object.keys(database).sort()).toEqual([
|
||||
"delete",
|
||||
"execute",
|
||||
"insert",
|
||||
"select",
|
||||
"selectDistinct",
|
||||
"selectDistinctOn",
|
||||
"transaction",
|
||||
"update",
|
||||
]);
|
||||
expect(Object.isFrozen(database)).toBe(true);
|
||||
for (const forbidden of ["$client", "_", "query", "session", "dialect", "pool", "with"]) {
|
||||
expect(forbidden in database).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every forwarded operation correctly bound to its underlying Drizzle instance, even when destructured off the facade", async () => {
|
||||
const database = createModuleDatabaseView(fakePool());
|
||||
const {
|
||||
select,
|
||||
selectDistinct,
|
||||
selectDistinctOn,
|
||||
insert,
|
||||
update,
|
||||
delete: remove,
|
||||
execute,
|
||||
transaction,
|
||||
} = database;
|
||||
|
||||
// Each of these synchronously builds a query object from `this.session`/`this.dialect` without
|
||||
// performing I/O — if the forwarded method lost its `this` binding when destructured (a real JS
|
||||
// pitfall for plain prototype methods), the very first property access inside would throw.
|
||||
expect(() => select()).not.toThrow();
|
||||
expect(() => selectDistinct()).not.toThrow();
|
||||
expect(() => selectDistinctOn([testTable.name])).not.toThrow();
|
||||
expect(() => insert(testTable)).not.toThrow();
|
||||
expect(() => update(testTable)).not.toThrow();
|
||||
expect(() => remove(testTable)).not.toThrow();
|
||||
expect(() => execute("select 1")).not.toThrow();
|
||||
|
||||
let transactionResult: unknown;
|
||||
expect(() => {
|
||||
transactionResult = transaction(async () => undefined);
|
||||
}).not.toThrow();
|
||||
expect(transactionResult).toBeInstanceOf(Promise);
|
||||
// The fake pool cannot complete a real transaction; only the synchronous binding matters here.
|
||||
await Promise.resolve(transactionResult).catch(() => undefined);
|
||||
});
|
||||
|
||||
it("never constructs a second pool — the given pool instance is the only one ever touched", () => {
|
||||
const pool = fakePool();
|
||||
|
||||
createModuleDatabaseView(pool);
|
||||
createModuleDatabaseView(pool);
|
||||
|
||||
// Two views built from the same pool never allocate a new connection — there is nothing on
|
||||
// `pool` for either call to have invoked at all (see the construction test above), so calling
|
||||
// this twice is itself the proof that no per-call pool allocation ever happens.
|
||||
expect(vi.mocked(pool.connect)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import {
|
||||
@@ -108,11 +109,50 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
|
||||
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
|
||||
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
|
||||
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
|
||||
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||
const key = `ekklesia.runtime.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const values: unknown[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||
eventSymbols.push(symbol);
|
||||
return {
|
||||
values,
|
||||
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
|
||||
* ever assert on context wiring and object identity, never on actual query behavior. */
|
||||
function fakeModuleDatabase(): ModuleDatabase {
|
||||
return Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: fakeModuleDatabase() });
|
||||
}
|
||||
|
||||
function runtimeOptions(
|
||||
modulesDirectory: string,
|
||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||
) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -822,7 +862,7 @@ describe("already-discovered module runtime", () => {
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||
|
||||
const runtime = await startDiscoveredModuleRuntime(discovered);
|
||||
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
|
||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
||||
await runtime.stop();
|
||||
@@ -840,7 +880,10 @@ describe("already-discovered module runtime", () => {
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
||||
|
||||
const runtime = await startDiscoveredModuleRuntime([...discovered].reverse());
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
[...discovered].reverse(),
|
||||
fakeModuleRuntimeServices(),
|
||||
);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
||||
await runtime.stop();
|
||||
});
|
||||
@@ -857,3 +900,98 @@ describe("already-discovered module runtime", () => {
|
||||
await runtime.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module runtime services", () => {
|
||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const registerServices = createReferenceRecorder();
|
||||
const lifecycleServices = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "wired", {
|
||||
serverSource: `export default {
|
||||
id: "wired", version: "0.1.0",
|
||||
register(context) { ${registerServices.expression}.push(context.services); },
|
||||
start(context) { ${lifecycleServices.expression}.push(context.services); },
|
||||
stop(context) { ${lifecycleServices.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(registerServices.values).toEqual([undefined]);
|
||||
expect(lifecycleServices.values).toHaveLength(2);
|
||||
expect(lifecycleServices.values[0]).toBe(services);
|
||||
expect(lifecycleServices.values[1]).toBe(services);
|
||||
});
|
||||
|
||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "alpha", {
|
||||
serverSource: `export default {
|
||||
id: "alpha", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "beta", {
|
||||
manifest: manifestFor("beta", {
|
||||
dependencies: { modules: { required: { alpha: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "beta", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("gives a rollback stop hook the same services object a start failure left available", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "started", {
|
||||
serverSource: `export default {
|
||||
id: "started", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); },
|
||||
stop(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "failing", {
|
||||
manifest: manifestFor("failing", {
|
||||
dependencies: { modules: { required: { started: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "failing", version: "0.1.0",
|
||||
start() { throw new Error("boom"); }
|
||||
};`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
});
|
||||
|
||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.modules).toEqual([]);
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user