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:
ekklesia
2026-07-25 20:57:32 -05:00
parent 9c9ede0418
commit 0e308eb9ad
17 changed files with 702 additions and 51 deletions
+96
View File
@@ -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();
});
});
+141 -3
View File
@@ -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();
});
});