feat: transition module packages and template to database ABI v1 #72
@@ -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`,
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
|
||||
// in tests as well as the compiled file in the image.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts";
|
||||
import {
|
||||
parseModuleRuntimeDefinition,
|
||||
parseModuleSchemaDefinition,
|
||||
parseModuleSchemaMetadataDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
interface ProbeRequest {
|
||||
readonly databaseAbi?: 1;
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly entrypoints: Readonly<Record<string, string>>;
|
||||
@@ -22,7 +27,7 @@ interface ProbeRequest {
|
||||
|
||||
interface ProbeFailure {
|
||||
readonly role: string;
|
||||
readonly reason: "export" | "identity" | "load";
|
||||
readonly reason: "contract" | "export" | "identity" | "load";
|
||||
}
|
||||
|
||||
function defaultExport(namespace: unknown): unknown {
|
||||
@@ -51,8 +56,14 @@ async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
|
||||
if (definition.id !== request.moduleId || definition.version !== request.version) {
|
||||
failures.push({ reason: "identity", role });
|
||||
}
|
||||
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
|
||||
failures.push({ reason: "contract", role });
|
||||
}
|
||||
} else if (role === "schema") {
|
||||
const definition = parseModuleSchemaDefinition(defaultExport(namespace));
|
||||
const definition =
|
||||
request.databaseAbi === 1
|
||||
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
|
||||
: parseModuleSchemaDefinition(defaultExport(namespace));
|
||||
if (definition.moduleId !== request.moduleId) {
|
||||
failures.push({ reason: "identity", role });
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-disc
|
||||
export type ModulePackageVerificationErrorCode =
|
||||
| ModulePackageArchiveErrorCode
|
||||
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
|
||||
| "MODULE_PACKAGE_DIGEST_MISMATCH"
|
||||
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
|
||||
| "MODULE_PACKAGE_EXTRACTION_FAILED"
|
||||
@@ -67,6 +68,8 @@ export type ModulePackageVerificationErrorCode =
|
||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
|
||||
"A Module Database ABI package must not carry a transitional database runtime.",
|
||||
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
|
||||
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
|
||||
"A backend entrypoint could not be loaded from the package alone.",
|
||||
@@ -275,9 +278,15 @@ function checkCompatibility(
|
||||
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
|
||||
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
|
||||
}
|
||||
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
|
||||
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
|
||||
// implementation constraint pending a Module Database ABI, not that ABI.
|
||||
if (manifest.database?.abi === 1) {
|
||||
if (metadata.transitional?.databaseRuntime !== undefined) {
|
||||
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
|
||||
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
|
||||
// bundled ORM is a private implementation detail and is never compared with Core's.
|
||||
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
|
||||
if (platform.databaseRuntime[runtime] !== version) {
|
||||
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
|
||||
@@ -314,8 +323,7 @@ function probeEntrypointPath(): string {
|
||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||
* only whether each loaded and exported a definition naming this module. */
|
||||
async function loadEntrypoints(
|
||||
moduleId: string,
|
||||
version: string,
|
||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||
entrypoints: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
@@ -332,13 +340,24 @@ async function loadEntrypoints(
|
||||
}
|
||||
};
|
||||
try {
|
||||
child = fork(probeEntrypointPath(), [JSON.stringify({ entrypoints, moduleId, version })], {
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
child = fork(
|
||||
probeEntrypointPath(),
|
||||
[
|
||||
JSON.stringify({
|
||||
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
|
||||
entrypoints,
|
||||
moduleId: manifest.id,
|
||||
version: manifest.version,
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
finish("failed");
|
||||
return;
|
||||
@@ -437,12 +456,7 @@ export async function verifyModulePackage(
|
||||
|
||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||
// after the probe proved it loads on its own.
|
||||
await loadEntrypoints(
|
||||
metadata.module.id,
|
||||
metadata.module.version,
|
||||
entrypoints,
|
||||
options.loadTimeoutMs ?? 30_000,
|
||||
);
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
||||
try {
|
||||
await discoverModuleSchemas(discoveryOptions);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ModulePackageVerificationErrorCode,
|
||||
ModulePackageVerificationError,
|
||||
type ModulePackageVerificationErrorCode,
|
||||
verifyModulePackage,
|
||||
} from "../src/modules/index.js";
|
||||
import {
|
||||
@@ -72,6 +72,19 @@ function withEntry(entry: FixtureEntry): PackageFixtureOptions {
|
||||
return { extraEntries: [entry] };
|
||||
}
|
||||
|
||||
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
|
||||
return {
|
||||
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
|
||||
schemaSource: `export default {
|
||||
moduleId: "example",
|
||||
tables: [{ name: "example_records", foreignKeys: [] }]
|
||||
};\n`,
|
||||
serverSource:
|
||||
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
it("verifies a valid any-target package, its digest, and its module", async () => {
|
||||
const { archivePath, sha256: digest } = await archiveFile();
|
||||
@@ -214,6 +227,44 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
|
||||
const { archivePath } = await archiveFile(abiPackage());
|
||||
|
||||
await expect(
|
||||
verifyModulePackage({
|
||||
archivePath,
|
||||
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
|
||||
}),
|
||||
).resolves.toMatchObject({ moduleId: "example" });
|
||||
});
|
||||
|
||||
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
|
||||
await rejection(
|
||||
abiPackage({
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||
}),
|
||||
}),
|
||||
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
|
||||
await rejection(
|
||||
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
|
||||
"MODULE_PACKAGE_MANIFEST_INVALID",
|
||||
);
|
||||
const schemaError = await rejection(
|
||||
abiPackage({
|
||||
schemaSource:
|
||||
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
|
||||
}),
|
||||
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||
);
|
||||
expect(schemaError.message).not.toContain("attacker-chosen");
|
||||
});
|
||||
|
||||
it("fails a target this platform cannot run", async () => {
|
||||
await rejection(
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface PackageFixtureOptions {
|
||||
readonly id?: string;
|
||||
readonly version?: string;
|
||||
readonly serverSource?: string;
|
||||
readonly schemaSource?: string;
|
||||
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
||||
readonly runtimePackage?: Record<string, unknown>;
|
||||
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
|
||||
@@ -125,6 +126,7 @@ export async function modulePackageFixture(
|
||||
let files: Map<string, Buffer>;
|
||||
try {
|
||||
const created = await createMigrationModule(scratch, id, {
|
||||
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
|
||||
serverSource:
|
||||
options.serverSource ??
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
},
|
||||
"database": {
|
||||
"abi": 1
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "example.records",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user