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.
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|