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
+67 -23
View File
@@ -1,5 +1,6 @@
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
type CapabilityId,
type CapabilityRegistrar,
type CapabilityResolver,
@@ -7,12 +8,15 @@ import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar,
type ModuleId,
ModuleRealtimeError,
type ModuleRealtimePublisher,
type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRouteHandler,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
@@ -68,6 +72,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_DATABASE_MISMATCH"
| "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH"
@@ -105,9 +110,8 @@ export interface StartedModuleRuntime {
/**
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
*
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
* shared object identity every module receives** — a property this runtime has a test for — while
* configuration is per-module by definition. A module with no entry receives
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
* frozen per module independently of its live service object. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
* module code never branches on whether the platform supplied one.
*/
@@ -124,12 +128,33 @@ function configurationFor(
* Per-module scoped object storage, keyed by module id (ADR 0030).
*
* A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
* Sharing it would hand every
* module every other module's storage.
*/
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
module: DiscoveredModule,
): AnyModuleRuntimeServices {
const selected = "get" in services ? services.get(module.manifest.id) : services;
if (!selected) {
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
}
const expectsAbi = module.manifest.database?.abi === 1;
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
if (expectsAbi !== isAbi) {
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
}
return selected;
}
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
}
@@ -164,10 +189,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions,
ModuleRuntimeCompositionOptions {
/** 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;
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
readonly services: ModuleRuntimeServicesSource;
}
interface LoadedModule {
@@ -175,6 +199,13 @@ interface LoadedModule {
readonly discovered: DiscoveredModule;
}
type AnyRegistrationHook = (
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
type AnyRuntimeHook = (
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[];
@@ -469,7 +500,7 @@ class StagedModuleHttpRoutes {
local.push(
Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler,
handler: parsed.data.handler as ModuleRouteHandler,
maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method,
moduleId: module.manifest.id,
@@ -555,6 +586,19 @@ async function loadRuntimeDefinitions(
),
);
}
const runtimeDeclaresAbi = definition.database?.abi === 1;
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
matchesManifest = false;
issues.push(
runtimeIssue(
"MODULE_RUNTIME_DATABASE_MISMATCH",
"load",
discovered.manifest.id,
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
),
);
}
if (matchesManifest) {
loaded.push({ definition, discovered });
}
@@ -580,15 +624,15 @@ async function registerModules(
module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
);
const context: ModuleRegistrationContext = Object.freeze({
const context = Object.freeze({
capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http,
moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime,
});
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
try {
await module.definition.register?.(context);
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]);
@@ -685,7 +729,7 @@ async function stopModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -698,7 +742,7 @@ async function stopModules(
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop;
const stop = module.definition.stop as AnyRuntimeHook | undefined;
if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook.
continue;
@@ -710,13 +754,13 @@ async function stopModules(
registry.createRuntimeContext(
module.discovered,
phase,
services,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
),
),
).then(() => undefined),
timeoutMs,
);
if (outcome === "timed-out") {
@@ -750,7 +794,7 @@ async function stopModules(
async function startModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -766,15 +810,15 @@ async function startModules(
for (const module of modules) {
rollbackCandidates.push(module);
try {
await module.definition.start?.(
await (module.definition.start as AnyRuntimeHook | undefined)?.(
registry.createRuntimeContext(
module.discovered,
"start",
services,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
);
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) {
@@ -831,7 +875,7 @@ async function startModules(
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
timeouts: ModuleRuntimeCompositionOptions = {},
): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules);
+129 -11
View File
@@ -2,9 +2,11 @@ import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaDefinition,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import { is } from "drizzle-orm";
@@ -40,7 +42,9 @@ export interface DiscoveredModuleTable {
readonly exportName: string;
readonly moduleId: ModuleId;
readonly name: string;
readonly table: AnyPgTable;
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
* object and contributes nothing to the legacy aggregate below. */
readonly table?: AnyPgTable;
}
export interface DiscoveredModuleSchema {
@@ -55,21 +59,40 @@ export interface ModuleSchemaAggregate {
readonly tables: readonly DiscoveredModuleTable[];
}
interface LoadedModuleSchema {
interface LoadedLegacyModuleSchema {
readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "legacy";
}
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema {
readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[];
}
interface ModuleTableCandidate extends DiscoveredModuleTable {
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string;
readonly table: AnyPgTable;
}
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error {
@@ -156,9 +179,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
let definition: ModuleSchemaDefinition;
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try {
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
definition =
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch {
issues.push(
issue(
@@ -182,7 +209,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
loaded.push({ definition, discovered });
loaded.push(
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
}
return { issues, loaded };
@@ -197,6 +228,58 @@ function inspectTables(
for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
@@ -293,6 +376,7 @@ function inspectTables(
const candidate: ModuleTableCandidate = {
exportName,
foreignKeys: config.foreignKeys,
mode: "legacy",
moduleId,
name: config.name,
path,
@@ -321,8 +405,8 @@ function inspectTables(
}
function validForeignKeyTarget(
foreignKey: ModuleTableCandidate["foreignKeys"][number],
candidate: ModuleTableCandidate,
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
candidate: LegacyModuleTableCandidate,
): AnyPgTable | undefined {
try {
const reference = foreignKey.reference();
@@ -356,7 +440,9 @@ function inspectForeignKeys(
const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) {
ownerByTable.set(candidate.table, candidate.moduleId);
if (candidate.mode === "legacy") {
ownerByTable.set(candidate.table, candidate.moduleId);
}
}
}
@@ -364,6 +450,32 @@ function inspectForeignKeys(
const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
}
});
continue;
}
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
@@ -446,7 +558,9 @@ function publishAggregate(
moduleId: inspected.discovered.manifest.id,
tables: Object.freeze(
inspected.tables
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
Object.freeze(table),
)
.sort((left, right) => compareText(left.exportName, right.exportName)),
),
}),
@@ -458,7 +572,11 @@ function publishAggregate(
.sort((left, right) => compareText(left.name, right.name)),
);
const drizzleSchema = Object.freeze(
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
Object.fromEntries(
tables.flatMap((table) =>
table.table === undefined ? [] : [[table.name, table.table] as const],
),
),
);
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });