feat: add module migration contracts and validation
This commit is contained in:
@@ -10,8 +10,10 @@ Phase 3 is underway. The repository now includes public module contracts, indepe
|
||||
capabilities, static module manifests, deterministic automatic discovery, compiled server loading,
|
||||
a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table
|
||||
contracts, deterministic schema aggregation, and a cloneable backend-only module template. Migration
|
||||
execution, identity, HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules
|
||||
remain later vertical slices.
|
||||
artifacts now have module-owned Drizzle contracts, deterministic offline discovery, explicit
|
||||
destructive-change declarations, and exact checksums. Transactional migration execution, identity,
|
||||
HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules remain later
|
||||
vertical slices.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -22,6 +24,7 @@ pnpm install --frozen-lockfile
|
||||
cp .env.example .env
|
||||
pnpm infrastructure:up
|
||||
pnpm modules:validate
|
||||
pnpm migrations:validate
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||
|
||||
function plural(count: number, singular: string): string {
|
||||
return count === 1 ? singular : `${singular}s`;
|
||||
}
|
||||
|
||||
export function formatMigrationValidationReport(
|
||||
aggregate: ModuleMigrationAggregate,
|
||||
): readonly string[] {
|
||||
const moduleCount = aggregate.modules.length;
|
||||
const migrationCount = aggregate.migrations.length;
|
||||
const header = `Migration validation passed (${moduleCount} ${plural(moduleCount, "module")}, ${migrationCount} ${plural(migrationCount, "migration")})${moduleCount === 0 ? "." : ":"}`;
|
||||
const lines = [header];
|
||||
|
||||
for (const module of aggregate.modules) {
|
||||
if (module.migrations.length === 0) {
|
||||
lines.push(`- ${module.moduleId} (no migrations)`);
|
||||
continue;
|
||||
}
|
||||
for (const migration of module.migrations) {
|
||||
lines.push(
|
||||
`- ${migration.identity} sha256=${migration.checksum} destructive=${migration.destructive.mode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatMigrationValidationReport } from "./migration-report.js";
|
||||
import {
|
||||
discoverModuleMigrations,
|
||||
ModuleDiscoveryError,
|
||||
ModuleMigrationDiscoveryError,
|
||||
} from "../modules/index.js";
|
||||
|
||||
interface PackageVersion {
|
||||
version?: unknown;
|
||||
}
|
||||
|
||||
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
|
||||
if (typeof packageData.version !== "string") {
|
||||
throw new TypeError("A required workspace package does not declare a version.");
|
||||
}
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const repositoryRoot = process.cwd();
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
]);
|
||||
const aggregate = await discoverModuleMigrations({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion,
|
||||
});
|
||||
|
||||
for (const line of formatMigrationValidationReport(aggregate)) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
console.error(error.message);
|
||||
for (const issue of error.issues) {
|
||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
|
||||
}
|
||||
} else if (error instanceof ModuleMigrationDiscoveryError) {
|
||||
console.error(error.message);
|
||||
for (const issue of error.issues) {
|
||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
|
||||
}
|
||||
} else {
|
||||
console.error("Migration validation failed unexpectedly.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./discovery.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
DRIZZLE_MIGRATION_JOURNAL_FILENAME,
|
||||
DRIZZLE_MIGRATION_META_DIRECTORY,
|
||||
DRIZZLE_MIGRATION_ORIGIN_UUID,
|
||||
EKKLESIA_MIGRATION_POLICY_FILENAME,
|
||||
type DrizzleMigrationJournal,
|
||||
type DrizzleMigrationSnapshotHeader,
|
||||
type ModuleId,
|
||||
type ModuleMigrationDestructiveDeclaration,
|
||||
type ModuleMigrationPolicy,
|
||||
moduleMigrationTagSchema,
|
||||
parseDrizzleMigrationJournal,
|
||||
parseDrizzleMigrationSnapshotHeader,
|
||||
parseModuleMigrationPolicy,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
} from "./discovery.js";
|
||||
|
||||
export type ModuleMigrationDiscoveryIssueCode =
|
||||
| "MODULE_MIGRATION_CHECKSUM_INVALID"
|
||||
| "MODULE_MIGRATION_DIRECTORY_INVALID"
|
||||
| "MODULE_MIGRATION_EMPTY"
|
||||
| "MODULE_MIGRATION_FILE_INVALID"
|
||||
| "MODULE_MIGRATION_FILE_MISSING"
|
||||
| "MODULE_MIGRATION_FILE_UNDECLARED"
|
||||
| "MODULE_MIGRATION_ID_DUPLICATE"
|
||||
| "MODULE_MIGRATION_ID_INVALID"
|
||||
| "MODULE_MIGRATION_JOURNAL_INVALID"
|
||||
| "MODULE_MIGRATION_METADATA_INCONSISTENT"
|
||||
| "MODULE_MIGRATION_METADATA_INVALID"
|
||||
| "MODULE_MIGRATION_ORDER_INVALID"
|
||||
| "MODULE_MIGRATION_PATH_UNSAFE"
|
||||
| "MODULE_MIGRATION_SNAPSHOT_INVALID"
|
||||
| "MODULE_MIGRATION_UNSUPPORTED_FILE";
|
||||
|
||||
export interface ModuleMigrationDiscoveryIssue {
|
||||
readonly code: ModuleMigrationDiscoveryIssueCode;
|
||||
readonly message: string;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredModuleMigration {
|
||||
readonly approvalRequired: boolean;
|
||||
readonly breakpoints: boolean;
|
||||
readonly checksum: string;
|
||||
readonly destructive: ModuleMigrationDestructiveDeclaration;
|
||||
readonly file: string;
|
||||
readonly identity: string;
|
||||
readonly index: number;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly sql: string;
|
||||
readonly tag: string;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredModuleMigrations {
|
||||
readonly migrations: readonly DiscoveredModuleMigration[];
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
export interface ModuleMigrationAggregate {
|
||||
readonly discoveredModules: readonly DiscoveredModule[];
|
||||
readonly migrations: readonly DiscoveredModuleMigration[];
|
||||
readonly modules: readonly DiscoveredModuleMigrations[];
|
||||
}
|
||||
|
||||
interface DirectoryInventory {
|
||||
readonly metaEntries: ReadonlySet<string>;
|
||||
readonly rootEntries: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const sqlFilenamePattern = /^\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.sql$/u;
|
||||
const snapshotFilenamePattern = /^\d{4,}_snapshot\.json$/u;
|
||||
|
||||
export class ModuleMigrationDiscoveryError extends Error {
|
||||
readonly issues: readonly ModuleMigrationDiscoveryIssue[];
|
||||
|
||||
constructor(issues: readonly ModuleMigrationDiscoveryIssue[]) {
|
||||
const sortedIssues = [...issues].sort(compareIssues);
|
||||
super(`Module migration discovery failed with ${sortedIssues.length} issue(s).`);
|
||||
this.name = "ModuleMigrationDiscoveryError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
this.issues = Object.freeze(sortedIssues.map((issue) => Object.freeze({ ...issue })));
|
||||
}
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function compareIssues(
|
||||
left: ModuleMigrationDiscoveryIssue,
|
||||
right: ModuleMigrationDiscoveryIssue,
|
||||
): number {
|
||||
return (
|
||||
compareText(left.moduleId, right.moduleId) ||
|
||||
compareText(left.path ?? "", right.path ?? "") ||
|
||||
compareText(left.code, right.code) ||
|
||||
compareText(left.message, right.message)
|
||||
);
|
||||
}
|
||||
|
||||
function issue(
|
||||
code: ModuleMigrationDiscoveryIssueCode,
|
||||
moduleId: ModuleId,
|
||||
message: string,
|
||||
pathValue?: string,
|
||||
): ModuleMigrationDiscoveryIssue {
|
||||
return { code, message, moduleId, ...(pathValue ? { path: pathValue } : {}) };
|
||||
}
|
||||
|
||||
function pathIsInside(parent: string, candidate: string): boolean {
|
||||
const relative = path.relative(parent, candidate);
|
||||
return (
|
||||
relative !== "" &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== ".." &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
async function readSafeFile(
|
||||
root: string,
|
||||
relativePath: string,
|
||||
moduleId: ModuleId,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
logicalPath: string,
|
||||
): Promise<Uint8Array | undefined> {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
if (!pathIsInside(root, absolutePath)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata contains an unsafe path.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStats = await lstat(absolutePath);
|
||||
if (fileStats.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration files must not be symbolic links.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (!fileStats.isFile()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration inventory contains a non-file entry.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const canonicalPath = await realpath(absolutePath);
|
||||
if (!pathIsInside(root, canonicalPath)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration files must remain inside the migration directory.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return await readFile(canonicalPath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
issues.push(
|
||||
issue(
|
||||
code === "ENOENT" ? "MODULE_MIGRATION_FILE_MISSING" : "MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
code === "ENOENT"
|
||||
? `Module ${moduleId} is missing a required migration file.`
|
||||
: `Module ${moduleId} migration file could not be read.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readInventory(
|
||||
root: string,
|
||||
moduleId: ModuleId,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): Promise<DirectoryInventory | undefined> {
|
||||
let rootEntries: Dirent[];
|
||||
try {
|
||||
rootEntries = (await readdir(root, { withFileTypes: true })).sort((left, right) =>
|
||||
compareText(left.name, right.name),
|
||||
);
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_DIRECTORY_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration directory could not be inspected.`,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rootNames = new Set(rootEntries.map((entry) => entry.name));
|
||||
rootEntries.forEach((entry, index) => {
|
||||
if (entry.name === DRIZZLE_MIGRATION_META_DIRECTORY) {
|
||||
return;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration files must not be symbolic links.`,
|
||||
`files.${index}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sqlFilenamePattern.test(entry.name)) {
|
||||
if (!entry.isFile()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration inventory contains a non-file entry.`,
|
||||
`files.${index}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_UNSUPPORTED_FILE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration directory contains an unsupported entry.`,
|
||||
`files.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const metaPath = path.join(root, DRIZZLE_MIGRATION_META_DIRECTORY);
|
||||
try {
|
||||
const metaStats = await lstat(metaPath);
|
||||
if (metaStats.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata directory must not be a symbolic link.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
if (!metaStats.isDirectory()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_DIRECTORY_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata entry must be a directory.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
const canonicalMetaPath = await realpath(metaPath);
|
||||
if (!pathIsInside(root, canonicalMetaPath)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata must remain inside the migration directory.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
const metaEntries = (await readdir(canonicalMetaPath, { withFileTypes: true })).sort(
|
||||
(left, right) => compareText(left.name, right.name),
|
||||
);
|
||||
const metaNames = new Set(metaEntries.map((entry) => entry.name));
|
||||
metaEntries.forEach((entry, index) => {
|
||||
if (entry.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata files must not be symbolic links.`,
|
||||
`meta.files.${index}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
entry.name === DRIZZLE_MIGRATION_JOURNAL_FILENAME ||
|
||||
entry.name === EKKLESIA_MIGRATION_POLICY_FILENAME ||
|
||||
snapshotFilenamePattern.test(entry.name)
|
||||
) {
|
||||
if (!entry.isFile()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata contains a non-file entry.`,
|
||||
`meta.files.${index}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_UNSUPPORTED_FILE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata contains an unsupported entry.`,
|
||||
`meta.files.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return { metaEntries: metaNames, rootEntries: rootNames };
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_DIRECTORY_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata directory could not be inspected.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Uint8Array): unknown {
|
||||
return JSON.parse(utf8Decoder.decode(bytes)) as unknown;
|
||||
}
|
||||
|
||||
function validateJournalOrdering(
|
||||
moduleId: ModuleId,
|
||||
journal: DrizzleMigrationJournal,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): void {
|
||||
const seenTags = new Set<string>();
|
||||
let previousTimestamp = -1;
|
||||
|
||||
journal.entries.forEach((entry, position) => {
|
||||
const entryPath = `journal.entries.${position}`;
|
||||
if (entry.idx !== position) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ORDER_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration indices must be contiguous and journal-ordered.`,
|
||||
`${entryPath}.idx`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!moduleMigrationTagSchema.safeParse(entry.tag).success) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ID_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains an invalid migration identity.`,
|
||||
`${entryPath}.tag`,
|
||||
),
|
||||
);
|
||||
} else if (!entry.tag.startsWith(`${entry.idx.toString().padStart(4, "0")}_`)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ID_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration identities must match their journal indices.`,
|
||||
`${entryPath}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (seenTags.has(entry.tag)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ID_DUPLICATE",
|
||||
moduleId,
|
||||
`Module ${moduleId} declares the same migration identity more than once.`,
|
||||
`${entryPath}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
seenTags.add(entry.tag);
|
||||
if (entry.when <= previousTimestamp) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ORDER_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration timestamps must increase with journal order.`,
|
||||
`${entryPath}.when`,
|
||||
),
|
||||
);
|
||||
}
|
||||
previousTimestamp = entry.when;
|
||||
});
|
||||
}
|
||||
|
||||
function validatePolicy(
|
||||
moduleId: ModuleId,
|
||||
journal: DrizzleMigrationJournal,
|
||||
policy: ModuleMigrationPolicy,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): void {
|
||||
if (policy.moduleId !== moduleId) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy does not match its manifest ID.`,
|
||||
"policy.moduleId",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (policy.entries.length !== journal.entries.length) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy must match the journal inventory.`,
|
||||
"policy.entries",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const seenTags = new Set<string>();
|
||||
policy.entries.forEach((entry, index) => {
|
||||
if (seenTags.has(entry.tag)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy contains a duplicate entry.`,
|
||||
`policy.entries.${index}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
seenTags.add(entry.tag);
|
||||
if (entry.tag !== journal.entries[index]?.tag) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy must preserve journal identity and order.`,
|
||||
`policy.entries.${index}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function validateSnapshots(
|
||||
root: string,
|
||||
moduleId: ModuleId,
|
||||
journal: DrizzleMigrationJournal,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): Promise<void> {
|
||||
const seenIds = new Set<string>();
|
||||
let previousId: string | undefined = DRIZZLE_MIGRATION_ORIGIN_UUID;
|
||||
|
||||
for (const [position, entry] of journal.entries.entries()) {
|
||||
const prefix = entry.idx.toString().padStart(4, "0");
|
||||
const bytes = await readSafeFile(
|
||||
root,
|
||||
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${prefix}_snapshot.json`,
|
||||
moduleId,
|
||||
issues,
|
||||
`snapshots.${position}`,
|
||||
);
|
||||
if (!bytes) {
|
||||
previousId = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
let snapshot: DrizzleMigrationSnapshotHeader;
|
||||
try {
|
||||
snapshot = parseDrizzleMigrationSnapshotHeader(parseJson(bytes));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_SNAPSHOT_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains invalid Drizzle snapshot metadata.`,
|
||||
`snapshots.${position}`,
|
||||
),
|
||||
);
|
||||
previousId = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
snapshot.id === DRIZZLE_MIGRATION_ORIGIN_UUID ||
|
||||
seenIds.has(snapshot.id) ||
|
||||
(previousId !== undefined && snapshot.prevId !== previousId)
|
||||
) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_SNAPSHOT_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} Drizzle snapshots must form one ordered identity chain.`,
|
||||
`snapshots.${position}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
seenIds.add(snapshot.id);
|
||||
previousId = snapshot.id;
|
||||
}
|
||||
}
|
||||
|
||||
function validateInventory(
|
||||
moduleId: ModuleId,
|
||||
inventory: DirectoryInventory,
|
||||
journal: DrizzleMigrationJournal,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): void {
|
||||
const expectedSql = new Set(
|
||||
journal.entries
|
||||
.filter((entry) => moduleMigrationTagSchema.safeParse(entry.tag).success)
|
||||
.map((entry) => `${entry.tag}.sql`),
|
||||
);
|
||||
const expectedSnapshots = new Set(
|
||||
journal.entries.map((entry) => `${entry.idx.toString().padStart(4, "0")}_snapshot.json`),
|
||||
);
|
||||
|
||||
const undeclaredSql = [...inventory.rootEntries]
|
||||
.filter((name) => sqlFilenamePattern.test(name) && !expectedSql.has(name))
|
||||
.sort(compareText);
|
||||
undeclaredSql.forEach((_name, index) => {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains a SQL migration absent from its journal.`,
|
||||
`undeclaredSql.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const undeclaredSnapshots = [...inventory.metaEntries]
|
||||
.filter((name) => snapshotFilenamePattern.test(name) && !expectedSnapshots.has(name))
|
||||
.sort(compareText);
|
||||
undeclaredSnapshots.forEach((_name, index) => {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains a snapshot absent from its journal.`,
|
||||
`undeclaredSnapshots.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectModuleMigrations(
|
||||
discovered: DiscoveredModule,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): Promise<readonly DiscoveredModuleMigration[]> {
|
||||
const moduleId = discovered.manifest.id;
|
||||
const migrationRoot = discovered.entrypoints.migrations;
|
||||
if (!migrationRoot) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
|
||||
const issueCountBeforeModule = issues.length;
|
||||
const inventory = await readInventory(migrationRoot, moduleId, issues);
|
||||
const [journalBytes, policyBytes] = await Promise.all([
|
||||
readSafeFile(
|
||||
migrationRoot,
|
||||
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${DRIZZLE_MIGRATION_JOURNAL_FILENAME}`,
|
||||
moduleId,
|
||||
issues,
|
||||
"journal",
|
||||
),
|
||||
readSafeFile(
|
||||
migrationRoot,
|
||||
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${EKKLESIA_MIGRATION_POLICY_FILENAME}`,
|
||||
moduleId,
|
||||
issues,
|
||||
"policy",
|
||||
),
|
||||
]);
|
||||
|
||||
let journal: DrizzleMigrationJournal | undefined;
|
||||
if (journalBytes) {
|
||||
try {
|
||||
journal = parseDrizzleMigrationJournal(parseJson(journalBytes));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_JOURNAL_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} has invalid Drizzle migration journal metadata.`,
|
||||
"journal",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let policy: ModuleMigrationPolicy | undefined;
|
||||
if (policyBytes) {
|
||||
try {
|
||||
policy = parseModuleMigrationPolicy(parseJson(policyBytes));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} has invalid migration policy metadata.`,
|
||||
"policy",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!journal) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
|
||||
validateJournalOrdering(moduleId, journal, issues);
|
||||
if (policy) {
|
||||
validatePolicy(moduleId, journal, policy, issues);
|
||||
}
|
||||
if (inventory) {
|
||||
validateInventory(moduleId, inventory, journal, issues);
|
||||
}
|
||||
await validateSnapshots(migrationRoot, moduleId, journal, issues);
|
||||
|
||||
const migrations: DiscoveredModuleMigration[] = [];
|
||||
for (const [position, entry] of journal.entries.entries()) {
|
||||
if (!moduleMigrationTagSchema.safeParse(entry.tag).success) {
|
||||
continue;
|
||||
}
|
||||
const sqlBytes = await readSafeFile(
|
||||
migrationRoot,
|
||||
`${entry.tag}.sql`,
|
||||
moduleId,
|
||||
issues,
|
||||
`migrations.${position}.sql`,
|
||||
);
|
||||
if (!sqlBytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const checksum = createHash("sha256").update(sqlBytes).digest("hex");
|
||||
if (!/^[a-f0-9]{64}$/u.test(checksum)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_CHECKSUM_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration checksum could not be calculated.`,
|
||||
`migrations.${position}.checksum`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql: string;
|
||||
try {
|
||||
sql = utf8Decoder.decode(sqlBytes);
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} SQL migrations must be valid UTF-8 text.`,
|
||||
`migrations.${position}.sql`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (sql.trim().length === 0) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_EMPTY",
|
||||
moduleId,
|
||||
`Module ${moduleId} SQL migrations must not be empty.`,
|
||||
`migrations.${position}.sql`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const policyEntry = policy?.entries[position];
|
||||
if (!policyEntry || policyEntry.tag !== entry.tag) {
|
||||
continue;
|
||||
}
|
||||
const { destructive } = policyEntry;
|
||||
migrations.push(
|
||||
Object.freeze({
|
||||
approvalRequired: destructive.mode === "approval-required",
|
||||
breakpoints: entry.breakpoints,
|
||||
checksum,
|
||||
destructive: Object.freeze({ ...destructive }),
|
||||
file: `${entry.tag}.sql`,
|
||||
identity: `${moduleId}:${entry.tag}`,
|
||||
index: entry.idx,
|
||||
moduleId,
|
||||
sql,
|
||||
tag: entry.tag,
|
||||
timestamp: entry.when,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length > issueCountBeforeModule) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
return Object.freeze(migrations);
|
||||
}
|
||||
|
||||
export async function discoverModuleMigrations(
|
||||
options: ModuleDiscoveryOptions,
|
||||
): Promise<ModuleMigrationAggregate> {
|
||||
const discoveredModules = await discoverModules(options);
|
||||
const issues: ModuleMigrationDiscoveryIssue[] = [];
|
||||
const modules: DiscoveredModuleMigrations[] = [];
|
||||
|
||||
for (const discovered of discoveredModules) {
|
||||
modules.push(
|
||||
Object.freeze({
|
||||
migrations: await inspectModuleMigrations(discovered, issues),
|
||||
moduleId: discovered.manifest.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleMigrationDiscoveryError(issues);
|
||||
}
|
||||
|
||||
const frozenModules = Object.freeze(modules);
|
||||
const migrations = Object.freeze(frozenModules.flatMap((module) => module.migrations));
|
||||
return Object.freeze({ discoveredModules, migrations, modules: frozenModules });
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { formatMigrationValidationReport } from "../src/cli/migration-report.js";
|
||||
import {
|
||||
discoverModuleMigrations,
|
||||
ModuleDiscoveryError,
|
||||
ModuleMigrationDiscoveryError,
|
||||
type ModuleMigrationDiscoveryIssueCode,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface MigrationModuleFixture {
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly manifest?: Record<string, unknown>;
|
||||
readonly migrations?: readonly MigrationFixture[] | false;
|
||||
readonly schemaSource?: string;
|
||||
readonly serverSource?: string;
|
||||
}
|
||||
|
||||
interface CreatedMigrationModule {
|
||||
readonly migrationDirectory?: string;
|
||||
readonly moduleDirectory: string;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function createModulesDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-migrations-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function snapshotId(index: number): string {
|
||||
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
function manifestFor(
|
||||
id: string,
|
||||
options: {
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly migrations?: boolean;
|
||||
readonly schema?: boolean;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.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" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMigrations(id: string): readonly MigrationFixture[] {
|
||||
return [
|
||||
{
|
||||
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
||||
tag: "0000_initial",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function discoveryOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
};
|
||||
}
|
||||
|
||||
async function captureMigrationError(
|
||||
modulesDirectory: string,
|
||||
): Promise<ModuleMigrationDiscoveryError> {
|
||||
try {
|
||||
await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleMigrationDiscoveryError) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected module migration discovery to fail.");
|
||||
}
|
||||
|
||||
function issueCodes(error: ModuleMigrationDiscoveryError): ModuleMigrationDiscoveryIssueCode[] {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
function sha256(contents: string | Uint8Array): string {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
describe("module migration discovery", () => {
|
||||
it("aggregates dependency-first modules and journal-ordered migrations with an exact report", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const peopleInitial = 'CREATE TABLE "people_records" ("id" integer);\n';
|
||||
const peopleArchive = new TextEncoder().encode(
|
||||
'ALTER TABLE "people_records" ADD COLUMN "archived" boolean;\r\n',
|
||||
);
|
||||
const calendarEvents = 'CREATE TABLE "calendar_events" ("id" integer);\n';
|
||||
await createMigrationModule(modulesDirectory, "calendar", {
|
||||
dependencies: { modules: { required: { people: "^0.1.0" } } },
|
||||
migrations: [{ sql: calendarEvents, tag: "0000_events" }],
|
||||
});
|
||||
await createMigrationModule(modulesDirectory, "people", {
|
||||
migrations: [
|
||||
{ sql: peopleInitial, tag: "0000_initial" },
|
||||
{
|
||||
destructive: {
|
||||
mode: "approval-required",
|
||||
reason: "Existing deployments must approve the archive-column rollout.",
|
||||
},
|
||||
sql: peopleArchive,
|
||||
tag: "0001_archive",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["people", "calendar"]);
|
||||
expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([
|
||||
"people:0000_initial",
|
||||
"people:0001_archive",
|
||||
"calendar:0000_events",
|
||||
]);
|
||||
expect(aggregate.migrations.map((migration) => migration.checksum)).toEqual([
|
||||
sha256(peopleInitial),
|
||||
sha256(peopleArchive),
|
||||
sha256(calendarEvents),
|
||||
]);
|
||||
expect(aggregate.migrations[1]).toMatchObject({
|
||||
approvalRequired: true,
|
||||
breakpoints: true,
|
||||
destructive: {
|
||||
mode: "approval-required",
|
||||
reason: "Existing deployments must approve the archive-column rollout.",
|
||||
},
|
||||
file: "0001_archive.sql",
|
||||
index: 1,
|
||||
timestamp: 1_700_000_000_001,
|
||||
});
|
||||
expect(formatMigrationValidationReport(aggregate)).toEqual([
|
||||
"Migration validation passed (2 modules, 3 migrations):",
|
||||
`- people:0000_initial sha256=${sha256(peopleInitial)} destructive=none`,
|
||||
`- people:0001_archive sha256=${sha256(peopleArchive)} destructive=approval-required`,
|
||||
`- calendar:0000_events sha256=${sha256(calendarEvents)} destructive=none`,
|
||||
]);
|
||||
expect(Object.isFrozen(aggregate)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.modules)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.modules[0])).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.migrations)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.migrations[0])).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.migrations[1]?.destructive)).toBe(true);
|
||||
expect(Object.isFrozen(formatMigrationValidationReport(aggregate))).toBe(true);
|
||||
});
|
||||
|
||||
it("hashes exact SQL bytes and changes only the checksum when content changes", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const original = new TextEncoder().encode("SELECT 1;\n");
|
||||
const changed = new TextEncoder().encode("SELECT 1;\r\n");
|
||||
const fixture = await createMigrationModule(modulesDirectory, "exact", {
|
||||
migrations: [{ sql: original, tag: "0000_initial" }],
|
||||
});
|
||||
|
||||
const first = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
expect(first.migrations[0]).toMatchObject({
|
||||
checksum: sha256(original),
|
||||
identity: "exact:0000_initial",
|
||||
sql: "SELECT 1;\n",
|
||||
});
|
||||
|
||||
await writeFile(path.join(fixture.migrationDirectory ?? "", "0000_initial.sql"), changed);
|
||||
const second = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(second.migrations[0]?.identity).toBe(first.migrations[0]?.identity);
|
||||
expect(second.migrations[0]?.checksum).toBe(sha256(changed));
|
||||
expect(second.migrations[0]?.checksum).not.toBe(first.migrations[0]?.checksum);
|
||||
});
|
||||
|
||||
it("supports modules without migrations without importing server or schema code", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createMigrationModule(modulesDirectory, "schema-only", {
|
||||
migrations: false,
|
||||
schemaSource: 'throw new Error("schema entrypoint must remain unloaded");',
|
||||
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
|
||||
});
|
||||
await createMigrationModule(modulesDirectory, "server-only", {
|
||||
migrations: false,
|
||||
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.discoveredModules).toHaveLength(2);
|
||||
expect(aggregate.modules).toEqual([
|
||||
{ migrations: [], moduleId: "schema-only" },
|
||||
{ migrations: [], moduleId: "server-only" },
|
||||
]);
|
||||
expect(aggregate.migrations).toEqual([]);
|
||||
expect(formatMigrationValidationReport(aggregate)).toEqual([
|
||||
"Migration validation passed (2 modules, 0 migrations):",
|
||||
"- schema-only (no migrations)",
|
||||
"- server-only (no migrations)",
|
||||
]);
|
||||
expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true);
|
||||
});
|
||||
|
||||
it("completes static discovery before inspecting migration contents", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const valid = await createMigrationModule(modulesDirectory, "migration-invalid");
|
||||
await writeFile(
|
||||
path.join(valid.migrationDirectory ?? "", "meta/_journal.json"),
|
||||
'{"secret":"postgres://admin:password@database/private"',
|
||||
);
|
||||
await createMigrationModule(modulesDirectory, "static-invalid", {
|
||||
manifest: {
|
||||
...manifestFor("static-invalid", { migrations: false }),
|
||||
entrypoints: { server: "./dist/server/missing.js" },
|
||||
},
|
||||
migrations: false,
|
||||
});
|
||||
|
||||
let discoveryError: ModuleDiscoveryError | undefined;
|
||||
try {
|
||||
await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
discoveryError = error;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(discoveryError?.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_MISSING",
|
||||
moduleId: "static-invalid",
|
||||
path: "entrypoints.server",
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(discoveryError)).not.toContain("postgres://");
|
||||
});
|
||||
|
||||
it("rejects malformed journal and policy metadata without leaking their contents", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const journalInvalid = await createMigrationModule(modulesDirectory, "journal-invalid");
|
||||
const policyInvalid = await createMigrationModule(modulesDirectory, "policy-invalid");
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(journalInvalid.migrationDirectory ?? "", "meta/_journal.json"),
|
||||
'{"url":"postgres://admin:password@database/private","path":"/machine/journal"',
|
||||
),
|
||||
writeFile(
|
||||
path.join(policyInvalid.migrationDirectory ?? "", "meta/_ekklesia.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
moduleId: "policy-invalid",
|
||||
entries: [
|
||||
{
|
||||
tag: "0000_initial",
|
||||
destructive: { mode: "none" },
|
||||
approved: "token-at-/machine/policy",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual([
|
||||
"MODULE_MIGRATION_JOURNAL_INVALID",
|
||||
"MODULE_MIGRATION_METADATA_INVALID",
|
||||
]);
|
||||
const report = `${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`;
|
||||
expect(report).not.toContain("postgres://");
|
||||
expect(report).not.toContain("password");
|
||||
expect(report).not.toContain("/machine/journal");
|
||||
expect(report).not.toContain("token-at-/machine/policy");
|
||||
expect(report).not.toContain(modulesDirectory);
|
||||
});
|
||||
|
||||
it("rejects duplicate identities, invalid ordering, and inconsistent destructive policy", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "inconsistent", {
|
||||
migrations: [
|
||||
{ sql: "SELECT 1;\n", tag: "0000_first" },
|
||||
{ sql: "SELECT 2;\n", tag: "0001_second" },
|
||||
],
|
||||
});
|
||||
const root = fixture.migrationDirectory ?? "";
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(root, "meta/_journal.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 1,
|
||||
version: "7",
|
||||
when: 200,
|
||||
tag: "0001_second",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
version: "7",
|
||||
when: 100,
|
||||
tag: "0001_second",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "meta/_ekklesia.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
moduleId: "wrong-module",
|
||||
entries: [
|
||||
{ tag: "0001_second", destructive: { mode: "none" } },
|
||||
{ tag: "0001_second", destructive: { mode: "none" } },
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MIGRATION_ID_DUPLICATE",
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
"MODULE_MIGRATION_ORDER_INVALID",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
issueCodes(error).filter((code) => code === "MODULE_MIGRATION_METADATA_INCONSISTENT"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
issueCodes(error).filter((code) => code === "MODULE_MIGRATION_ORDER_INVALID"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects unsafe journal tags before deriving or reading a migration path", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "unsafe-tag");
|
||||
const root = fixture.migrationDirectory ?? "";
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(root, "meta/_journal.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
version: "7",
|
||||
when: 1_700_000_000_000,
|
||||
tag: "../outside",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(fixture.moduleDirectory, "outside.sql"),
|
||||
"SELECT 'database-password at /machine/outside';\n",
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toContain("MODULE_MIGRATION_ID_INVALID");
|
||||
expect(error.issues).not.toContainEqual(expect.objectContaining({ path: "migrations.0.sql" }));
|
||||
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
|
||||
expect(report).not.toContain("../outside");
|
||||
expect(report).not.toContain("database-password");
|
||||
expect(report).not.toContain("/machine/outside");
|
||||
});
|
||||
|
||||
it("rejects missing, undeclared, unsupported, empty, and invalid UTF-8 migration files", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const missing = await createMigrationModule(modulesDirectory, "missing");
|
||||
const inventory = await createMigrationModule(modulesDirectory, "inventory");
|
||||
await createMigrationModule(modulesDirectory, "empty", {
|
||||
migrations: [{ sql: " \n\t", tag: "0000_initial" }],
|
||||
});
|
||||
await createMigrationModule(modulesDirectory, "invalid-utf8", {
|
||||
migrations: [{ sql: Uint8Array.from([0xff, 0xfe, 0xfd]), tag: "0000_initial" }],
|
||||
});
|
||||
await Promise.all([
|
||||
rm(path.join(missing.migrationDirectory ?? "", "0000_initial.sql")),
|
||||
writeFile(path.join(inventory.migrationDirectory ?? "", "0001_orphan.sql"), "SELECT 2;\n"),
|
||||
writeFile(path.join(inventory.migrationDirectory ?? "", "run.ts"), "throw new Error();\n"),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MIGRATION_EMPTY",
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
"MODULE_MIGRATION_FILE_MISSING",
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
"MODULE_MIGRATION_UNSUPPORTED_FILE",
|
||||
]),
|
||||
);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "missing")).toBe(true);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "inventory")).toBe(true);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "empty")).toBe(true);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "invalid-utf8")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing, broken, and undeclared Drizzle snapshots", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "snapshots", {
|
||||
migrations: [
|
||||
{ sql: "SELECT 1;\n", tag: "0000_first" },
|
||||
{ sql: "SELECT 2;\n", tag: "0001_second" },
|
||||
],
|
||||
});
|
||||
const missing = await createMigrationModule(modulesDirectory, "snapshot-missing");
|
||||
const root = fixture.migrationDirectory ?? "";
|
||||
await Promise.all([
|
||||
rm(path.join(missing.migrationDirectory ?? "", "meta/0000_snapshot.json")),
|
||||
writeFile(
|
||||
path.join(root, "meta/0001_snapshot.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: snapshotId(1),
|
||||
prevId: originSnapshotId,
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "meta/9999_snapshot.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
prevId: snapshotId(1),
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MIGRATION_FILE_MISSING",
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
"MODULE_MIGRATION_SNAPSHOT_INVALID",
|
||||
]),
|
||||
);
|
||||
expect(error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_MIGRATION_FILE_MISSING",
|
||||
moduleId: "snapshot-missing",
|
||||
path: "snapshots.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe migration symlinks and sanitizes the target path", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "linked");
|
||||
const outsideDirectory = await createModulesDirectory();
|
||||
const outsideSql = path.join(outsideDirectory, "database-password.sql");
|
||||
const migrationFile = path.join(fixture.migrationDirectory ?? "", "0000_initial.sql");
|
||||
await writeFile(outsideSql, "SELECT 'secret-at-/machine/private';\n");
|
||||
await rm(migrationFile);
|
||||
await symlink(outsideSql, migrationFile);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toContain("MODULE_MIGRATION_PATH_UNSAFE");
|
||||
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
|
||||
expect(report).not.toContain("database-password");
|
||||
expect(report).not.toContain("secret-at-/machine/private");
|
||||
expect(report).not.toContain(outsideDirectory);
|
||||
});
|
||||
|
||||
it("produces deterministic sanitized errors and no partial result when any module is invalid", async () => {
|
||||
const firstModulesDirectory = await createModulesDirectory();
|
||||
const secondModulesDirectory = await createModulesDirectory();
|
||||
for (const modulesDirectory of [firstModulesDirectory, secondModulesDirectory]) {
|
||||
await createMigrationModule(modulesDirectory, "valid");
|
||||
const broken = await createMigrationModule(modulesDirectory, "broken");
|
||||
await writeFile(
|
||||
path.join(broken.migrationDirectory ?? "", "meta/_journal.json"),
|
||||
'{"secret":"https://user:[email protected]/private","sql":"DROP TABLE private"',
|
||||
);
|
||||
}
|
||||
|
||||
const [firstError, secondError] = await Promise.all([
|
||||
captureMigrationError(firstModulesDirectory),
|
||||
captureMigrationError(secondModulesDirectory),
|
||||
]);
|
||||
|
||||
expect(firstError.issues).toEqual(secondError.issues);
|
||||
expect(firstError.stack).toBe(secondError.stack);
|
||||
expect(firstError.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "MODULE_MIGRATION_JOURNAL_INVALID",
|
||||
moduleId: "broken",
|
||||
path: "journal",
|
||||
}),
|
||||
]);
|
||||
const report = `${firstError.message}\n${firstError.stack}\n${JSON.stringify(firstError.issues)}`;
|
||||
expect(report).not.toContain("https://");
|
||||
expect(report).not.toContain("password");
|
||||
expect(report).not.toContain("DROP TABLE");
|
||||
expect(report).not.toContain(firstModulesDirectory);
|
||||
expect(Object.isFrozen(firstError.issues)).toBe(true);
|
||||
expect(Object.isFrozen(firstError.issues[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
# 0008: Module-owned migration contracts and offline validation
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-21
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0007 gives each module exclusive ownership of its namespaced Drizzle tables, but it does not
|
||||
define how those tables evolve. Ekklesia needs a cloneable module to carry its migration history
|
||||
without a central registry, and Core needs to inspect every installed history deterministically
|
||||
before a future runner is allowed to touch PostgreSQL.
|
||||
|
||||
Drizzle Kit already generates PostgreSQL SQL files, a journal, and schema snapshots. Replacing that
|
||||
format would separate module authors from supported Drizzle tooling. At the same time, Drizzle's
|
||||
journal does not express Ekklesia's destructive-change policy or module ownership boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
### Ownership and packaging
|
||||
|
||||
Each module owns its migration history and may create, alter, or remove only database objects that
|
||||
it owns. A dependency on another module does not grant permission to migrate that module's tables,
|
||||
indexes, constraints, sequences, types, or other private database objects. Cross-module foreign
|
||||
keys and imports of another module's private schema or migrations remain prohibited by ADR 0007.
|
||||
|
||||
A module with migrations declares the optional manifest field
|
||||
`entrypoints.migrations`, normally as `./drizzle`. It points to a packaged directory inside the
|
||||
module, not executable code. A module with no migrations omits the field. Static module discovery
|
||||
must validate the directory for every installed module before migration files are inspected. The
|
||||
directory is included in the module package so cloning a module into `modules/`, installing, and
|
||||
building remains sufficient; no central migration registry is introduced.
|
||||
|
||||
### Supported Drizzle format and workflow
|
||||
|
||||
This slice pins Drizzle Kit `0.31.10`, matching the stable pre-v1 format compatible with the
|
||||
repository's Drizzle ORM `0.45.2`. A module configures PostgreSQL, its source schema, an output
|
||||
directory, index-prefixed migrations, and statement breakpoints. Authors generate migrations with
|
||||
the module's `migration:generate` script, review the generated SQL and snapshots, add the required
|
||||
Ekklesia policy entry, and run `pnpm migrations:validate` from the repository root.
|
||||
|
||||
The accepted directory format is:
|
||||
|
||||
```text
|
||||
drizzle/
|
||||
├── 0000_initial.sql
|
||||
└── meta/
|
||||
├── 0000_snapshot.json
|
||||
├── _ekklesia.json
|
||||
└── _journal.json
|
||||
```
|
||||
|
||||
`_journal.json` and indexed snapshots are the native Drizzle Kit PostgreSQL version-7 metadata.
|
||||
Every journal entry has a corresponding `<tag>.sql` file and `<index>_snapshot.json`. Extra SQL or
|
||||
snapshot files and unsupported file types are invalid. Files and the `meta` directory must be real
|
||||
entries within the declared directory; symbolic links are rejected rather than followed.
|
||||
|
||||
Ekklesia adds strict `meta/_ekklesia.json` policy metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"moduleId": "example",
|
||||
"entries": [
|
||||
{
|
||||
"tag": "0000_initial",
|
||||
"destructive": { "mode": "none" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The sidecar has exactly one entry for each journal entry in the same order. A destructive migration
|
||||
uses `{"mode":"approval-required","reason":"..."}`. The source tree never records an
|
||||
`approved` boolean: a future approval record must be external to the module and bound to the
|
||||
module ID, migration identity, and checksum so editing the migration invalidates approval.
|
||||
|
||||
### Identity, checksums, and ordering
|
||||
|
||||
Within a module, `_journal.json` is authoritative for ordering. Indices start at zero, are
|
||||
contiguous, and match each indexed tag; timestamps strictly increase; snapshot IDs form the native
|
||||
Drizzle predecessor chain. The validator does not repair or re-sort an invalid journal.
|
||||
|
||||
Between modules, Core uses the dependency-first order produced by complete static module
|
||||
discovery, with its deterministic identifier tie-breaking. It then preserves journal order within
|
||||
each module.
|
||||
|
||||
A migration's stable identity is `<module ID>:<Drizzle tag>`, for example
|
||||
`example:0000_initial`. Core calculates a lowercase SHA-256 checksum from the exact SQL file bytes.
|
||||
Line endings, whitespace, breakpoint comments, and final newlines therefore affect the checksum.
|
||||
SQL must be non-empty valid UTF-8, but it is not normalized. The source metadata does not duplicate
|
||||
the calculated checksum. When execution exists, Core's applied database history is the authority
|
||||
for comparing the stored applied checksum with current packaged bytes and rejecting edits to an
|
||||
already-applied migration.
|
||||
|
||||
### Destructive changes and SQL limits
|
||||
|
||||
Migrations are forward-only for now. There are no downgrade or uninstall migrations. A migration
|
||||
declared `approval-required` must never be silently applied; offline validation reports that state,
|
||||
while the future runner will require an explicit external approval contract before execution.
|
||||
This slice neither grants approval nor executes SQL.
|
||||
|
||||
The ownership and destructive declarations are enforceable author contracts and review inputs,
|
||||
not claims that the offline validator can prove arbitrary SQL safe. SQL comments, quoted and
|
||||
schema-qualified identifiers, procedural bodies, dynamic SQL, extension behavior, and
|
||||
`search_path` changes make simplistic regular-expression classification incomplete and
|
||||
misleading. The validator deliberately treats SQL as opaque bytes and does not claim to detect
|
||||
cross-module access or destructive statements. Stronger enforcement, if added, requires a real
|
||||
PostgreSQL-aware model and must state its limits.
|
||||
|
||||
### Atomic validation and future execution
|
||||
|
||||
Offline migration discovery begins only after complete static module discovery succeeds. It opens
|
||||
no PostgreSQL connection, imports no schema or server entrypoint, and executes no SQL. It validates
|
||||
the full inventory, metadata, ordering, identities, paths, snapshots, policy, and checksums for all
|
||||
modules, then publishes one frozen aggregate. Any issue prevents publication of every module's
|
||||
result. Errors contain deterministic codes, module IDs, and logical metadata paths only; caught
|
||||
exception messages, SQL text, URLs, secrets, machine paths, causes, stacks, and partial results are
|
||||
not exposed.
|
||||
|
||||
Migration execution remains exclusively controlled by Core. A later ADR and slice will define the
|
||||
database history schema, pending/applied comparison, advisory locking, transaction boundaries,
|
||||
approval lookup, failure recovery, and execution semantics.
|
||||
|
||||
The selected format follows Drizzle Kit's stable
|
||||
[`_journal.json` and snapshot handling](https://github.com/drizzle-team/drizzle-orm/blob/drizzle-kit%400.31.10/drizzle-kit/src/utils.ts),
|
||||
its [migration generation flow](https://github.com/drizzle-team/drizzle-orm/blob/drizzle-kit%400.31.10/drizzle-kit/src/cli/commands/migrate.ts),
|
||||
and Drizzle ORM's [SQL reading and SHA-256 behavior](https://github.com/drizzle-team/drizzle-orm/blob/0.45.2/drizzle-orm/src/migrator.ts).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- A custom migration language was rejected because it would abandon supported Drizzle generation
|
||||
and metadata without improving runtime safety.
|
||||
- Expected checksums in packaged migration policy metadata were rejected because they duplicate the
|
||||
artifact being checked and do not prove whether an edited migration was already applied. Applied
|
||||
database history will provide that authority.
|
||||
- Timestamp-only or filename-only identity was rejected because independently installed modules
|
||||
can legitimately use the same generated tag and timestamp.
|
||||
- Lexicographically sorting migration files was rejected because Drizzle's journal is the native
|
||||
ordered history and must be internally consistent.
|
||||
- Automatically classifying SQL with regular expressions was rejected because it would imply an
|
||||
ownership and destructive-change guarantee the implementation cannot provide.
|
||||
- Module-controlled execution was rejected because ordering, history, locking, approval, and
|
||||
failure behavior are platform-wide invariants owned by Core.
|
||||
|
||||
## Consequences
|
||||
|
||||
Module authors commit generated SQL, native Drizzle metadata, and the Ekklesia policy sidecar. They
|
||||
must review ownership and destructive impact explicitly. Modules with no database history remain
|
||||
valid and need no placeholder directory. Offline validation gives CI a stable inventory and exact
|
||||
checksums without requiring PostgreSQL, but intentionally does not prove SQL semantics or predict
|
||||
whether a migration will succeed against a particular live database.
|
||||
@@ -16,3 +16,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded
|
||||
- [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted
|
||||
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
|
||||
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
|
||||
|
||||
@@ -63,8 +63,11 @@ static versioned manifests, independent capability versions and dependency range
|
||||
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
|
||||
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
|
||||
template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned
|
||||
Drizzle tables to a deterministic aggregate without connecting to a database.
|
||||
Drizzle tables to a deterministic aggregate without connecting to a database. Optional module-owned
|
||||
Drizzle migration directories now produce an atomic dependency-ordered offline inventory with
|
||||
validated metadata, explicit destructive-change declarations, and exact SHA-256 checksums.
|
||||
|
||||
Target architecture, not yet implemented: migration generation or execution, Better Auth,
|
||||
Target architecture, not yet implemented: transactional migration execution and applied history,
|
||||
Better Auth,
|
||||
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
||||
|
||||
@@ -42,7 +42,9 @@ common UI-framework dependencies inside modules. This deny list is defense in de
|
||||
review remains required when adopting a new frontend technology.
|
||||
|
||||
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
||||
targets, and physical table namespaces in compiled Drizzle metadata.
|
||||
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
|
||||
checks each module's packaged Drizzle inventory and policy contract, while deliberately making no
|
||||
claim that opaque SQL ownership can be proven without PostgreSQL-aware analysis.
|
||||
|
||||
## Generated code
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pnpm format:check # formatting without mutation
|
||||
pnpm lint # Biome recommended rules
|
||||
pnpm boundaries # architectural directions, UI ownership, and cycles
|
||||
pnpm modules:validate # installed module manifests, compatibility, and dependency graph
|
||||
pnpm migrations:validate # offline module migration inventory and exact checksums
|
||||
pnpm typecheck # strict TypeScript
|
||||
pnpm test # Vitest
|
||||
pnpm build # Turborepo package build graph
|
||||
@@ -35,6 +36,11 @@ must produce the compiled JavaScript entrypoints declared by their static manife
|
||||
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
|
||||
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
||||
|
||||
`pnpm migrations:validate` also builds first, completes static module discovery, and atomically
|
||||
checks every declared stable Drizzle migration directory. It reports dependency-first module
|
||||
inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or
|
||||
executing SQL. Modules with no migration history are valid.
|
||||
|
||||
CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate
|
||||
CI job runs the live infrastructure smoke test so code-only local verification does not require
|
||||
Docker. The explicit suffix avoids pnpm's built-in `ci` install command.
|
||||
|
||||
@@ -10,15 +10,24 @@ strict semantic version, and the module layer marker:
|
||||
|
||||
```text
|
||||
modules/example/
|
||||
├── drizzle/ # when the module has migration history
|
||||
│ ├── 0000_initial.sql
|
||||
│ └── meta/
|
||||
│ ├── 0000_snapshot.json
|
||||
│ ├── _ekklesia.json
|
||||
│ └── _journal.json
|
||||
├── dist/schema/index.js # when the module owns tables
|
||||
├── dist/server/index.js
|
||||
├── drizzle.config.ts # when generating migrations
|
||||
├── ekklesia.module.json
|
||||
├── package.json
|
||||
├── src/schema/index.ts # when the module owns tables
|
||||
├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit
|
||||
└── src/server/index.ts
|
||||
```
|
||||
|
||||
The schema paths are optional. A tableless module omits them and omits `entrypoints.schema`.
|
||||
The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a
|
||||
module with no migration history omits `entrypoints.migrations` and needs no placeholder files.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -79,7 +88,8 @@ without executing module code:
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js"
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -89,10 +99,11 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`,
|
||||
remain inside the package, and exist. Production never imports source TypeScript directly. Modules
|
||||
with no tables omit `entrypoints.schema`. Unknown manifest properties are rejected, including
|
||||
presentation fields such as components or renderers.
|
||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
||||
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
||||
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`. Unknown
|
||||
manifest properties are rejected, including presentation fields such as components or renderers.
|
||||
|
||||
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
||||
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
||||
@@ -135,18 +146,27 @@ close them in `stop`.
|
||||
|
||||
## Drizzle schema definition
|
||||
|
||||
A module with tables exports a declaration-only schema definition from `src/schema/index.ts`:
|
||||
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
|
||||
|
||||
```ts
|
||||
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { integer, text } from "drizzle-orm/pg-core";
|
||||
import { integer, pgTableCreator, text } from "drizzle-orm/pg-core";
|
||||
|
||||
const moduleTable = createModuleTable("calendar");
|
||||
const moduleTable = pgTableCreator((localName) => `calendar_${localName}`);
|
||||
|
||||
export const events = moduleTable("events", {
|
||||
id: integer("id").primaryKey(),
|
||||
title: text("title").notNull()
|
||||
});
|
||||
```
|
||||
|
||||
The production schema entrypoint in `src/schema/index.ts` wraps those same objects with the Module
|
||||
SDK ownership marker:
|
||||
|
||||
```ts
|
||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { events } from "./tables.js";
|
||||
|
||||
export { events } from "./tables.js";
|
||||
|
||||
export default defineModuleSchema({
|
||||
moduleId: "calendar",
|
||||
@@ -154,11 +174,12 @@ export default defineModuleSchema({
|
||||
});
|
||||
```
|
||||
|
||||
`createModuleTable` converts the module ID to a snake-case namespace and prefixes every local table
|
||||
The prefix callback converts the module ID to a snake-case namespace and prefixes every local table
|
||||
name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes
|
||||
`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier
|
||||
must not exceed 63 characters. Core validates the built metadata even when a module does not use
|
||||
the helper.
|
||||
must not exceed 63 characters. The SDK's `createModuleTable` provides the same convenience when a
|
||||
generator-facing pure declaration file is not needed; Core validates built metadata independently
|
||||
of either helper.
|
||||
|
||||
Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must
|
||||
be side-effect-free: do not connect to a database, read configuration, or perform I/O during import.
|
||||
@@ -171,6 +192,45 @@ and all columns in a compound target must belong to the same exported target tab
|
||||
never import another module's tables, columns, relations, schema entrypoint, or migration files.
|
||||
Store opaque identifiers and integrate through public capabilities, events, or APIs instead.
|
||||
|
||||
## Drizzle migrations
|
||||
|
||||
Module migrations use the stable Drizzle Kit `0.31.10` PostgreSQL format. Pin that exact version in
|
||||
the module, set `migrations.prefix` to `index`, and generate from the module's own source schema:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/schema/tables.ts",
|
||||
out: "./drizzle",
|
||||
migrations: { prefix: "index" },
|
||||
breakpoints: true
|
||||
});
|
||||
```
|
||||
|
||||
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
|
||||
executing the ESM Module SDK wrapper. `src/schema/index.ts` remains the production compiled schema
|
||||
entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not
|
||||
declared twice.
|
||||
|
||||
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
|
||||
explicitly declare either `{"mode":"none"}` or
|
||||
`{"mode":"approval-required","reason":"..."}`. Do not add an `approved` flag; approval will be
|
||||
an external Core-owned record bound to the migration checksum.
|
||||
|
||||
Migration identities are `<module-id>:<generated-tag>`. Core preserves Drizzle journal order inside
|
||||
each module and dependency-first discovery order between modules. It calculates SHA-256 from the
|
||||
exact SQL bytes, so changing whitespace or line endings changes the checksum. Never edit an
|
||||
already-applied migration; the future database history will detect checksum changes. Add a new
|
||||
forward migration instead. Downgrade and uninstall migrations are not supported.
|
||||
|
||||
A module may migrate only its own objects. SQL is intentionally treated as opaque: offline
|
||||
validation checks the artifact and metadata contract but cannot prove SQL ownership or classify
|
||||
destructive behavior. Review those properties explicitly. Core remains the only future migration
|
||||
executor.
|
||||
|
||||
## Discovery and lifecycle
|
||||
|
||||
From the repository root, run:
|
||||
@@ -178,6 +238,7 @@ From the repository root, run:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm modules:validate
|
||||
pnpm migrations:validate
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
@@ -193,6 +254,12 @@ dependency-first and rejects invalid definitions, unnamespaced or duplicate tabl
|
||||
PostgreSQL schemas, and foreign keys outside the owning module. The aggregate is published only when
|
||||
every schema is valid. This slice does not connect to PostgreSQL or execute migrations.
|
||||
|
||||
`pnpm migrations:validate` performs complete static discovery first, then reads every declared
|
||||
migration directory without importing module code or connecting to PostgreSQL. It validates native
|
||||
Drizzle journals and snapshot chains, Ekklesia policy metadata, ordering, inventory, paths, and
|
||||
checksums atomically. Its concise report lists every installed module and migration in deterministic
|
||||
order.
|
||||
|
||||
Core completes discovery before importing any server entrypoint. It then loads and verifies every
|
||||
runtime definition before running hooks, registers all modules dependency-first into an unpublished
|
||||
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later
|
||||
|
||||
+2
-1
@@ -22,13 +22,14 @@
|
||||
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
||||
"lint": "turbo run lint lint:root",
|
||||
"lint:root": "biome lint .",
|
||||
"migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js",
|
||||
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
||||
"test": "turbo run test test:root",
|
||||
"test:root": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "turbo run typecheck typecheck:root",
|
||||
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
||||
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm modules:validate && pnpm build"
|
||||
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm modules:validate && pnpm migrations:validate && pnpm build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./identifiers.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./module-migration.js";
|
||||
export * from "./module-runtime.js";
|
||||
export * from "./module-schema.js";
|
||||
export * from "./versions.js";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
|
||||
export const DRIZZLE_MIGRATION_DIALECT = "postgresql";
|
||||
export const DRIZZLE_MIGRATION_FORMAT_VERSION = "7";
|
||||
export const DRIZZLE_MIGRATION_META_DIRECTORY = "meta";
|
||||
export const DRIZZLE_MIGRATION_JOURNAL_FILENAME = "_journal.json";
|
||||
export const EKKLESIA_MIGRATION_POLICY_FILENAME = "_ekklesia.json";
|
||||
export const DRIZZLE_MIGRATION_ORIGIN_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
export const moduleMigrationTagSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.regex(
|
||||
/^\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u,
|
||||
"Must be an indexed lowercase Drizzle migration tag.",
|
||||
);
|
||||
|
||||
export const drizzleMigrationJournalEntrySchema = z
|
||||
.object({
|
||||
idx: z.number().int().safe().nonnegative(),
|
||||
version: z.literal(DRIZZLE_MIGRATION_FORMAT_VERSION),
|
||||
when: z.number().int().safe().positive(),
|
||||
tag: z.string().min(1).max(240),
|
||||
breakpoints: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const drizzleMigrationJournalSchema = z
|
||||
.object({
|
||||
version: z.literal(DRIZZLE_MIGRATION_FORMAT_VERSION),
|
||||
dialect: z.literal(DRIZZLE_MIGRATION_DIALECT),
|
||||
entries: z.array(drizzleMigrationJournalEntrySchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const drizzleMigrationSnapshotHeaderSchema = z
|
||||
.object({
|
||||
version: z.literal(DRIZZLE_MIGRATION_FORMAT_VERSION),
|
||||
dialect: z.literal(DRIZZLE_MIGRATION_DIALECT),
|
||||
id: z.uuid(),
|
||||
prevId: z.uuid(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const moduleMigrationDestructiveDeclarationSchema = z.discriminatedUnion("mode", [
|
||||
z.object({ mode: z.literal("none") }).strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("approval-required"),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const moduleMigrationPolicyEntrySchema = z
|
||||
.object({
|
||||
tag: moduleMigrationTagSchema,
|
||||
destructive: moduleMigrationDestructiveDeclarationSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const moduleMigrationPolicySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
moduleId: moduleIdSchema,
|
||||
entries: z.array(moduleMigrationPolicyEntrySchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export function parseDrizzleMigrationJournal(input: unknown): DrizzleMigrationJournal {
|
||||
return drizzleMigrationJournalSchema.parse(input);
|
||||
}
|
||||
|
||||
export function parseDrizzleMigrationSnapshotHeader(
|
||||
input: unknown,
|
||||
): DrizzleMigrationSnapshotHeader {
|
||||
return drizzleMigrationSnapshotHeaderSchema.parse(input);
|
||||
}
|
||||
|
||||
export function parseModuleMigrationPolicy(input: unknown): ModuleMigrationPolicy {
|
||||
return moduleMigrationPolicySchema.parse(input);
|
||||
}
|
||||
|
||||
export type DrizzleMigrationJournal = z.output<typeof drizzleMigrationJournalSchema>;
|
||||
export type DrizzleMigrationJournalEntry = z.output<typeof drizzleMigrationJournalEntrySchema>;
|
||||
export type DrizzleMigrationSnapshotHeader = z.output<typeof drizzleMigrationSnapshotHeaderSchema>;
|
||||
export type ModuleMigrationDestructiveDeclaration = z.output<
|
||||
typeof moduleMigrationDestructiveDeclarationSchema
|
||||
>;
|
||||
export type ModuleMigrationPolicy = z.output<typeof moduleMigrationPolicySchema>;
|
||||
export type ModuleMigrationPolicyEntry = z.output<typeof moduleMigrationPolicyEntrySchema>;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DRIZZLE_MIGRATION_ORIGIN_UUID,
|
||||
moduleMigrationTagSchema,
|
||||
parseDrizzleMigrationJournal,
|
||||
parseDrizzleMigrationSnapshotHeader,
|
||||
parseModuleMigrationPolicy,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("module migration contracts", () => {
|
||||
it("parses the supported stable Drizzle PostgreSQL journal format", () => {
|
||||
const journal = parseDrizzleMigrationJournal({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
version: "7",
|
||||
when: 1_750_000_000_000,
|
||||
tag: "0000_initial",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(journal.entries[0]?.tag).toBe("0000_initial");
|
||||
});
|
||||
|
||||
it("rejects unsupported journal versions, dialects, and unknown metadata", () => {
|
||||
const base = {
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
version: "7",
|
||||
when: 1,
|
||||
tag: "0000_initial",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => parseDrizzleMigrationJournal({ ...base, version: "8" })).toThrow();
|
||||
expect(() => parseDrizzleMigrationJournal({ ...base, dialect: "sqlite" })).toThrow();
|
||||
expect(() => parseDrizzleMigrationJournal({ ...base, extra: true })).toThrow();
|
||||
});
|
||||
|
||||
it("parses snapshot identity metadata while allowing Drizzle's schema payload", () => {
|
||||
const snapshot = parseDrizzleMigrationSnapshotHeader({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: "10000000-0000-4000-8000-000000000001",
|
||||
prevId: DRIZZLE_MIGRATION_ORIGIN_UUID,
|
||||
tables: {},
|
||||
_meta: { tables: {}, schemas: {}, columns: {} },
|
||||
});
|
||||
|
||||
expect(snapshot.prevId).toBe(DRIZZLE_MIGRATION_ORIGIN_UUID);
|
||||
});
|
||||
|
||||
it("requires explicit non-destructive or approval-required policy declarations", () => {
|
||||
expect(
|
||||
parseModuleMigrationPolicy({
|
||||
version: 1,
|
||||
moduleId: "calendar",
|
||||
entries: [
|
||||
{ tag: "0000_initial", destructive: { mode: "none" } },
|
||||
{
|
||||
tag: "0001_remove_legacy",
|
||||
destructive: { mode: "approval-required", reason: "Removes a legacy column." },
|
||||
},
|
||||
],
|
||||
}).entries,
|
||||
).toHaveLength(2);
|
||||
|
||||
expect(() =>
|
||||
parseModuleMigrationPolicy({
|
||||
version: 1,
|
||||
moduleId: "calendar",
|
||||
entries: [
|
||||
{
|
||||
tag: "0000_initial",
|
||||
destructive: { mode: "approval-required", reason: "Drops data.", approved: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../outside",
|
||||
"/absolute",
|
||||
"0000_nested/name",
|
||||
"0000_back\\slash",
|
||||
"0000_already.sql",
|
||||
"0000_Uppercase",
|
||||
])("rejects unsafe migration tag %s", (tag) => {
|
||||
expect(moduleMigrationTagSchema.safeParse(tag).success).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+856
-11
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -7,5 +7,5 @@ packages:
|
||||
- "surfaces/*"
|
||||
- "templates/*"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
@@ -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