feat: transition module packages and template to database ABI v1 #72

Merged
Zayin_Admin merged 1 commits from feat/module-package-abi-v1-transition into main 2026-10-07 23:14:50 +00:00
20 changed files with 274 additions and 115 deletions
Showing only changes of commit 70854d234c - Show all commits
+3 -1
View File
@@ -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 ([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 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 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 Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`, (`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
+14 -3
View File
@@ -12,9 +12,14 @@
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly // 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. // in tests as well as the compiled file in the image.
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts"; import {
parseModuleRuntimeDefinition,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
interface ProbeRequest { interface ProbeRequest {
readonly databaseAbi?: 1;
readonly moduleId: string; readonly moduleId: string;
readonly version: string; readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>; readonly entrypoints: Readonly<Record<string, string>>;
@@ -22,7 +27,7 @@ interface ProbeRequest {
interface ProbeFailure { interface ProbeFailure {
readonly role: string; readonly role: string;
readonly reason: "export" | "identity" | "load"; readonly reason: "contract" | "export" | "identity" | "load";
} }
function defaultExport(namespace: unknown): unknown { 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) { if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role }); failures.push({ reason: "identity", role });
} }
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
failures.push({ reason: "contract", role });
}
} else if (role === "schema") { } 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) { if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role }); failures.push({ reason: "identity", role });
} }
+27 -13
View File
@@ -45,6 +45,7 @@ import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-disc
export type ModulePackageVerificationErrorCode = export type ModulePackageVerificationErrorCode =
| ModulePackageArchiveErrorCode | ModulePackageArchiveErrorCode
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE" | "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
| "MODULE_PACKAGE_DIGEST_MISMATCH" | "MODULE_PACKAGE_DIGEST_MISMATCH"
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" | "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
| "MODULE_PACKAGE_EXTRACTION_FAILED" | "MODULE_PACKAGE_EXTRACTION_FAILED"
@@ -67,6 +68,8 @@ export type ModulePackageVerificationErrorCode =
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE: MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
"The package's bundled database runtime does not match this platform's (a transitional constraint).", "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_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED: MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
"A backend entrypoint could not be loaded from the package alone.", "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)) { if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE"); fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
} }
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the if (manifest.database?.abi === 1) {
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an if (metadata.transitional?.databaseRuntime !== undefined) {
// implementation constraint pending a Module Database ABI, not that ABI. 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 ?? {})) { for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) { if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"); 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 /** 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. */ * only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints( async function loadEntrypoints(
moduleId: string, manifest: Pick<ModuleManifest, "database" | "id" | "version">,
version: string,
entrypoints: Readonly<Record<string, string>>, entrypoints: Readonly<Record<string, string>>,
timeoutMs: number, timeoutMs: number,
): Promise<void> { ): Promise<void> {
@@ -332,13 +340,24 @@ async function loadEntrypoints(
} }
}; };
try { try {
child = fork(probeEntrypointPath(), [JSON.stringify({ entrypoints, moduleId, version })], { child = fork(
probeEntrypointPath(),
[
JSON.stringify({
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
entrypoints,
moduleId: manifest.id,
version: manifest.version,
}),
],
{
cwd: tmpdir(), cwd: tmpdir(),
env: {}, env: {},
execArgv: [], execArgv: [],
serialization: "json", serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"], stdio: ["ignore", "ignore", "ignore", "ipc"],
}); },
);
} catch { } catch {
finish("failed"); finish("failed");
return; 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 // 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. // after the probe proved it loads on its own.
await loadEntrypoints( await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
metadata.module.id,
metadata.module.version,
entrypoints,
options.loadTimeoutMs ?? 30_000,
);
try { try {
await discoverModuleSchemas(discoveryOptions); await discoverModuleSchemas(discoveryOptions);
} catch (error) { } catch (error) {
+52 -1
View File
@@ -3,8 +3,8 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
type ModulePackageVerificationErrorCode,
ModulePackageVerificationError, ModulePackageVerificationError,
type ModulePackageVerificationErrorCode,
verifyModulePackage, verifyModulePackage,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
import { import {
@@ -72,6 +72,19 @@ function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] }; 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)", () => { describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => { it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile(); 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 () => { it("fails a target this platform cannot run", async () => {
await rejection( await rejection(
{ {
@@ -86,6 +86,7 @@ export interface PackageFixtureOptions {
readonly id?: string; readonly id?: string;
readonly version?: string; readonly version?: string;
readonly serverSource?: string; readonly serverSource?: string;
readonly schemaSource?: string;
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>; readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>; readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => 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>; let files: Map<string, Buffer>;
try { try {
const created = await createMigrationModule(scratch, id, { const created = await createMigrationModule(scratch, id, {
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
serverSource: serverSource:
options.serverSource ?? options.serverSource ??
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`, `export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
+8 -10
View File
@@ -682,13 +682,11 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
and `THIRD_PARTY_NOTICES`, and writes the archive. and `THIRD_PARTY_NOTICES`, and writes the archive.
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links, - **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, 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, runs the platform's own module, schema, and migration validation, and loads the backend once in a
and loads the backend once in a short-lived process with an empty environment. Only then does it short-lived process with an empty environment. Only then does it extract anything.
extract anything. - **What exists today.** Only the `any` target. A manifest-v8 ABI module carries no
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a `transitional.databaseRuntime`; Drizzle may be bundled as its private implementation detail, and
database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md) defines the Module Database ABI Core validates plain schema metadata without comparing ORM versions. A version 8 module that
that removes it for modules that declare that ABI. Manifest version 8 now lets a module declare omits `database` remains on the temporary legacy facade and keeps ADR 0049's exact transitional
`database: { abi: 1 }`; Core gives each such module its own attributed ABI executor and validates Drizzle agreement. The canonical template now demonstrates ABI v1. The legacy facade still
its plain schema metadata. A version 8 module that omits `database` remains on the temporary exists.
legacy facade. Packaging still uses ADR 0049's transitional Drizzle agreement in this slice; the
packager transition and template ABI migration are deliberately next.
+9 -7
View File
@@ -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, - `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 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 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 format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
runtime, a missing entrypoint, a backend that imports something the package lacks, absolute and 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 `..` 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 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, - `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 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 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: 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 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 verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
the package is resolved. Turbo builds the template and Core first, and runs the template's own Node's own resolution hooks proving nothing outside the package is resolved. The package contains
tests (which rebuild its UI in place) before these. 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 ## 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 generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
runs the installed-module lifecycle: runs the installed-module lifecycle:
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049), - 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; - its migration is planned and explicitly applied, and the API restarted;
- a real browser mounts it; - a real browser mounts it;
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and - it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
+16 -18
View File
@@ -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, - **Deterministic.** The same packager version and the same built inputs produce the same archive,
byte for byte, wherever the checkout is. byte for byte, wherever the checkout is.
**Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries **Database ABI v1 is the current path.** A module that declares `"database": { "abi": 1 }` keeps
through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two Drizzle and its exact version inside its own bundle as an implementation detail. Its package omits
copies interoperate only when the copies are interchangeable, so the current implementation requires `transitional.databaseRuntime`; Core validates the module's plain schema metadata and never compares
**exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. The the bundled Drizzle version with its own. The canonical template demonstrates this path with
package records it under `transitional`, and an installation refuses a package whose Drizzle differs. `@ekklesia/module-database/drizzle`.
This is a constraint of today's implementation, not a permanent contract.
`@ekklesia/module-database` now contains the module-side ABI v1 adapters that remove that coupling, A manifest-v8 module that omits `database` remains on the temporary legacy facade. Only that legacy
but deployed modules cannot receive an ABI v1 executor yet. Manifest v8 and per-module runtime package records bundled Drizzle under `transitional.databaseRuntime`, and the verifier continues to
service delivery are separate later work. Until those land, the current transitional package rule require exact agreement with Core. The legacy facade has not been removed.
and database service above remain authoritative; neither Module Package v1 nor the template changed
with the adapter package.
#### Verifying a package #### 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 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 executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production
entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not compiled schema entrypoint and converts those same table objects to plain metadata with
declared twice. `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 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 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 prefixes, duplicates, and same-module foreign-key targets from those strings and never inspects an
ABI module's ORM objects. ABI module's ORM objects.
The canonical module template has moved to manifest version 8 but intentionally omits `database`, The canonical module template declares ABI v1, uses the module-side Drizzle adapter, and exports
so it remains on the legacy facade for one more slice. The packager still emits ADR 0049's plain schema metadata derived with `moduleDrizzleSchemaMetadata`. Its Module Package v1 still
`transitional.databaseRuntime`; the next package-transition change will remove that fact for ABI bundles Drizzle, but carries no `transitional.databaseRuntime`: that version belongs to the module,
packages and migrate the template at the same time. not Core.
## Legacy database access ## Legacy database access
+4 -4
View File
@@ -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 * (ADR 0049). Strict at every level: a reader that finds a fact it does not understand must refuse
* the package rather than ignore it. * the package rather than ignore it.
* *
* `databaseRuntime` records the Drizzle version bundled with the module's backend, because Core runs * For a manifest-v8 module that omits `database`, `databaseRuntime` records the Drizzle version
* a module's queries through its own Drizzle instance over tables the module defined with its own * bundled with the module's backend because Core runs that legacy module's queries through its own
* copy. That is an implementation constraint pending a Module Database ABI decision, **not** a * Drizzle instance over the module's table objects. ABI-v1 packages must omit this fact: their ORM
* database contract a module may rely on. * stays module-side and Core receives only the stable SQL ABI.
*/ */
const modulePackageTransitionalSchema = z.strictObject({ const modulePackageTransitionalSchema = z.strictObject({
databaseRuntime: z databaseRuntime: z
+4 -3
View File
@@ -17,7 +17,7 @@ import {
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build"; import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild"; import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, validRange } from "semver"; 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 { BundledPackageIndex, isNativeOrPlatformSpecific } from "./packages.js";
import { deterministicTarGz, TarPathError } from "./tar.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]) }); 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"); throw new ModulePackError("MODULE_PACK_DATABASE_RUNTIME_CONFLICT");
} }
const sdk = incorporates.get("@ekklesia/module-sdk"); const sdk = incorporates.get("@ekklesia/module-sdk");
@@ -531,7 +532,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
incorporates: Object.fromEntries( incorporates: Object.fromEntries(
[...incorporates].sort(([left], [right]) => compare(left, right)), [...incorporates].sort(([left], [right]) => compare(left, right)),
), ),
...(drizzleVersions.size === 1 ...(!usesDatabaseAbi && drizzleVersions.size === 1
? { transitional: { databaseRuntime: { "drizzle-orm": [...drizzleVersions][0] } } } ? { transitional: { databaseRuntime: { "drizzle-orm": [...drizzleVersions][0] } } }
: {}), : {}),
files: listed.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })), 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 { isBuiltin } from "node:module";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; 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 { import {
cleanupFixtures, cleanupFixtures,
type FixtureModuleOptions, 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({ const { archive } = await pack({
databaseAbi: 1,
serverSource: `import { moduleDrizzle } from "@ekklesia/module-database/drizzle"; serverSource: `import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
export const database = (executor) => moduleDrizzle(executor); 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, withModuleDatabase: true,
}); });
@@ -194,7 +195,7 @@ export default { id: "example", version: "0.1.0", register() {} };
expect(metadata.incorporates).toMatchObject({ expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String), "@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) { for (const [file, data] of files) {
if (!file.endsWith(".js")) { if (!file.endsWith(".js")) {
continue; 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 () => { it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
await packRejection( await packRejection(
{ {
@@ -22,6 +22,7 @@ export interface FakePackage {
} }
export interface FixtureModuleOptions { export interface FixtureModuleOptions {
readonly databaseAbi?: 1;
readonly id?: string; readonly id?: string;
readonly version?: string; readonly version?: string;
readonly serverSource?: string; readonly serverSource?: string;
@@ -55,19 +56,27 @@ async function write(file: string, contents: string | Uint8Array): Promise<void>
const defaultServer = ( const defaultServer = (
id: string, id: string,
version: string, version: string,
databaseAbi?: 1,
) => `import { defineModuleRuntime } from "@ekklesia/module-sdk"; ) => `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 = ( const schemaSource = (id: string, databaseAbi?: 1) => `${
id: string, databaseAbi === 1
) => `import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk"; ? '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"; import { text, uuid } from "drizzle-orm/pg-core";
export const records = createModuleTable(${JSON.stringify(id)})("records", { export const records = createModuleTable(${JSON.stringify(id)})("records", {
id: uuid("id").primaryKey(), id: uuid("id").primaryKey(),
value: text("value").notNull(), 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> { 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 root = await scratchDirectory("ekklesia-pack-fixture-");
const moduleDirectory = path.join(root, id); const moduleDirectory = path.join(root, id);
const tableName = `${id.replaceAll("-", "_")}_records`; const tableName = `${id.replaceAll("-", "_")}_records`;
const usesModuleDatabase = options.withModuleDatabase || options.databaseAbi === 1;
const baseManifest: Record<string, unknown> = { const baseManifest: Record<string, unknown> = {
manifestVersion: 8, manifestVersion: 8,
@@ -83,6 +93,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
name: `${id} module`, name: `${id} module`,
version, version,
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}),
capabilities: [], capabilities: [],
permissions: [], permissions: [],
entrypoints: { entrypoints: {
@@ -108,7 +119,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
scripts: { build: "tsc" }, scripts: { build: "tsc" },
dependencies: { dependencies: {
"@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-sdk": "workspace:*",
...(options.withModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}), ...(usesModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
}, },
devDependencies: { typescript: "7.0.2" }, devDependencies: { typescript: "7.0.2" },
@@ -121,12 +132,15 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
); );
await write( await write(
path.join(moduleDirectory, "dist/server/index.js"), 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.js.map"), '{"version":3}\n');
await write(path.join(moduleDirectory, "dist/server/index.d.ts"), "export {};\n"); await write(path.join(moduleDirectory, "dist/server/index.d.ts"), "export {};\n");
if (options.withSchema) { 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, "src/server/index.ts"), "export {};\n");
await write(path.join(moduleDirectory, "tests/server.test.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")), await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
path.join(modules, "@ekklesia", "module-sdk"), path.join(modules, "@ekklesia", "module-sdk"),
); );
if (options.withModuleDatabase) { if (usesModuleDatabase) {
await symlink( await symlink(
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")), await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")),
path.join(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>; 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")); const core = await readJson(path.join(repositoryRoot, "core", "package.json"));
return { return {
databaseRuntime: { databaseRuntime: {
"drizzle-orm": (core.dependencies as Record<string, string>)["drizzle-orm"] ?? "", "drizzle-orm":
databaseRuntimeVersion ??
(core.dependencies as Record<string, string>)["drizzle-orm"] ??
"",
}, },
moduleSdkVersion: String( moduleSdkVersion: String(
(await readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json"))).version, (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); 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 () => { it("verifies against this platform and extracts to an unrelated directory that holds no link", async () => {
const { result } = await packTemplate(); const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example"); const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example");
+3
View File
@@ -441,6 +441,9 @@ importers:
'@ekklesia/design-tokens': '@ekklesia/design-tokens':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/design-tokens version: link:../../packages/design-tokens
'@ekklesia/module-database':
specifier: workspace:*
version: link:../../packages/module-database
'@ekklesia/module-sdk': '@ekklesia/module-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-sdk version: link:../../packages/module-sdk
+11 -9
View File
@@ -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 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 generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (`GET /records`, declared in `register`) that reads that table through route (`GET /records`, declared in `register`) that reads that table through
`context.services.database` — the platform-provided `ModuleDatabase` facade, never a `@ekklesia/module-database/drizzle` over the platform-provided ABI-v1 executor. Drizzle stays a
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage module-side implementation detail; Core receives SQL and bound values, never the module's ORM
provisioning are intentionally not included. 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` 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 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 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`, 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 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 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` with the module ID. Keep `moduleDrizzleSchemaMetadata` in `src/schema/index.ts` aligned too; it
aligned too, and never import another module's schema. 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 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 --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 `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 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. or descriptor; `context.permissions` remains a presentation-only hint.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, 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 and release them in `stop`. Construct Drizzle only through the module-side ABI adapter; never read
raw `pg` client — `context.services.database` is the only supported way to query this module's own `DATABASE_URL` or reach for a raw `pg` client. `context.services.database` is the only supported
tables, and it is never available during `register`. 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 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 snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
+3
View File
@@ -8,6 +8,9 @@
"platform": "^0.1.0", "platform": "^0.1.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.1.0"
}, },
"database": {
"abi": 1
},
"capabilities": [ "capabilities": [
{ {
"id": "example.records", "id": "example.records",
+1
View File
@@ -29,6 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@ekklesia/design-tokens": "workspace:*", "@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-database": "workspace:*",
"@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-sdk": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*", "@ekklesia/ui": "workspace:*",
+2 -5
View File
@@ -1,9 +1,6 @@
import { defineModuleSchema } from "@ekklesia/module-sdk"; import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
import { records } from "./tables.js"; import { records } from "./tables.js";
export { records } from "./tables.js"; export { records } from "./tables.js";
export default defineModuleSchema({ export default moduleDrizzleSchemaMetadata("example", { records });
moduleId: "example",
tables: { records },
});
+8 -7
View File
@@ -1,3 +1,4 @@
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
import { defineModuleRuntime } from "@ekklesia/module-sdk"; import { defineModuleRuntime } from "@ekklesia/module-sdk";
import { records } from "../schema/tables.js"; import { records } from "../schema/tables.js";
@@ -6,16 +7,17 @@ export const recordsCapability = Object.freeze({
}); });
export default defineModuleRuntime({ export default defineModuleRuntime({
database: { abi: 1 },
id: "example", id: "example",
version: "0.1.0", version: "0.1.0",
register({ capabilities, http }) { register({ capabilities, http }) {
capabilities.provide("example.records", recordsCapability); capabilities.provide("example.records", recordsCapability);
// The accepted module database boundary: `context.services.database` — provided only to an // 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 // authorized handler, never to `register` — is this module's ABI-v1 SQL executor. The adapter
// is a fully typed Drizzle query surface over the module's own table (`../schema/tables.js`), // keeps this module's own Drizzle query builder and table types entirely module-side; Core sees
// never a raw `pg` client, a connection string, or a Drizzle instance this module built // only SQL text and bound values, never Drizzle objects, a raw `pg` client, or a connection
// itself. `example.records.read` is declared in `ekklesia.module.json`, exactly like every // string. `example.records.read` is declared in `ekklesia.module.json`, exactly like every
// other permission a module route enforces. // other permission a module route enforces.
http.route({ http.route({
method: "GET", method: "GET",
@@ -28,9 +30,8 @@ export default defineModuleRuntime({
// (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`). // (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`).
maxBodyBytes: 0, maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
const rows = await context.services.database const database = moduleDrizzle(context.services.database);
.select({ id: records.id, value: records.value }) const rows = await database.select({ id: records.id, value: records.value }).from(records);
.from(records);
// The accepted module configuration boundary: `context.config` is a frozen, already // 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 // 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 // `register`, `start`, `stop`, and every handler as the same object. This module never
+35 -14
View File
@@ -6,7 +6,7 @@ import {
EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
type ModuleConfiguration, type ModuleConfiguration,
type ModuleDatabase, type ModuleDatabaseExecutor,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices, type ModuleRuntimeServices,
@@ -20,19 +20,34 @@ import { describe, expect, it } from "vitest";
import moduleSchema, { records } from "../src/schema/index.js"; import moduleSchema, { records } from "../src/schema/index.js";
import moduleRuntime, { recordsCapability } from "../src/server/index.js"; import moduleRuntime, { recordsCapability } from "../src/server/index.js";
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for this unit test, which only /** A small ABI-v1 executor — no PostgreSQL required for this unit test, which proves the template's
* proves the example handler queries through the facade and shapes its response correctly. */ * module-side Drizzle adapter compiles its query to SQL and shapes the positional result. */
function fakeModuleDatabase(rows: readonly { id: string; value: string }[]): ModuleDatabase { function fakeModuleDatabase(
const selectBuilder = { from: () => Promise.resolve(rows) }; rows: readonly { id: string; value: string }[],
return { select: () => selectBuilder } as unknown as ModuleDatabase; ): 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<{ async function registerAndCaptureRoutes(): Promise<{
provided: Map<CapabilityId, unknown>; provided: Map<CapabilityId, unknown>;
routes: ModuleRouteDefinitionInput[]; routes: ModuleRouteDefinitionInput<ModuleDatabaseExecutor>[];
}> { }> {
const provided = new Map<CapabilityId, unknown>(); const provided = new Map<CapabilityId, unknown>();
const routes: ModuleRouteDefinitionInput[] = []; const routes: ModuleRouteDefinitionInput<ModuleDatabaseExecutor>[] = [];
await moduleRuntime.register?.({ await moduleRuntime.register?.({
// The template declares no configuration, so it receives an empty snapshot — proof that a // 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). // 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); const manifest = parseModuleManifest(manifestInput);
expect(manifest.id).toBe("example"); expect(manifest.id).toBe("example");
expect(manifest.database).toEqual({ abi: 1 });
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]); expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
expect(manifest.permissions).toEqual([ 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 () => { 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(); 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(provided.get("example.records")).toBe(recordsCapability);
expect(routes).toHaveLength(1); expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({ expect(routes[0]).toMatchObject({
@@ -143,10 +163,10 @@ describe("module template", () => {
} }
const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }]; const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }];
const services: ModuleRuntimeServices = Object.freeze({ const services: ModuleRuntimeServices<ModuleDatabaseExecutor> = Object.freeze({
database: fakeModuleDatabase(fakeRows), database: fakeModuleDatabase(fakeRows),
}); });
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext<ModuleDatabaseExecutor> = Object.freeze({
config: EMPTY_MODULE_CONFIGURATION, config: EMPTY_MODULE_CONFIGURATION,
moduleId: "example", moduleId: "example",
params: Object.freeze({}), params: Object.freeze({}),
@@ -191,7 +211,7 @@ describe("module template", () => {
has: (key: string) => key === "greeting", has: (key: string) => key === "greeting",
keys: () => Object.freeze(["greeting"]), keys: () => Object.freeze(["greeting"]),
}); });
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext<ModuleDatabaseExecutor> = Object.freeze({
config, config,
moduleId: "example", moduleId: "example",
params: Object.freeze({}), params: Object.freeze({}),
@@ -228,9 +248,10 @@ describe("module template", () => {
expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false); 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.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 }); expect(getTableConfig(records)).toMatchObject({ name: "example_records", schema: undefined });
}); });
}); });