feat: transition module packages to database ABI v1
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
This commit is contained in:
@@ -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