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
+4 -3
View File
@@ -17,7 +17,7 @@ import {
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, validRange } from "semver";
import { NoticeError, type NoticeComponent, type NoticeUse, thirdPartyNotices } from "./notices.js";
import { type NoticeComponent, NoticeError, type NoticeUse, thirdPartyNotices } from "./notices.js";
import { BundledPackageIndex, isNativeOrPlatformSpecific } from "./packages.js";
import { deterministicTarGz, TarPathError } from "./tar.js";
@@ -440,7 +440,8 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
components.set(key, { bundled, uses: new Set([use]) });
}
}
if (drizzleVersions.size > 1) {
const usesDatabaseAbi = manifest.database?.abi === 1;
if (!usesDatabaseAbi && drizzleVersions.size > 1) {
throw new ModulePackError("MODULE_PACK_DATABASE_RUNTIME_CONFLICT");
}
const sdk = incorporates.get("@ekklesia/module-sdk");
@@ -531,7 +532,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
incorporates: Object.fromEntries(
[...incorporates].sort(([left], [right]) => compare(left, right)),
),
...(drizzleVersions.size === 1
...(!usesDatabaseAbi && drizzleVersions.size === 1
? { transitional: { databaseRuntime: { "drizzle-orm": [...drizzleVersions][0] } } }
: {}),
files: listed.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })),
+13 -4
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type ModulePackErrorCode, ModulePackError, packModule } from "../src/index.js";
import { ModulePackError, type ModulePackErrorCode, packModule } from "../src/index.js";
import {
cleanupFixtures,
type FixtureModuleOptions,
@@ -181,11 +181,12 @@ describe("packModule: a self-contained backend", () => {
}
});
it("bundles the module-side Drizzle adapter with the module's ORM runtime", async () => {
it("bundles ABI-v1 Drizzle as a module detail without transitional runtime metadata", async () => {
const { archive } = await pack({
databaseAbi: 1,
serverSource: `import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
export const database = (executor) => moduleDrizzle(executor);
export default { id: "example", version: "0.1.0", register() {} };
export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };
`,
withModuleDatabase: true,
});
@@ -194,7 +195,7 @@ export default { id: "example", version: "0.1.0", register() {} };
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
});
expect(metadata.transitional).toEqual({ databaseRuntime: { "drizzle-orm": "0.45.2" } });
expect(metadata.transitional).toBeUndefined();
for (const [file, data] of files) {
if (!file.endsWith(".js")) {
continue;
@@ -213,6 +214,14 @@ export default { id: "example", version: "0.1.0", register() {} };
}
});
it("keeps legacy-v8 Drizzle runtime metadata unchanged", async () => {
const { archive } = await pack({ withSchema: true });
const metadata = JSON.parse(
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "",
);
expect(metadata.transitional).toEqual({ databaseRuntime: { "drizzle-orm": "0.45.2" } });
});
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
await packRejection(
{
@@ -22,6 +22,7 @@ export interface FakePackage {
}
export interface FixtureModuleOptions {
readonly databaseAbi?: 1;
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
@@ -55,19 +56,27 @@ async function write(file: string, contents: string | Uint8Array): Promise<void>
const defaultServer = (
id: string,
version: string,
databaseAbi?: 1,
) => `import { defineModuleRuntime } from "@ekklesia/module-sdk";
export default defineModuleRuntime({ id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} });
export default defineModuleRuntime({ ${databaseAbi === 1 ? "database: { abi: 1 }, " : ""}id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} });
`;
const schemaSource = (
id: string,
) => `import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
const schemaSource = (id: string, databaseAbi?: 1) => `${
databaseAbi === 1
? 'import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";'
: 'import { defineModuleSchema } from "@ekklesia/module-sdk";'
}
import { createModuleTable } from "@ekklesia/module-sdk";
import { text, uuid } from "drizzle-orm/pg-core";
export const records = createModuleTable(${JSON.stringify(id)})("records", {
id: uuid("id").primaryKey(),
value: text("value").notNull(),
});
export default defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } });
export default ${
databaseAbi === 1
? `moduleDrizzleSchemaMetadata(${JSON.stringify(id)}, { records })`
: `defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } })`
};
`;
export async function fixtureModule(options: FixtureModuleOptions = {}): Promise<string> {
@@ -76,6 +85,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
const root = await scratchDirectory("ekklesia-pack-fixture-");
const moduleDirectory = path.join(root, id);
const tableName = `${id.replaceAll("-", "_")}_records`;
const usesModuleDatabase = options.withModuleDatabase || options.databaseAbi === 1;
const baseManifest: Record<string, unknown> = {
manifestVersion: 8,
@@ -83,6 +93,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
name: `${id} module`,
version,
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}),
capabilities: [],
permissions: [],
entrypoints: {
@@ -108,7 +119,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
scripts: { build: "tsc" },
dependencies: {
"@ekklesia/module-sdk": "workspace:*",
...(options.withModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
...(usesModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
"drizzle-orm": "0.45.2",
},
devDependencies: { typescript: "7.0.2" },
@@ -121,12 +132,15 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
);
await write(
path.join(moduleDirectory, "dist/server/index.js"),
options.serverSource ?? defaultServer(id, version),
options.serverSource ?? defaultServer(id, version, options.databaseAbi),
);
await write(path.join(moduleDirectory, "dist/server/index.js.map"), '{"version":3}\n');
await write(path.join(moduleDirectory, "dist/server/index.d.ts"), "export {};\n");
if (options.withSchema) {
await write(path.join(moduleDirectory, "dist/schema/index.js"), schemaSource(id));
await write(
path.join(moduleDirectory, "dist/schema/index.js"),
schemaSource(id, options.databaseAbi),
);
}
await write(path.join(moduleDirectory, "src/server/index.ts"), "export {};\n");
await write(path.join(moduleDirectory, "tests/server.test.ts"), "export {};\n");
@@ -171,7 +185,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
path.join(modules, "@ekklesia", "module-sdk"),
);
if (options.withModuleDatabase) {
if (usesModuleDatabase) {
await symlink(
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")),
path.join(modules, "@ekklesia", "module-database"),
@@ -31,11 +31,14 @@ async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
async function platform() {
async function platform(databaseRuntimeVersion?: string) {
const core = await readJson(path.join(repositoryRoot, "core", "package.json"));
return {
databaseRuntime: {
"drizzle-orm": (core.dependencies as Record<string, string>)["drizzle-orm"] ?? "",
"drizzle-orm":
databaseRuntimeVersion ??
(core.dependencies as Record<string, string>)["drizzle-orm"] ??
"",
},
moduleSdkVersion: String(
(await readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json"))).version,
@@ -96,6 +99,32 @@ describe("the module template as a Module Package v1", () => {
expect(await treeListing(template)).toEqual(templateBefore);
});
it("packages the canonical template as ABI v1 with Drizzle private to the module", async () => {
const { archive, result } = await packTemplate();
const files = fileEntries(archive);
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
expect(manifest.database).toEqual({ abi: 1 });
expect(metadata.transitional).toBeUndefined();
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
});
expect(
[...files.entries()].some(
([file, data]) => file.endsWith(".js") && data.toString("utf8").includes("drizzle-orm"),
),
).toBe(true);
await expect(
verifyModulePackage({
archivePath: result.archivePath,
expectedSha256: result.sha256,
platform: await platform("99.0.0"),
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("verifies against this platform and extracts to an unrelated directory that holds no link", async () => {
const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example");