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
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`,
+14 -3
View File
@@ -12,9 +12,14 @@
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
// in tests as well as the compiled file in the image.
import { pathToFileURL } from "node:url";
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts";
import {
parseModuleRuntimeDefinition,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
interface ProbeRequest {
readonly databaseAbi?: 1;
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
@@ -22,7 +27,7 @@ interface ProbeRequest {
interface ProbeFailure {
readonly role: string;
readonly reason: "export" | "identity" | "load";
readonly reason: "contract" | "export" | "identity" | "load";
}
function defaultExport(namespace: unknown): unknown {
@@ -51,8 +56,14 @@ async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role });
}
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
failures.push({ reason: "contract", role });
}
} else if (role === "schema") {
const definition = parseModuleSchemaDefinition(defaultExport(namespace));
const definition =
request.databaseAbi === 1
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
: parseModuleSchemaDefinition(defaultExport(namespace));
if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role });
}
+32 -18
View File
@@ -45,6 +45,7 @@ import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-disc
export type ModulePackageVerificationErrorCode =
| ModulePackageArchiveErrorCode
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
| "MODULE_PACKAGE_DIGEST_MISMATCH"
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
| "MODULE_PACKAGE_EXTRACTION_FAILED"
@@ -67,6 +68,8 @@ export type ModulePackageVerificationErrorCode =
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
"A Module Database ABI package must not carry a transitional database runtime.",
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
"A backend entrypoint could not be loaded from the package alone.",
@@ -275,9 +278,15 @@ function checkCompatibility(
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
}
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
// implementation constraint pending a Module Database ABI, not that ABI.
if (manifest.database?.abi === 1) {
if (metadata.transitional?.databaseRuntime !== undefined) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
}
return;
}
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
// bundled ORM is a private implementation detail and is never compared with Core's.
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
@@ -314,8 +323,7 @@ function probeEntrypointPath(): string {
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
moduleId: string,
version: string,
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
): Promise<void> {
@@ -332,13 +340,24 @@ async function loadEntrypoints(
}
};
try {
child = fork(probeEntrypointPath(), [JSON.stringify({ entrypoints, moduleId, version })], {
cwd: tmpdir(),
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
child = fork(
probeEntrypointPath(),
[
JSON.stringify({
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
entrypoints,
moduleId: manifest.id,
version: manifest.version,
}),
],
{
cwd: tmpdir(),
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
} catch {
finish("failed");
return;
@@ -437,12 +456,7 @@ export async function verifyModulePackage(
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(
metadata.module.id,
metadata.module.version,
entrypoints,
options.loadTimeoutMs ?? 30_000,
);
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
+52 -1
View File
@@ -3,8 +3,8 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
type ModulePackageVerificationErrorCode,
ModulePackageVerificationError,
type ModulePackageVerificationErrorCode,
verifyModulePackage,
} from "../src/modules/index.js";
import {
@@ -72,6 +72,19 @@ function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] };
}
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
return {
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "example",
tables: [{ name: "example_records", foreignKeys: [] }]
};\n`,
serverSource:
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
...overrides,
};
}
describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile();
@@ -214,6 +227,44 @@ describe("Module Package v1 verification (ADR 0049)", () => {
});
});
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
const { archivePath } = await archiveFile(abiPackage());
await expect(
verifyModulePackage({
archivePath,
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
await rejection(
abiPackage({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
}),
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
);
});
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
await rejection(
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
"MODULE_PACKAGE_MANIFEST_INVALID",
);
const schemaError = await rejection(
abiPackage({
schemaSource:
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
}),
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(schemaError.message).not.toContain("attacker-chosen");
});
it("fails a target this platform cannot run", async () => {
await rejection(
{
@@ -86,6 +86,7 @@ export interface PackageFixtureOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly schemaSource?: string;
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
@@ -125,6 +126,7 @@ export async function modulePackageFixture(
let files: Map<string, Buffer>;
try {
const created = await createMigrationModule(scratch, id, {
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
serverSource:
options.serverSource ??
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
+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.
- **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.
+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,
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
+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,
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
+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
* 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
+4 -3
View File
@@ -17,7 +17,7 @@ import {
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, validRange } from "semver";
import { NoticeError, type NoticeComponent, type NoticeUse, thirdPartyNotices } from "./notices.js";
import { type NoticeComponent, NoticeError, type NoticeUse, thirdPartyNotices } from "./notices.js";
import { BundledPackageIndex, isNativeOrPlatformSpecific } from "./packages.js";
import { deterministicTarGz, TarPathError } from "./tar.js";
@@ -440,7 +440,8 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
components.set(key, { bundled, uses: new Set([use]) });
}
}
if (drizzleVersions.size > 1) {
const usesDatabaseAbi = manifest.database?.abi === 1;
if (!usesDatabaseAbi && drizzleVersions.size > 1) {
throw new ModulePackError("MODULE_PACK_DATABASE_RUNTIME_CONFLICT");
}
const sdk = incorporates.get("@ekklesia/module-sdk");
@@ -531,7 +532,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
incorporates: Object.fromEntries(
[...incorporates].sort(([left], [right]) => compare(left, right)),
),
...(drizzleVersions.size === 1
...(!usesDatabaseAbi && drizzleVersions.size === 1
? { transitional: { databaseRuntime: { "drizzle-orm": [...drizzleVersions][0] } } }
: {}),
files: listed.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })),
+13 -4
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type ModulePackErrorCode, ModulePackError, packModule } from "../src/index.js";
import { ModulePackError, type ModulePackErrorCode, packModule } from "../src/index.js";
import {
cleanupFixtures,
type FixtureModuleOptions,
@@ -181,11 +181,12 @@ describe("packModule: a self-contained backend", () => {
}
});
it("bundles the module-side Drizzle adapter with the module's ORM runtime", async () => {
it("bundles ABI-v1 Drizzle as a module detail without transitional runtime metadata", async () => {
const { archive } = await pack({
databaseAbi: 1,
serverSource: `import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
export const database = (executor) => moduleDrizzle(executor);
export default { id: "example", version: "0.1.0", register() {} };
export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };
`,
withModuleDatabase: true,
});
@@ -194,7 +195,7 @@ export default { id: "example", version: "0.1.0", register() {} };
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
});
expect(metadata.transitional).toEqual({ databaseRuntime: { "drizzle-orm": "0.45.2" } });
expect(metadata.transitional).toBeUndefined();
for (const [file, data] of files) {
if (!file.endsWith(".js")) {
continue;
@@ -213,6 +214,14 @@ export default { id: "example", version: "0.1.0", register() {} };
}
});
it("keeps legacy-v8 Drizzle runtime metadata unchanged", async () => {
const { archive } = await pack({ withSchema: true });
const metadata = JSON.parse(
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "",
);
expect(metadata.transitional).toEqual({ databaseRuntime: { "drizzle-orm": "0.45.2" } });
});
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
await packRejection(
{
@@ -22,6 +22,7 @@ export interface FakePackage {
}
export interface FixtureModuleOptions {
readonly databaseAbi?: 1;
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
@@ -55,19 +56,27 @@ async function write(file: string, contents: string | Uint8Array): Promise<void>
const defaultServer = (
id: string,
version: string,
databaseAbi?: 1,
) => `import { defineModuleRuntime } from "@ekklesia/module-sdk";
export default defineModuleRuntime({ id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} });
export default defineModuleRuntime({ ${databaseAbi === 1 ? "database: { abi: 1 }, " : ""}id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} });
`;
const schemaSource = (
id: string,
) => `import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
const schemaSource = (id: string, databaseAbi?: 1) => `${
databaseAbi === 1
? 'import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";'
: 'import { defineModuleSchema } from "@ekklesia/module-sdk";'
}
import { createModuleTable } from "@ekklesia/module-sdk";
import { text, uuid } from "drizzle-orm/pg-core";
export const records = createModuleTable(${JSON.stringify(id)})("records", {
id: uuid("id").primaryKey(),
value: text("value").notNull(),
});
export default defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } });
export default ${
databaseAbi === 1
? `moduleDrizzleSchemaMetadata(${JSON.stringify(id)}, { records })`
: `defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } })`
};
`;
export async function fixtureModule(options: FixtureModuleOptions = {}): Promise<string> {
@@ -76,6 +85,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
const root = await scratchDirectory("ekklesia-pack-fixture-");
const moduleDirectory = path.join(root, id);
const tableName = `${id.replaceAll("-", "_")}_records`;
const usesModuleDatabase = options.withModuleDatabase || options.databaseAbi === 1;
const baseManifest: Record<string, unknown> = {
manifestVersion: 8,
@@ -83,6 +93,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
name: `${id} module`,
version,
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}),
capabilities: [],
permissions: [],
entrypoints: {
@@ -108,7 +119,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
scripts: { build: "tsc" },
dependencies: {
"@ekklesia/module-sdk": "workspace:*",
...(options.withModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
...(usesModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
"drizzle-orm": "0.45.2",
},
devDependencies: { typescript: "7.0.2" },
@@ -121,12 +132,15 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
);
await write(
path.join(moduleDirectory, "dist/server/index.js"),
options.serverSource ?? defaultServer(id, version),
options.serverSource ?? defaultServer(id, version, options.databaseAbi),
);
await write(path.join(moduleDirectory, "dist/server/index.js.map"), '{"version":3}\n');
await write(path.join(moduleDirectory, "dist/server/index.d.ts"), "export {};\n");
if (options.withSchema) {
await write(path.join(moduleDirectory, "dist/schema/index.js"), schemaSource(id));
await write(
path.join(moduleDirectory, "dist/schema/index.js"),
schemaSource(id, options.databaseAbi),
);
}
await write(path.join(moduleDirectory, "src/server/index.ts"), "export {};\n");
await write(path.join(moduleDirectory, "tests/server.test.ts"), "export {};\n");
@@ -171,7 +185,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
path.join(modules, "@ekklesia", "module-sdk"),
);
if (options.withModuleDatabase) {
if (usesModuleDatabase) {
await symlink(
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")),
path.join(modules, "@ekklesia", "module-database"),
@@ -31,11 +31,14 @@ async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
async function platform() {
async function platform(databaseRuntimeVersion?: string) {
const core = await readJson(path.join(repositoryRoot, "core", "package.json"));
return {
databaseRuntime: {
"drizzle-orm": (core.dependencies as Record<string, string>)["drizzle-orm"] ?? "",
"drizzle-orm":
databaseRuntimeVersion ??
(core.dependencies as Record<string, string>)["drizzle-orm"] ??
"",
},
moduleSdkVersion: String(
(await readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json"))).version,
@@ -96,6 +99,32 @@ describe("the module template as a Module Package v1", () => {
expect(await treeListing(template)).toEqual(templateBefore);
});
it("packages the canonical template as ABI v1 with Drizzle private to the module", async () => {
const { archive, result } = await packTemplate();
const files = fileEntries(archive);
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
expect(manifest.database).toEqual({ abi: 1 });
expect(metadata.transitional).toBeUndefined();
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
});
expect(
[...files.entries()].some(
([file, data]) => file.endsWith(".js") && data.toString("utf8").includes("drizzle-orm"),
),
).toBe(true);
await expect(
verifyModulePackage({
archivePath: result.archivePath,
expectedSha256: result.sha256,
platform: await platform("99.0.0"),
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("verifies against this platform and extracts to an unrelated directory that holds no link", async () => {
const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example");
+3
View File
@@ -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
+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
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
+3
View File
@@ -8,6 +8,9 @@
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
},
"database": {
"abi": 1
},
"capabilities": [
{
"id": "example.records",
+1
View File
@@ -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:*",
+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";
export { records } from "./tables.js";
export default defineModuleSchema({
moduleId: "example",
tables: { records },
});
export default moduleDrizzleSchemaMetadata("example", { records });
+8 -7
View File
@@ -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
+35 -14
View File
@@ -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<CapabilityId, unknown>;
routes: ModuleRouteDefinitionInput[];
routes: ModuleRouteDefinitionInput<ModuleDatabaseExecutor>[];
}> {
const provided = new Map<CapabilityId, unknown>();
const routes: ModuleRouteDefinitionInput[] = [];
const routes: ModuleRouteDefinitionInput<ModuleDatabaseExecutor>[] = [];
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<ModuleDatabaseExecutor> = Object.freeze({
database: fakeModuleDatabase(fakeRows),
});
const context: ModuleRouteHandlerContext = Object.freeze({
const context: ModuleRouteHandlerContext<ModuleDatabaseExecutor> = 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<ModuleDatabaseExecutor> = 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 });
});
});