feat: add module migration contracts and validation
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# Ekklesia module template
|
||||
|
||||
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
||||
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, and
|
||||
logical storage-bucket declarations without pretending that migrations, API, or storage
|
||||
provisioning already exist.
|
||||
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
||||
generated module-owned migration, and logical storage-bucket declarations. Migration execution,
|
||||
API routing, and storage provisioning are intentionally not included.
|
||||
|
||||
To create a module inside this repository:
|
||||
|
||||
@@ -15,12 +15,24 @@ To create a module inside this repository:
|
||||
module actually implements and consumes.
|
||||
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
|
||||
must match the manifest; do not duplicate the rest of the manifest in TypeScript.
|
||||
6. Replace `src/schema/index.ts` with declaration-only Drizzle tables. Keep `createModuleTable` and
|
||||
`defineModuleSchema` aligned with the module ID, and never import another module's schema.
|
||||
7. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, and `pnpm verify` from the repository
|
||||
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.
|
||||
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
|
||||
file's `moduleId` when renaming the template.
|
||||
8. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, `pnpm migrations:validate`, and
|
||||
`pnpm verify` from the repository
|
||||
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
|
||||
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
|
||||
|
||||
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
|
||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||
and release them in `stop`.
|
||||
|
||||
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
|
||||
validation treats SQL as opaque bytes, calculates SHA-256 from the exact contents, and never opens a
|
||||
database or executes SQL. Destructive declarations are review metadata, not automatic SQL
|
||||
classification or approval.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/schema/tables.ts",
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
prefix: "index",
|
||||
},
|
||||
breakpoints: true,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "example_records" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"value" text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "253ea4cd-faee-4471-a14c-67187c207392",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.example_records": {
|
||||
"name": "example_records",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"moduleId": "example",
|
||||
"entries": [
|
||||
{
|
||||
"tag": "0000_initial",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784638608616,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js"
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"drizzle",
|
||||
"ekklesia.module.json"
|
||||
],
|
||||
"exports": {
|
||||
@@ -20,6 +21,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
@@ -32,6 +34,7 @@
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { text, uuid } from "drizzle-orm/pg-core";
|
||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { records } from "./tables.js";
|
||||
|
||||
const moduleTable = createModuleTable("example");
|
||||
|
||||
export const records = moduleTable("records", {
|
||||
id: uuid("id").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
export { records } from "./tables.js";
|
||||
|
||||
export default defineModuleSchema({
|
||||
moduleId: "example",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
const moduleTable = pgTableCreator((localName) => `example_${localName}`);
|
||||
|
||||
export const records = moduleTable("records", {
|
||||
id: uuid("id").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
type CapabilityId,
|
||||
parseDrizzleMigrationJournal,
|
||||
parseModuleManifest,
|
||||
parseModuleMigrationPolicy,
|
||||
type RegisteredCapability,
|
||||
} from "@ekklesia/module-sdk";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
@@ -19,6 +22,34 @@ describe("module template", () => {
|
||||
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
||||
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
||||
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||
});
|
||||
|
||||
it("packages a generated stable Drizzle migration and matching policy", async () => {
|
||||
const [journalInput, policyInput, packageInput, sql] = await Promise.all([
|
||||
readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)),
|
||||
]);
|
||||
const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown);
|
||||
const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown);
|
||||
const packageJson = JSON.parse(packageInput) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
files?: string[];
|
||||
};
|
||||
|
||||
expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]);
|
||||
expect(policy).toMatchObject({
|
||||
moduleId: "example",
|
||||
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
|
||||
});
|
||||
expect(packageJson.devDependencies?.["drizzle-kit"]).toBe("0.31.10");
|
||||
expect(packageJson.files).toContain("drizzle");
|
||||
expect(createHash("sha256").update(sql).digest("hex")).toBe(
|
||||
"e09d6c9995fa2c4bd59b815afc9bb6ded6a2d4e1b3e1d97c10457a94a6a4f2a4",
|
||||
);
|
||||
expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"');
|
||||
});
|
||||
|
||||
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user