CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
Every platform workspace package and every public @ekklesia/* package declares 0.2.0, and the deploy README's image build example names it. templates/module keeps its own version. 0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one (docs/development/releases.md, ADR 0054), and since 0.1.2 main has broken modules, module authors, and operators: - #64 (ADR 0048): one generic image; installed modules live outside it; - #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and move to the Module Database ABI; - #71: the manifest v8 cutover; v7 manifests are refused; - #79 (ADR 0053): the curated SDK drops defineModuleSchema and the legacy ModuleDatabase type; plus #67 (AGPL), #74, #77 (licensing, native targets), and the public package train (#80, #81). It is also the first package train to publish: 0.1.2 never will be. Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0: - the standalone fixture's @ekklesia ranges and its exact module-pack pin; - the compatibility ranges of the modules built for this release: the template, the standalone fixture, and the native proof module; - the test fixtures that model a module of the current release, or the current platform's versions; - the module-development guide's examples, and the release guide, which now names what a minor bump moves. Co-Authored-By: Claude Opus 5.5 <[email protected]>
172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* A minimal, valid installed module written into a directory the caller owns: `package.json`,
|
|
* `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless
|
|
* disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain,
|
|
* and SQL) that passes Core's migration discovery.
|
|
*
|
|
* Everything is generated here, from nothing in the repository: no build output, no ignored file,
|
|
* and no `node_modules`. Migration discovery imports no module code, so the stub is never run.
|
|
*/
|
|
|
|
export const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
|
|
|
export interface MigrationFixture {
|
|
readonly breakpoints?: boolean;
|
|
readonly destructive?:
|
|
| { readonly mode: "none" }
|
|
| { readonly mode: "approval-required"; readonly reason: string };
|
|
readonly snapshotId?: string;
|
|
readonly sql: string | Uint8Array;
|
|
readonly tag: string;
|
|
readonly timestamp?: number;
|
|
}
|
|
|
|
export interface MigrationModuleFixture {
|
|
readonly dependencies?: Record<string, unknown>;
|
|
readonly manifest?: Record<string, unknown>;
|
|
readonly migrations?: readonly MigrationFixture[] | false;
|
|
readonly schemaSource?: string;
|
|
readonly serverSource?: string;
|
|
}
|
|
|
|
export interface CreatedMigrationModule {
|
|
readonly migrationDirectory?: string;
|
|
readonly moduleDirectory: string;
|
|
}
|
|
|
|
export function snapshotId(index: number): string {
|
|
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
|
}
|
|
|
|
export function manifestFor(
|
|
id: string,
|
|
options: {
|
|
readonly dependencies?: Record<string, unknown>;
|
|
readonly migrations?: boolean;
|
|
readonly schema?: boolean;
|
|
} = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
manifestVersion: 8,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
capabilities: [],
|
|
permissions: [],
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
|
|
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function defaultMigrations(id: string): readonly MigrationFixture[] {
|
|
return [
|
|
{
|
|
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
|
tag: "0000_initial",
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function createMigrationModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
fixture: MigrationModuleFixture = {},
|
|
): Promise<CreatedMigrationModule> {
|
|
const migrations =
|
|
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
if (fixture.schemaSource !== undefined) {
|
|
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
|
|
}
|
|
if (migrationDirectory) {
|
|
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
|
|
}
|
|
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
name: `@test/module-${id}`,
|
|
version: "0.1.0",
|
|
type: "module",
|
|
ekklesia: { layer: "module" },
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify(
|
|
fixture.manifest ??
|
|
manifestFor(id, {
|
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
|
migrations: migrations !== undefined,
|
|
schema: fixture.schemaSource !== undefined,
|
|
}),
|
|
),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
|
),
|
|
...(fixture.schemaSource === undefined
|
|
? []
|
|
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
|
|
]);
|
|
|
|
if (!migrationDirectory || !migrations) {
|
|
return { moduleDirectory };
|
|
}
|
|
|
|
const journalEntries = migrations.map((migration, index) => ({
|
|
idx: index,
|
|
version: "7",
|
|
when: migration.timestamp ?? 1_700_000_000_000 + index,
|
|
tag: migration.tag,
|
|
breakpoints: migration.breakpoints ?? true,
|
|
}));
|
|
const policyEntries = migrations.map((migration) => ({
|
|
tag: migration.tag,
|
|
destructive: migration.destructive ?? { mode: "none" },
|
|
}));
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(migrationDirectory, "meta/_journal.json"),
|
|
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
|
|
),
|
|
writeFile(
|
|
path.join(migrationDirectory, "meta/_ekklesia.json"),
|
|
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
|
|
),
|
|
...migrations.flatMap((migration, index) => {
|
|
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
|
|
const previousId =
|
|
index === 0
|
|
? originSnapshotId
|
|
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
|
|
return [
|
|
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
|
|
writeFile(
|
|
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
|
|
JSON.stringify({
|
|
version: "7",
|
|
dialect: "postgresql",
|
|
id: idForSnapshot,
|
|
prevId: previousId,
|
|
}),
|
|
),
|
|
];
|
|
}),
|
|
]);
|
|
|
|
return { migrationDirectory, moduleDirectory };
|
|
}
|