From 70854d234c1428bacc6da1e2138c98d92031ed17 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 7 Oct 2026 18:00:04 -0500 Subject: [PATCH] feat: transition module packages to database ABI v1 --- README.md | 4 +- core/src/modules/module-package-probe.ts | 17 ++++-- .../modules/module-package-verification.ts | 50 ++++++++++------- .../tests/module-package-verification.test.ts | 53 ++++++++++++++++++- core/tests/support/module-package-fixture.ts | 2 + docs/architecture/platform-architecture.md | 18 +++---- docs/development/testing.md | 16 +++--- docs/module-development/README.md | 34 ++++++------ packages/contracts/src/module-package.ts | 8 +-- packages/module-pack/src/pack-module.ts | 7 +-- .../module-pack/tests/pack-module.test.ts | 17 ++++-- .../tests/support/fixture-module.ts | 32 +++++++---- .../tests/template-package.test.ts | 33 +++++++++++- pnpm-lock.yaml | 3 ++ templates/module/README.md | 20 +++---- templates/module/ekklesia.module.json | 3 ++ templates/module/package.json | 1 + templates/module/src/schema/index.ts | 7 +-- templates/module/src/server/index.ts | 15 +++--- templates/module/tests/manifest.test.ts | 49 ++++++++++++----- 20 files changed, 274 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 3789403..dfb8479 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ as one **Ekklesia Module Package v1** ([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists -so far. Purge, native-target packages, signing, and any installer or marketplace do not exist yet. +so far. ABI-v1 packages may bundle a module-owned Drizzle version without coupling it to Core; +legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages, +signing, and any installer or marketplace do not exist yet. Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema (`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`, diff --git a/core/src/modules/module-package-probe.ts b/core/src/modules/module-package-probe.ts index 66e68a3..eb279d1 100644 --- a/core/src/modules/module-package-probe.ts +++ b/core/src/modules/module-package-probe.ts @@ -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>; @@ -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 { 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 }); } diff --git a/core/src/modules/module-package-verification.ts b/core/src/modules/module-package-verification.ts index c1570fa..0a4fcb1 100644 --- a/core/src/modules/module-package-verification.ts +++ b/core/src/modules/module-package-verification.ts @@ -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> = 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, entrypoints: Readonly>, timeoutMs: number, ): Promise { @@ -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) { diff --git a/core/tests/module-package-verification.test.ts b/core/tests/module-package-verification.test.ts index 153cf88..3c7ce81 100644 --- a/core/tests/module-package-verification.test.ts +++ b/core/tests/module-package-verification.test.ts @@ -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( { diff --git a/core/tests/support/module-package-fixture.ts b/core/tests/support/module-package-fixture.ts index bc051b5..a90f471 100644 --- a/core/tests/support/module-package-fixture.ts +++ b/core/tests/support/module-package-fixture.ts @@ -86,6 +86,7 @@ export interface PackageFixtureOptions { readonly id?: string; readonly version?: string; readonly serverSource?: string; + readonly schemaSource?: string; readonly manifest?: (manifest: Record) => Record; readonly runtimePackage?: Record; readonly metadata?: (metadata: Record) => Record; @@ -125,6 +126,7 @@ export async function modulePackageFixture( let files: Map; 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`, diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 81f83a7..6164157 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -682,13 +682,11 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256. and `THIRD_PARTY_NOTICES`, and writes the archive. - **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links, special files, and escaping paths. It checks every digest and identity, the target and runtime, - and the transitional Drizzle agreement, runs the platform's own module and migration validation, - and loads the backend once in a short-lived process with an empty environment. Only then does it - extract anything. -- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a - database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md) defines the Module Database ABI - that removes it for modules that declare that ABI. Manifest version 8 now lets a module declare - `database: { abi: 1 }`; Core gives each such module its own attributed ABI executor and validates - its plain schema metadata. A version 8 module that omits `database` remains on the temporary - legacy facade. Packaging still uses ADR 0049's transitional Drizzle agreement in this slice; the - packager transition and template ABI migration are deliberately next. + runs the platform's own module, schema, and migration validation, and loads the backend once in a + short-lived process with an empty environment. Only then does it extract anything. +- **What exists today.** Only the `any` target. A manifest-v8 ABI module carries no + `transitional.databaseRuntime`; Drizzle may be bundled as its private implementation detail, and + Core validates plain schema metadata without comparing ORM versions. A version 8 module that + omits `database` remains on the temporary legacy facade and keeps ADR 0049's exact transitional + Drizzle agreement. The canonical template now demonstrates ABI v1. The legacy facade still + exists. diff --git a/docs/development/testing.md b/docs/development/testing.md index ffa962a..693d122 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -603,19 +603,21 @@ Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm ve - `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives, including what the packager never produces. A valid package verifies and extracts with normalized modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown - format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or database - runtime, a missing entrypoint, a backend that imports something the package lacks, absolute and + format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy + database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema + metadata, a missing entrypoint, a backend that imports something the package lacks, absolute and `..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second top-level directory, and a duplicate entry each fail with their own sanitized code. The backend - load check runs in a real child process. + load check runs in a real child process and selects the schema contract from the manifest. - `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents, generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native and platform-specific dependencies and of unknown or textless licences, notices, byte-identical archives, normalized tar and gzip headers), and end to end against the built module template: packed twice to identical bytes without changing the workspace's install state, verified by Core's - verifier, extracted elsewhere, and loaded with Node's own resolution hooks proving nothing outside - the package is resolved. Turbo builds the template and Core first, and runs the template's own - tests (which rebuild its UI in place) before these. + verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with + Node's own resolution hooks proving nothing outside the package is resolved. The package contains + module-side Drizzle but no `transitional.databaseRuntime`. Turbo builds the template and Core + first, and runs the template's own tests (which rebuild its UI in place) before these. ## The generic platform image and the reference deployment @@ -635,7 +637,7 @@ These need Docker and build images locally; nothing is pushed or published. generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also runs the installed-module lifecycle: - the module template is packaged by the canonical packager (Module Package v1, ADR 0049), - verified by the image's own verifier, and installed into module storage; + as ABI v1, verified by the image's own verifier, and installed into module storage; - its migration is planned and explicitly applied, and the API restarted; - a real browser mounts it; - it is removed and the API restarted, and Core still runs, migrations still plan and apply, and diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 3bd00d6..17f0e44 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -138,18 +138,15 @@ The archive holds one directory named with your module id: - **Deterministic.** The same packager version and the same built inputs produce the same archive, byte for byte, wherever the checkout is. -**Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries -through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two -copies interoperate only when the copies are interchangeable, so the current implementation requires -**exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. The -package records it under `transitional`, and an installation refuses a package whose Drizzle differs. -This is a constraint of today's implementation, not a permanent contract. +**Database ABI v1 is the current path.** A module that declares `"database": { "abi": 1 }` keeps +Drizzle and its exact version inside its own bundle as an implementation detail. Its package omits +`transitional.databaseRuntime`; Core validates the module's plain schema metadata and never compares +the bundled Drizzle version with its own. The canonical template demonstrates this path with +`@ekklesia/module-database/drizzle`. -`@ekklesia/module-database` now contains the module-side ABI v1 adapters that remove that coupling, -but deployed modules cannot receive an ABI v1 executor yet. Manifest v8 and per-module runtime -service delivery are separate later work. Until those land, the current transitional package rule -and database service above remain authoritative; neither Module Package v1 nor the template changed -with the adapter package. +A manifest-v8 module that omits `database` remains on the temporary legacy facade. Only that legacy +package records bundled Drizzle under `transitional.databaseRuntime`, and the verifier continues to +require exact agreement with Core. The legacy facade has not been removed. #### Verifying a package @@ -1468,9 +1465,10 @@ export default defineConfig({ ``` Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without -executing the ESM Module SDK wrapper. `src/schema/index.ts` remains the production compiled schema -entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not -declared twice. +executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production +compiled schema entrypoint and converts those same table objects to plain metadata with +`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to wrap +them with `defineModuleSchema` while that facade exists. Run the module's `migration:generate` script with a lowercase snake-case name, review the generated SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must @@ -1587,10 +1585,10 @@ The result is `{ moduleId, tables: [{ name, foreignKeys: [{ table }] }] }`. Core prefixes, duplicates, and same-module foreign-key targets from those strings and never inspects an ABI module's ORM objects. -The canonical module template has moved to manifest version 8 but intentionally omits `database`, -so it remains on the legacy facade for one more slice. The packager still emits ADR 0049's -`transitional.databaseRuntime`; the next package-transition change will remove that fact for ABI -packages and migrate the template at the same time. +The canonical module template declares ABI v1, uses the module-side Drizzle adapter, and exports +plain schema metadata derived with `moduleDrizzleSchemaMetadata`. Its Module Package v1 still +bundles Drizzle, but carries no `transitional.databaseRuntime`: that version belongs to the module, +not Core. ## Legacy database access diff --git a/packages/contracts/src/module-package.ts b/packages/contracts/src/module-package.ts index a0b8239..253b2b2 100644 --- a/packages/contracts/src/module-package.ts +++ b/packages/contracts/src/module-package.ts @@ -87,10 +87,10 @@ const ekklesiaPackageNameSchema = z * (ADR 0049). Strict at every level: a reader that finds a fact it does not understand must refuse * the package rather than ignore it. * - * `databaseRuntime` records the Drizzle version bundled with the module's backend, because Core runs - * a module's queries through its own Drizzle instance over tables the module defined with its own - * copy. That is an implementation constraint pending a Module Database ABI decision, **not** a - * database contract a module may rely on. + * For a manifest-v8 module that omits `database`, `databaseRuntime` records the Drizzle version + * bundled with the module's backend because Core runs that legacy module's queries through its own + * Drizzle instance over the module's table objects. ABI-v1 packages must omit this fact: their ORM + * stays module-side and Core receives only the stable SQL ABI. */ const modulePackageTransitionalSchema = z.strictObject({ databaseRuntime: z diff --git a/packages/module-pack/src/pack-module.ts b/packages/module-pack/src/pack-module.ts index 76b26c4..e858ac6 100644 --- a/packages/module-pack/src/pack-module.ts +++ b/packages/module-pack/src/pack-module.ts @@ -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 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 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 })), diff --git a/packages/module-pack/tests/pack-module.test.ts b/packages/module-pack/tests/pack-module.test.ts index a713313..a292f00 100644 --- a/packages/module-pack/tests/pack-module.test.ts +++ b/packages/module-pack/tests/pack-module.test.ts @@ -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( { diff --git a/packages/module-pack/tests/support/fixture-module.ts b/packages/module-pack/tests/support/fixture-module.ts index 68deb84..98b8e3b 100644 --- a/packages/module-pack/tests/support/fixture-module.ts +++ b/packages/module-pack/tests/support/fixture-module.ts @@ -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 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 { @@ -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 = { 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"), diff --git a/packages/module-pack/tests/template-package.test.ts b/packages/module-pack/tests/template-package.test.ts index 1d9ab1a..f5565c9 100644 --- a/packages/module-pack/tests/template-package.test.ts +++ b/packages/module-pack/tests/template-package.test.ts @@ -31,11 +31,14 @@ async function readJson(file: string): Promise> { return JSON.parse(await readFile(file, "utf8")) as Record; } -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)["drizzle-orm"] ?? "", + "drizzle-orm": + databaseRuntimeVersion ?? + (core.dependencies as Record)["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"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 238fbe2..72ce898 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,6 +441,9 @@ importers: '@ekklesia/design-tokens': specifier: workspace:* version: link:../../packages/design-tokens + '@ekklesia/module-database': + specifier: workspace:* + version: link:../../packages/module-database '@ekklesia/module-sdk': specifier: workspace:* version: link:../../packages/module-sdk diff --git a/templates/module/README.md b/templates/module/README.md index 42c3189..cfcd8cb 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -4,9 +4,10 @@ This is the smallest supported Ekklesia module product. It proves the static man compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP route (`GET /records`, declared in `register`) that reads that table through -`context.services.database` — the platform-provided `ModuleDatabase` facade, never a -module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage -provisioning are intentionally not included. +`@ekklesia/module-database/drizzle` over the platform-provided ABI-v1 executor. Drizzle stays a +module-side implementation detail; Core receives SQL and bound values, never the module's ORM +objects, a raw `pg` client, or connection configuration. Migration execution and storage provisioning +are intentionally not included. It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx` and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each @@ -40,10 +41,11 @@ To create a module inside this repository: must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP routes only in `register`, against a permission the manifest already declares; `start`, `stop`, and every route handler — never `register` — receive `context.services.database`, a typed - `ModuleDatabase` view over the platform's one PostgreSQL pool. + Module Database ABI-v1 executor. Adapt it with `moduleDrizzle`; do not pass ORM objects to Core. 6. Replace the pure Drizzle declarations in `src/schema/tables.ts` and keep its table prefix aligned - with the module ID. Keep the `defineModuleSchema` ownership wrapper in `src/schema/index.ts` - aligned too, and never import another module's schema. + with the module ID. Keep `moduleDrizzleSchemaMetadata` in `src/schema/index.ts` aligned too; it + exports plain ownership and foreign-key metadata, never ORM objects. Never import another + module's schema. 7. Update `drizzle.config.ts`, generate forward migrations with `pnpm migration:generate --name=lowercase_name` from the module directory, and review the SQL. Keep each generated tag in `drizzle/meta/_ekklesia.json`, classify it as `none` or `approval-required`, and change that @@ -72,9 +74,9 @@ surfaces and Core. A module UI asks its host for relative API paths and realtime hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page or descriptor; `context.permissions` remains a presentation-only hint. Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, -and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a -raw `pg` client — `context.services.database` is the only supported way to query this module's own -tables, and it is never available during `register`. +and release them in `stop`. Construct Drizzle only through the module-side ABI adapter; never read +`DATABASE_URL` or reach for a raw `pg` client. `context.services.database` is the only supported +executor for this module's own tables, and it is never available during `register`. The migration directory uses the pinned stable Drizzle Kit `0.31.10` PostgreSQL journal and snapshot format. Migrations are forward-only and may modify only this module's objects. Offline diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index b85d8eb..9ca6b02 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -8,6 +8,9 @@ "platform": "^0.1.0", "moduleSdk": "^0.1.0" }, + "database": { + "abi": 1 + }, "capabilities": [ { "id": "example.records", diff --git a/templates/module/package.json b/templates/module/package.json index 2fb5666..5e476ab 100644 --- a/templates/module/package.json +++ b/templates/module/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@ekklesia/design-tokens": "workspace:*", + "@ekklesia/module-database": "workspace:*", "@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/ui": "workspace:*", diff --git a/templates/module/src/schema/index.ts b/templates/module/src/schema/index.ts index 5b42140..02db58d 100644 --- a/templates/module/src/schema/index.ts +++ b/templates/module/src/schema/index.ts @@ -1,9 +1,6 @@ -import { defineModuleSchema } from "@ekklesia/module-sdk"; +import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle"; import { records } from "./tables.js"; export { records } from "./tables.js"; -export default defineModuleSchema({ - moduleId: "example", - tables: { records }, -}); +export default moduleDrizzleSchemaMetadata("example", { records }); diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index 00a38b1..021edd0 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -1,3 +1,4 @@ +import { moduleDrizzle } from "@ekklesia/module-database/drizzle"; import { defineModuleRuntime } from "@ekklesia/module-sdk"; import { records } from "../schema/tables.js"; @@ -6,16 +7,17 @@ export const recordsCapability = Object.freeze({ }); export default defineModuleRuntime({ + database: { abi: 1 }, id: "example", version: "0.1.0", register({ capabilities, http }) { capabilities.provide("example.records", recordsCapability); // The accepted module database boundary: `context.services.database` — provided only to an - // authorized handler, never to `register` — is this module's one way to reach PostgreSQL. It - // is a fully typed Drizzle query surface over the module's own table (`../schema/tables.js`), - // never a raw `pg` client, a connection string, or a Drizzle instance this module built - // itself. `example.records.read` is declared in `ekklesia.module.json`, exactly like every + // authorized handler, never to `register` — is this module's ABI-v1 SQL executor. The adapter + // keeps this module's own Drizzle query builder and table types entirely module-side; Core sees + // only SQL text and bound values, never Drizzle objects, a raw `pg` client, or a connection + // string. `example.records.read` is declared in `ekklesia.module.json`, exactly like every // other permission a module route enforces. http.route({ method: "GET", @@ -28,9 +30,8 @@ export default defineModuleRuntime({ // (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`). maxBodyBytes: 0, async handler(_request, context) { - const rows = await context.services.database - .select({ id: records.id, value: records.value }) - .from(records); + const database = moduleDrizzle(context.services.database); + const rows = await database.select({ id: records.id, value: records.value }).from(records); // The accepted module configuration boundary: `context.config` is a frozen, already // validated snapshot of exactly this module's own declared keys (ADR 0029). It reaches // `register`, `start`, `stop`, and every handler as the same object. This module never diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 0bab191..0ee9298 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -6,7 +6,7 @@ import { EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_STORAGE, type ModuleConfiguration, - type ModuleDatabase, + type ModuleDatabaseExecutor, type ModuleRouteDefinitionInput, type ModuleRouteHandlerContext, type ModuleRuntimeServices, @@ -20,19 +20,34 @@ import { describe, expect, it } from "vitest"; import moduleSchema, { records } from "../src/schema/index.js"; import moduleRuntime, { recordsCapability } from "../src/server/index.js"; -/** A small typed fake `ModuleDatabase` — no PostgreSQL required for this unit test, which only - * proves the example handler queries through the facade and shapes its response correctly. */ -function fakeModuleDatabase(rows: readonly { id: string; value: string }[]): ModuleDatabase { - const selectBuilder = { from: () => Promise.resolve(rows) }; - return { select: () => selectBuilder } as unknown as ModuleDatabase; +/** A small ABI-v1 executor — no PostgreSQL required for this unit test, which proves the template's + * module-side Drizzle adapter compiles its query to SQL and shapes the positional result. */ +function fakeModuleDatabase( + rows: readonly { id: string; value: string }[], +): ModuleDatabaseExecutor { + return Object.freeze({ + abiVersion: 1, + query: async () => ({ + command: "SELECT", + fields: [ + { name: "id", typeOid: 2950 }, + { name: "value", typeOid: 25 }, + ], + rowCount: rows.length, + rows: rows.map((row) => [row.id, row.value]), + }), + transaction: async () => { + throw new Error("The example read does not start a transaction."); + }, + }) as unknown as ModuleDatabaseExecutor; } async function registerAndCaptureRoutes(): Promise<{ provided: Map; - routes: ModuleRouteDefinitionInput[]; + routes: ModuleRouteDefinitionInput[]; }> { const provided = new Map(); - const routes: ModuleRouteDefinitionInput[] = []; + const routes: ModuleRouteDefinitionInput[] = []; await moduleRuntime.register?.({ // The template declares no configuration, so it receives an empty snapshot — proof that a // backend module needing no settings stays exactly as simple as it was (ADR 0029). @@ -65,6 +80,7 @@ describe("module template", () => { const manifest = parseModuleManifest(manifestInput); expect(manifest.id).toBe("example"); + expect(manifest.database).toEqual({ abi: 1 }); expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]); expect(manifest.permissions).toEqual([ { @@ -122,7 +138,11 @@ describe("module template", () => { it("exports the typed runtime definition used by the compiled server entrypoint, registering its one example route database-free", async () => { const { provided, routes } = await registerAndCaptureRoutes(); - expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" }); + expect(moduleRuntime).toMatchObject({ + database: { abi: 1 }, + id: "example", + version: "0.1.0", + }); expect(provided.get("example.records")).toBe(recordsCapability); expect(routes).toHaveLength(1); expect(routes[0]).toMatchObject({ @@ -143,10 +163,10 @@ describe("module template", () => { } const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }]; - const services: ModuleRuntimeServices = Object.freeze({ + const services: ModuleRuntimeServices = Object.freeze({ database: fakeModuleDatabase(fakeRows), }); - const context: ModuleRouteHandlerContext = Object.freeze({ + const context: ModuleRouteHandlerContext = Object.freeze({ config: EMPTY_MODULE_CONFIGURATION, moduleId: "example", params: Object.freeze({}), @@ -191,7 +211,7 @@ describe("module template", () => { has: (key: string) => key === "greeting", keys: () => Object.freeze(["greeting"]), }); - const context: ModuleRouteHandlerContext = Object.freeze({ + const context: ModuleRouteHandlerContext = Object.freeze({ config, moduleId: "example", params: Object.freeze({}), @@ -228,9 +248,10 @@ describe("module template", () => { expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false); }); - it("exports a namespaced module-owned Drizzle schema", () => { + it("exports plain schema metadata derived from its namespaced module-owned Drizzle schema", () => { expect(moduleSchema.moduleId).toBe("example"); - expect(moduleSchema.tables.records).toBe(records); + expect(moduleSchema.tables).toEqual([{ name: "example_records", foreignKeys: [] }]); + expect(JSON.parse(JSON.stringify(moduleSchema))).toEqual(moduleSchema); expect(getTableConfig(records)).toMatchObject({ name: "example_records", schema: undefined }); }); }); -- 2.54.0