feat: transition module packages to database ABI v1
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s

This commit is contained in:
ekklesia
2026-10-07 18:00:04 -05:00
parent 20ad1ae208
commit 70854d234c
20 changed files with 274 additions and 115 deletions
+14 -3
View File
@@ -12,9 +12,14 @@
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
// in tests as well as the compiled file in the image.
import { pathToFileURL } from "node:url";
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts";
import {
parseModuleRuntimeDefinition,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
interface ProbeRequest {
readonly databaseAbi?: 1;
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
@@ -22,7 +27,7 @@ interface ProbeRequest {
interface ProbeFailure {
readonly role: string;
readonly reason: "export" | "identity" | "load";
readonly reason: "contract" | "export" | "identity" | "load";
}
function defaultExport(namespace: unknown): unknown {
@@ -51,8 +56,14 @@ async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role });
}
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
failures.push({ reason: "contract", role });
}
} else if (role === "schema") {
const definition = parseModuleSchemaDefinition(defaultExport(namespace));
const definition =
request.databaseAbi === 1
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
: parseModuleSchemaDefinition(defaultExport(namespace));
if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role });
}
+32 -18
View File
@@ -45,6 +45,7 @@ import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-disc
export type ModulePackageVerificationErrorCode =
| ModulePackageArchiveErrorCode
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
| "MODULE_PACKAGE_DIGEST_MISMATCH"
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
| "MODULE_PACKAGE_EXTRACTION_FAILED"
@@ -67,6 +68,8 @@ export type ModulePackageVerificationErrorCode =
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
"A Module Database ABI package must not carry a transitional database runtime.",
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
"A backend entrypoint could not be loaded from the package alone.",
@@ -275,9 +278,15 @@ function checkCompatibility(
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
}
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
// implementation constraint pending a Module Database ABI, not that ABI.
if (manifest.database?.abi === 1) {
if (metadata.transitional?.databaseRuntime !== undefined) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
}
return;
}
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
// bundled ORM is a private implementation detail and is never compared with Core's.
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
@@ -314,8 +323,7 @@ function probeEntrypointPath(): string {
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
moduleId: string,
version: string,
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
): Promise<void> {
@@ -332,13 +340,24 @@ async function loadEntrypoints(
}
};
try {
child = fork(probeEntrypointPath(), [JSON.stringify({ entrypoints, moduleId, version })], {
cwd: tmpdir(),
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
child = fork(
probeEntrypointPath(),
[
JSON.stringify({
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
entrypoints,
moduleId: manifest.id,
version: manifest.version,
}),
],
{
cwd: tmpdir(),
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
} catch {
finish("failed");
return;
@@ -437,12 +456,7 @@ export async function verifyModulePackage(
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(
metadata.module.id,
metadata.module.version,
entrypoints,
options.loadTimeoutMs ?? 30_000,
);
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
+52 -1
View File
@@ -3,8 +3,8 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
type ModulePackageVerificationErrorCode,
ModulePackageVerificationError,
type ModulePackageVerificationErrorCode,
verifyModulePackage,
} from "../src/modules/index.js";
import {
@@ -72,6 +72,19 @@ function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] };
}
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
return {
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "example",
tables: [{ name: "example_records", foreignKeys: [] }]
};\n`,
serverSource:
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
...overrides,
};
}
describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile();
@@ -214,6 +227,44 @@ describe("Module Package v1 verification (ADR 0049)", () => {
});
});
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
const { archivePath } = await archiveFile(abiPackage());
await expect(
verifyModulePackage({
archivePath,
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
await rejection(
abiPackage({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
}),
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
);
});
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
await rejection(
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
"MODULE_PACKAGE_MANIFEST_INVALID",
);
const schemaError = await rejection(
abiPackage({
schemaSource:
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
}),
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(schemaError.message).not.toContain("attacker-chosen");
});
it("fails a target this platform cannot run", async () => {
await rejection(
{
@@ -86,6 +86,7 @@ export interface PackageFixtureOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly schemaSource?: string;
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
@@ -125,6 +126,7 @@ export async function modulePackageFixture(
let files: Map<string, Buffer>;
try {
const created = await createMigrationModule(scratch, id, {
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
serverSource:
options.serverSource ??
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,