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
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user