feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s

This commit is contained in:
ekklesia
2026-10-07 17:29:32 -05:00
parent a1cdcb3ad1
commit 49b4d4165d
60 changed files with 948 additions and 237 deletions
+2
View File
@@ -13,5 +13,7 @@ export * from "./module-package.js";
export * from "./module-realtime.js";
export * from "./module-runtime.js";
export * from "./module-schema.js";
export * from "./module-schema-metadata.js";
export * from "./module-schema-names.js";
export * from "./module-storage.js";
export * from "./versions.js";
+11 -2
View File
@@ -1,4 +1,5 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
/**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
@@ -45,6 +46,14 @@ export type ModuleDatabase = Pick<
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
* `database` was added.
*/
export interface ModuleRuntimeServices {
readonly database: ModuleDatabase;
export interface ModuleRuntimeServices<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly database: Database;
}
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
export type AnyModuleRuntimeServices =
| LegacyModuleRuntimeServices
| ModuleDatabaseAbiRuntimeServices;
+23 -13
View File
@@ -2,7 +2,8 @@ import { z } from "zod";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { EkklesiaPrincipal } from "./identity.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
import type { ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
@@ -271,7 +272,9 @@ export function moduleRoutesOverlap(
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
* handler — and therefore any parameter validation it performs — can run.
*/
export interface ModuleRouteHandlerContext {
export interface ModuleRouteHandlerContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
@@ -279,7 +282,7 @@ export interface ModuleRouteHandlerContext {
* object, not a re-resolved one (ADR 0030). This is what lets a module stream an object straight
* into a `Response` without the browser ever learning that object storage exists. */
readonly storage: ModuleStorage;
readonly services: ModuleRuntimeServices;
readonly services: ModuleRuntimeServices<Database>;
/** The identical module-scoped realtime publisher used by lifecycle hooks. */
readonly realtime: ModuleRealtimePublisher;
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
@@ -293,10 +296,9 @@ export interface ModuleRouteHandlerContext {
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
* query string remains available through `Request.url`; there is no competing query or body
* abstraction. */
export type ModuleRouteHandler = (
request: Request,
context: ModuleRouteHandlerContext,
) => Awaitable<Response>;
export type ModuleRouteHandler<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> = (request: Request, context: ModuleRouteHandlerContext<Database>) => Awaitable<Response>;
/**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
@@ -311,7 +313,9 @@ export type ModuleRouteHandler = (
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
* metadata, another module's namespace, or an unprotected/no-permission route.
*/
export interface ModuleRouteDefinitionInput {
export interface ModuleRouteDefinitionInput<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly method: ModuleHttpMethod;
readonly path: string;
readonly permission: string;
@@ -322,12 +326,14 @@ export interface ModuleRouteDefinitionInput {
* existed meant, so adding this field changed no existing route's behaviour.
*/
readonly bodyMode?: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler;
readonly handler: ModuleRouteHandler<Database>;
}
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
* only after every field passes its own grammar. */
export interface ModuleRouteDefinition {
export interface ModuleRouteDefinition<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
@@ -335,13 +341,15 @@ export interface ModuleRouteDefinition {
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
* to re-apply the default and none of them can disagree about it. */
readonly bodyMode: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler;
readonly handler: ModuleRouteHandler<Database>;
}
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
export interface ModuleHttpRouteRegistrar {
route(definition: ModuleRouteDefinitionInput): void;
export interface ModuleHttpRouteRegistrar<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
route(definition: ModuleRouteDefinitionInput<Database>): void;
}
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
@@ -355,6 +363,8 @@ export interface RegisteredModuleHttpRoute {
readonly permission: PermissionId;
readonly maxBodyBytes: number;
readonly bodyMode: ModuleRouteBodyMode;
/** Runtime-erased callback. Module authors get the database-mode-specific handler type through
* `ModuleHttpRouteRegistrar`; Core stores the validated callback opaquely in this snapshot. */
readonly handler: ModuleRouteHandler;
}
+17 -9
View File
@@ -68,6 +68,14 @@ const moduleStorageSchema = z
.strict()
.default(() => ({ buckets: [] }));
/** The database contract Core delivers to this module. Omitted means the temporary ADR 0016
* legacy facade; ABI v1 is the stable SQL boundary from ADR 0050. */
export const moduleDatabaseDeclarationSchema = z
.object({
abi: z.literal(1),
})
.strict();
/**
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
* display can never be declared without saying which one of the module's own permissions protects
@@ -122,9 +130,9 @@ function reportDuplicates(
}
/**
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
* so the static browser-contribution declaration is an incompatible shape change and the canonical
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
* The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
* `manifestVersion: 8` without silently reinterpreting version 7.
*
* Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration
@@ -133,14 +141,13 @@ function reportDuplicates(
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have.
*
* Version 6, and every other version, fails validation: there is no compatibility adapter, no
* Version 7, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
* nothing: the new field defaults to empty.
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
*/
export const MODULE_MANIFEST_VERSION = 7;
export const MODULE_MANIFEST_VERSION = 8;
export const moduleManifestSchema = z
.object({
@@ -163,6 +170,7 @@ export const moduleManifestSchema = z
* module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema,
database: moduleDatabaseDeclarationSchema.optional(),
/** Compiled browser renderers this module offers to capabilities **other** modules provide
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
* never learns what a contribution renders. A module that contributes nothing omits the field. */
+53 -16
View File
@@ -1,7 +1,8 @@
import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
@@ -24,12 +25,14 @@ export interface CapabilityRegistrar extends CapabilityResolver {
provide(capabilityId: CapabilityId, implementation: unknown): void;
}
export interface ModuleRegistrationContext {
export interface ModuleRegistrationContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly capabilities: CapabilityRegistrar;
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
* be declared while Core is still building the immutable startup snapshot, never added or removed
* afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar;
readonly http: ModuleHttpRouteRegistrar<Database>;
/** Declarative, registration-only channel publication. Live publishing is available only from
* runtime and request-handler contexts. */
readonly realtime: ModuleRealtimeChannelRegistrar;
@@ -49,10 +52,12 @@ export interface ModuleRegistrationContext {
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
* carries `http`: a route can only ever be declared during `register`. */
export interface ModuleRuntimeContext {
export interface ModuleRuntimeContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly capabilities: CapabilityResolver;
readonly moduleId: ModuleId;
readonly services: ModuleRuntimeServices;
readonly services: ModuleRuntimeServices<Database>;
/**
* This module's own scoped object storage (ADR 0030).
*
@@ -70,8 +75,8 @@ export interface ModuleRuntimeContext {
/**
* This module's own validated configuration, frozen for the lifetime of the process.
*
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance
* every module receives, and configuration is per-module by definition. It sits beside `moduleId`
* Deliberately **not** a field on `ModuleRuntimeServices`: that object holds live platform
* services while configuration is a separately resolved snapshot. It sits beside `moduleId`
* — the context's other per-module fact — so the scoping is visible in the shape rather than
* asserted in a comment.
*
@@ -82,15 +87,28 @@ export interface ModuleRuntimeContext {
readonly config: ModuleConfiguration;
}
export interface ModuleRuntimeDefinition {
interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDatabaseExecutor> {
readonly id: ModuleId;
readonly register?: (context: ModuleRegistrationContext) => Awaitable<void>;
readonly start?: (context: ModuleRuntimeContext) => Awaitable<void>;
readonly stop?: (context: ModuleRuntimeContext) => Awaitable<void>;
readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
readonly stop?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
readonly version: string;
}
const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]);
export interface LegacyModuleRuntimeDefinition extends ModuleRuntimeDefinitionBase<ModuleDatabase> {
readonly database?: never;
}
export interface ModuleDatabaseAbiRuntimeDefinition
extends ModuleRuntimeDefinitionBase<ModuleDatabaseExecutor> {
readonly database: { readonly abi: 1 };
}
export type ModuleRuntimeDefinition =
| LegacyModuleRuntimeDefinition
| ModuleDatabaseAbiRuntimeDefinition;
const runtimeDefinitionKeys = new Set(["database", "id", "register", "start", "stop", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -107,6 +125,14 @@ export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntim
if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) {
return false;
}
if (
value.database !== undefined &&
(!isRecord(value.database) ||
Reflect.ownKeys(value.database).length !== 1 ||
value.database.abi !== 1)
) {
return false;
}
return [value.register, value.start, value.stop].every(
(hook) => hook === undefined || typeof hook === "function",
);
@@ -116,11 +142,22 @@ export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefin
if (!isModuleRuntimeDefinition(value)) {
throw new TypeError("Invalid Ekklesia module runtime definition.");
}
return Object.freeze({
id: value.id,
version: value.version,
const hooks = {
...(value.register ? { register: value.register } : {}),
...(value.start ? { start: value.start } : {}),
...(value.stop ? { stop: value.stop } : {}),
});
};
if (value.database) {
return Object.freeze({
database: Object.freeze({ abi: 1 as const }),
id: value.id,
version: value.version,
...hooks,
}) as ModuleDatabaseAbiRuntimeDefinition;
}
return Object.freeze({
id: value.id,
version: value.version,
...hooks,
}) as LegacyModuleRuntimeDefinition;
}
@@ -0,0 +1,80 @@
import { z } from "zod";
import { moduleIdSchema } from "./identifiers.js";
import { physicalModuleTableNameSchema } from "./module-schema-names.js";
export const moduleSchemaForeignKeyMetadataSchema = z
.object({ table: physicalModuleTableNameSchema })
.strict();
export const moduleSchemaTableMetadataSchema = z
.object({
name: physicalModuleTableNameSchema,
foreignKeys: z.array(moduleSchemaForeignKeyMetadataSchema).default(() => []),
})
.strict();
/** The ABI-v1 schema entrypoint: inert Ekklesia-owned data, with no ORM object or executable
* callback anywhere in its shape. Ownership and foreign-key targets are validated by Core against
* the manifest module id and the complete table list. */
export const moduleSchemaMetadataDefinitionSchema = z
.object({
moduleId: moduleIdSchema,
tables: z.array(moduleSchemaTableMetadataSchema).min(1),
})
.strict();
export interface ModuleSchemaForeignKeyMetadata {
readonly table: string;
}
export interface ModuleSchemaTableMetadata {
readonly name: string;
readonly foreignKeys: readonly ModuleSchemaForeignKeyMetadata[];
}
export interface ModuleSchemaMetadataDefinition {
readonly moduleId: z.output<typeof moduleIdSchema>;
readonly tables: readonly ModuleSchemaTableMetadata[];
}
function isPlainData(value: unknown): boolean {
if (value === null || typeof value === "string") {
return true;
}
if (Array.isArray(value)) {
return value.every(isPlainData);
}
if (typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
(prototype === Object.prototype || prototype === null) &&
Reflect.ownKeys(value).every(
(key) => typeof key === "string" && isPlainData((value as Record<string, unknown>)[key]),
)
);
}
export function parseModuleSchemaMetadataDefinition(
value: unknown,
): ModuleSchemaMetadataDefinition {
if (!isPlainData(value)) {
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
}
const parsed = moduleSchemaMetadataDefinitionSchema.safeParse(value);
if (!parsed.success) {
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
}
return Object.freeze({
moduleId: parsed.data.moduleId,
tables: Object.freeze(
parsed.data.tables.map((table) =>
Object.freeze({
foreignKeys: Object.freeze(
table.foreignKeys.map((foreignKey) => Object.freeze(foreignKey)),
),
name: table.name,
}),
),
),
});
}
@@ -0,0 +1,31 @@
import { z } from "zod";
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
export const moduleTableLocalNameSchema = z
.string()
.min(1)
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
POSTGRES_IDENTIFIER_MAX_LENGTH,
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
);
export function moduleTableNamespace(moduleId: ModuleId): string {
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
}
export function moduleTablePrefix(moduleId: ModuleId): string {
return `${moduleTableNamespace(moduleId)}_`;
}
export function moduleTableName(moduleId: ModuleId, localName: string): string {
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
}
return tableName;
}
+7 -28
View File
@@ -1,19 +1,15 @@
import { is } from "drizzle-orm";
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
export const moduleTableLocalNameSchema = z
.string()
.min(1)
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
export {
moduleTableLocalNameSchema,
moduleTableName,
moduleTableNamespace,
moduleTablePrefix,
POSTGRES_IDENTIFIER_MAX_LENGTH,
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
);
physicalModuleTableNameSchema,
} from "./module-schema-names.js";
export interface ModuleSchemaDefinition<
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
);
}
export function moduleTableNamespace(moduleId: ModuleId): string {
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
}
export function moduleTablePrefix(moduleId: ModuleId): string {
return `${moduleTableNamespace(moduleId)}_`;
}
export function moduleTableName(moduleId: ModuleId, localName: string): string {
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
}
return tableName;
}
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
try {
if (
@@ -16,7 +16,7 @@ import {
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id: "calendar",
name: "Calendar",
version: "0.1.0",
@@ -42,6 +42,24 @@ function validManifest(): Record<string, unknown> {
}
describe("module manifest contract", () => {
it("accepts both manifest-v8 database modes", () => {
expect(parseModuleManifest(validManifest()).database).toBeUndefined();
expect(parseModuleManifest({ ...validManifest(), database: { abi: 1 } }).database).toEqual({
abi: 1,
});
});
it("rejects manifest v7, unsupported ABIs, malformed declarations, and unknown database fields", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 7 }).success).toBe(false);
expect(safeParseModuleManifest({ ...validManifest(), database: { abi: 2 } }).success).toBe(
false,
);
expect(safeParseModuleManifest({ ...validManifest(), database: "abi-v1" }).success).toBe(false);
expect(
safeParseModuleManifest({ ...validManifest(), database: { abi: 1, driver: "drizzle" } })
.success,
).toBe(false);
});
it("normalizes a valid manifest with safe defaults", () => {
const manifest = parseModuleManifest(validManifest());
@@ -232,15 +250,15 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
});
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
it("rejects a version 6 manifest rather than silently interpreting it as version 8", () => {
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(7);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
expect(MODULE_MANIFEST_VERSION).toBe(8);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { parseModuleSchemaMetadataDefinition } from "../src/index.js";
describe("module schema metadata contract", () => {
it("accepts and freezes plain ABI-v1 metadata", () => {
const definition = parseModuleSchemaMetadataDefinition({
moduleId: "calendar",
tables: [
{ name: "calendar_events" },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
],
});
expect(definition).toEqual({
moduleId: "calendar",
tables: [
{ name: "calendar_events", foreignKeys: [] },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
],
});
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
expect(Object.isFrozen(definition.tables[1]?.foreignKeys)).toBe(true);
});
it("strictly rejects malformed, executable, and class-backed metadata", () => {
class Metadata {
moduleId = "calendar";
tables = [{ name: "calendar_events" }];
}
for (const value of [
{ moduleId: "calendar", tables: [] },
{ moduleId: "calendar", tables: [{ name: "calendar_events", unknown: true }] },
{ moduleId: "calendar", tables: [{ name: "calendar_events", foreignKeys: [{}] }] },
{ moduleId: "calendar", tables: [{ name: "calendar_events", inspect: () => undefined }] },
new Metadata(),
]) {
expect(() => parseModuleSchemaMetadataDefinition(value)).toThrow(
"Invalid Ekklesia module schema metadata definition.",
);
}
});
});
+27
View File
@@ -3,7 +3,11 @@ import type {
ModuleDatabaseParameter,
ModuleDatabaseQueryable,
ModuleDatabaseTransaction,
ModuleId,
ModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
import { parseModuleSchemaMetadataDefinition } from "@ekklesia/contracts";
import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core";
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
import type { DrizzleConfig } from "drizzle-orm/utils";
@@ -83,3 +87,26 @@ export function moduleDrizzle<TSchema extends Record<string, unknown> = Record<s
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 });
}
@@ -20,10 +20,12 @@ describe("the module-side database architecture", () => {
});
it("keeps ABI v1 contracts free of ORM imports", async () => {
const source = await text("packages/contracts/src/module-database-abi.ts");
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
expect(source).not.toMatch(/(?:import|export).*kysely/u);
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
for (const file of ["module-database-abi.ts", "module-schema-metadata.ts"]) {
const source = await text(`packages/contracts/src/${file}`);
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
expect(source).not.toMatch(/(?:import|export).*kysely/u);
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
}
});
it("makes Drizzle an optional ranged peer and keeps raw SQL independent from it", async () => {
+24 -2
View File
@@ -8,7 +8,7 @@ import {
type ModuleDatabaseQueryResult,
type ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { and, eq, sql as drizzleSql } from "drizzle-orm";
import { and, sql as drizzleSql, eq } from "drizzle-orm";
import {
bigint,
customType,
@@ -22,7 +22,11 @@ import {
timestamp,
} from "drizzle-orm/pg-core";
import { describe, expect, expectTypeOf, it } from "vitest";
import { moduleDrizzle, type ModuleDrizzleDatabase } from "../src/drizzle.js";
import {
type ModuleDrizzleDatabase,
moduleDrizzle,
moduleDrizzleSchemaMetadata,
} from "../src/drizzle.js";
import { findEkklesiaDatabaseError } from "../src/index.js";
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
@@ -159,6 +163,24 @@ function setup(...outcomes: Outcome[]) {
}
describe("moduleDrizzle query compilation and ABI mapping", () => {
it("derives plain Ekklesia schema metadata with foreign-key targets", () => {
const child = pgTable("calendar_children", {
id: integer("id").primaryKey(),
recordId: integer("record_id").references(() => records.id),
});
const metadata = moduleDrizzleSchemaMetadata("calendar", { records, child });
expect(metadata).toEqual({
moduleId: "calendar",
tables: [
{ name: "calendar_children", foreignKeys: [{ table: "calendar_records" }] },
{ name: "calendar_records", foreignKeys: [] },
],
});
expect(JSON.parse(JSON.stringify(metadata))).toEqual(metadata);
});
it("compiles a select and decodes its positional result on the module side", async () => {
const { db, executor } = setup(result([[7, "Launch"]]));
await expect(
@@ -78,7 +78,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
const tableName = `${id.replaceAll("-", "_")}_records`;
const baseManifest: Record<string, unknown> = {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version,
@@ -1,5 +1,16 @@
import { type ModuleRuntimeDefinition, parseModuleRuntimeDefinition } from "@ekklesia/contracts";
import {
type LegacyModuleRuntimeDefinition,
type ModuleDatabaseAbiRuntimeDefinition,
type ModuleRuntimeDefinition,
parseModuleRuntimeDefinition,
} from "@ekklesia/contracts";
export function defineModuleRuntime<const Definition extends ModuleDatabaseAbiRuntimeDefinition>(
definition: Definition,
): Readonly<Definition>;
export function defineModuleRuntime<const Definition extends LegacyModuleRuntimeDefinition>(
definition: Definition,
): Readonly<Definition>;
export function defineModuleRuntime<const Definition extends ModuleRuntimeDefinition>(
definition: Definition,
): Readonly<Definition> {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import {
EMPTY_MODULE_CONFIGURATION,
defineModuleRuntime,
EMPTY_MODULE_CONFIGURATION,
isModuleHttpMethod,
MODULE_HTTP_METHODS,
type ModuleHttpRouteRegistrar,
@@ -9,6 +9,42 @@ import {
} from "../src/index.js";
describe("defineModuleRuntime", () => {
it("types each database mode without exposing a legacy-or-ABI union", () => {
defineModuleRuntime({
database: { abi: 1 },
id: "example",
version: "0.1.0",
start({ services }) {
expectTypeOf(services.database.query).toBeFunction();
expectTypeOf(services.database.abiVersion).toEqualTypeOf<1>();
// @ts-expect-error ABI-v1 modules never receive the legacy Drizzle facade.
services.database.select();
},
register({ http }) {
http.route({
handler: (_request, { services }) => {
expectTypeOf(services.database.abiVersion).toEqualTypeOf<1>();
return new Response();
},
maxBodyBytes: 0,
method: "GET",
path: "/",
permission: "example.read",
});
},
});
defineModuleRuntime({
id: "legacy",
version: "0.1.0",
start({ services }) {
expectTypeOf(services.database.select).toBeFunction();
// @ts-expect-error Legacy modules do not receive the ABI executor.
services.database.abiVersion;
},
});
});
it("defines a frozen server runtime without duplicating the static manifest", async () => {
const start = vi.fn();
const runtime = defineModuleRuntime({
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id: "example",
name: "Example",
version: "0.1.0",
@@ -1,5 +1,5 @@
{
"manifestVersion": 7,
"manifestVersion": 8,
"id": "example",
"name": "Example",
"version": "0.1.0",