Merge pull request 'feat: transition module packages and template to database ABI v1' (#72) from feat/module-package-abi-v1-transition into main
CI / verify (push) Successful in 59s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m53s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m21s
CI / verify (push) Successful in 59s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m53s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m21s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/72 Reviewed-by: The_River <[email protected]>
This commit was merged in pull request #72.
This commit is contained in:
@@ -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`,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
cwd: tmpdir(),
|
probeEntrypointPath(),
|
||||||
env: {},
|
[
|
||||||
execArgv: [],
|
JSON.stringify({
|
||||||
serialization: "json",
|
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
|
||||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
entrypoints,
|
||||||
});
|
moduleId: manifest.id,
|
||||||
|
version: manifest.version,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: tmpdir(),
|
||||||
|
env: {},
|
||||||
|
execArgv: [],
|
||||||
|
serialization: "json",
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user