From 88c28d75c9cb2a1a19ecbe4121be0e309db81714 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 21 Jul 2026 08:09:30 -0500 Subject: [PATCH] feat: add module migration contracts and validation --- README.md | 7 +- core/src/cli/migration-report.ts | 28 + core/src/cli/validate-migrations.ts | 56 ++ core/src/modules/index.ts | 1 + core/src/modules/migration-discovery.ts | 760 +++++++++++++++ core/tests/module-migration-discovery.test.ts | 646 +++++++++++++ .../0008-module-owned-migration-contracts.md | 151 +++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 7 +- docs/architecture/repository-boundaries.md | 4 +- docs/development/repository-workflow.md | 6 + docs/module-development/README.md | 93 +- package.json | 3 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/module-migration.ts | 93 ++ .../contracts/tests/module-migration.test.ts | 101 ++ pnpm-lock.yaml | 867 +++++++++++++++++- pnpm-workspace.yaml | 4 +- templates/module/README.md | 24 +- templates/module/drizzle.config.ts | 11 + templates/module/drizzle/0000_initial.sql | 4 + .../module/drizzle/meta/0000_snapshot.json | 44 + templates/module/drizzle/meta/_ekklesia.json | 12 + templates/module/drizzle/meta/_journal.json | 13 + templates/module/ekklesia.module.json | 3 +- templates/module/package.json | 3 + templates/module/src/schema/index.ts | 11 +- templates/module/src/schema/tables.ts | 8 + templates/module/tests/manifest.test.ts | 31 + templates/module/tsconfig.json | 2 +- 30 files changed, 2947 insertions(+), 48 deletions(-) create mode 100644 core/src/cli/migration-report.ts create mode 100644 core/src/cli/validate-migrations.ts create mode 100644 core/src/modules/migration-discovery.ts create mode 100644 core/tests/module-migration-discovery.test.ts create mode 100644 docs/architecture/decisions/0008-module-owned-migration-contracts.md create mode 100644 packages/contracts/src/module-migration.ts create mode 100644 packages/contracts/tests/module-migration.test.ts create mode 100644 templates/module/drizzle.config.ts create mode 100644 templates/module/drizzle/0000_initial.sql create mode 100644 templates/module/drizzle/meta/0000_snapshot.json create mode 100644 templates/module/drizzle/meta/_ekklesia.json create mode 100644 templates/module/drizzle/meta/_journal.json create mode 100644 templates/module/src/schema/tables.ts diff --git a/README.md b/README.md index db3b17a..3bfcf28 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/core/src/cli/migration-report.ts b/core/src/cli/migration-report.ts new file mode 100644 index 0000000..445ebf3 --- /dev/null +++ b/core/src/cli/migration-report.ts @@ -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); +} diff --git a/core/src/cli/validate-migrations.ts b/core/src/cli/validate-migrations.ts new file mode 100644 index 0000000..fec116f --- /dev/null +++ b/core/src/cli/validate-migrations.ts @@ -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 { + 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 { + 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; +}); diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 0fc5e5f..085b2e4 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,3 +1,4 @@ export * from "./discovery.js"; +export * from "./migration-discovery.js"; export * from "./runtime.js"; export * from "./schema-discovery.js"; diff --git a/core/src/modules/migration-discovery.ts b/core/src/modules/migration-discovery.ts new file mode 100644 index 0000000..c732585 --- /dev/null +++ b/core/src/modules/migration-discovery.ts @@ -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; + readonly rootEntries: ReadonlySet; +} + +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 { + 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 { + 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(); + 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(); + 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 { + const seenIds = new Set(); + 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 { + 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 { + 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 }); +} diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts new file mode 100644 index 0000000..3f22127 --- /dev/null +++ b/core/tests/module-migration-discovery.test.ts @@ -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; + readonly manifest?: Record; + 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 { + 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; + readonly migrations?: boolean; + readonly schema?: boolean; + } = {}, +): Record { + 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 { + 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 { + 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:password@example.test/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); + }); +}); diff --git a/docs/architecture/decisions/0008-module-owned-migration-contracts.md b/docs/architecture/decisions/0008-module-owned-migration-contracts.md new file mode 100644 index 0000000..5faec24 --- /dev/null +++ b/docs/architecture/decisions/0008-module-owned-migration-contracts.md @@ -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 `.sql` file and `_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 `:`, 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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index b18d55b..bd50312 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index aed06f5..c4b1207 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index ec53af3..0c265eb 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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 diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index b884021..972e333 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -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. diff --git a/docs/module-development/README.md b/docs/module-development/README.md index b981a06..74f9652 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 `:`. 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 diff --git a/package.json b/package.json index 62c3776..04086f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 298cb1c..91ff094 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/module-migration.ts b/packages/contracts/src/module-migration.ts new file mode 100644 index 0000000..bdf5621 --- /dev/null +++ b/packages/contracts/src/module-migration.ts @@ -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; +export type DrizzleMigrationJournalEntry = z.output; +export type DrizzleMigrationSnapshotHeader = z.output; +export type ModuleMigrationDestructiveDeclaration = z.output< + typeof moduleMigrationDestructiveDeclarationSchema +>; +export type ModuleMigrationPolicy = z.output; +export type ModuleMigrationPolicyEntry = z.output; diff --git a/packages/contracts/tests/module-migration.test.ts b/packages/contracts/tests/module-migration.test.ts new file mode 100644 index 0000000..f4ce20e --- /dev/null +++ b/packages/contracts/tests/module-migration.test.ts @@ -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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18d356d..4def1b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) yaml: specifier: 2.9.0 version: 2.9.0 @@ -47,7 +47,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) packages/contracts: dependencies: @@ -75,7 +75,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) packages/module-sdk: dependencies: @@ -97,7 +97,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) packages/typescript-config: {} @@ -119,12 +119,15 @@ importers: '@types/node': specifier: 24.13.3 version: 24.13.3 + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) packages: @@ -185,6 +188,9 @@ packages: cpu: [x64] os: [win32] + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -194,6 +200,458 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -508,6 +966,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -519,6 +980,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -614,6 +1079,21 @@ packages: es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -635,6 +1115,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -735,6 +1218,9 @@ packages: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -752,6 +1238,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -776,6 +1269,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.10.4: resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} hasBin: true @@ -922,6 +1420,8 @@ snapshots: '@biomejs/cli-win32-x64@2.5.3': optional: true + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -938,6 +1438,238 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -1109,13 +1841,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.4(@types/node@24.13.3)(yaml@2.9.0) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1143,16 +1875,108 @@ snapshots: assertion-error@2.0.1: {} + buffer-from@1.1.2: {} + chai@6.2.2: {} convert-source-map@2.0.0: {} detect-libc@2.1.2: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.1 + drizzle-orm@0.45.2: {} es-module-lexer@2.3.0: {} + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1166,6 +1990,10 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -1235,6 +2063,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + resolve-pkg-maps@1.0.0: {} + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -1262,6 +2092,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + stackback@0.0.2: {} std-env@4.2.0: {} @@ -1280,6 +2117,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + turbo@2.10.4: optionalDependencies: '@turbo/darwin-64': 2.10.4 @@ -1314,7 +2157,7 @@ snapshots: undici-types@7.18.2: {} - vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0): + vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -1323,13 +2166,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 + esbuild: 0.28.1 fsevents: 2.3.3 + tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)): + vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -1346,7 +2191,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@24.13.3)(yaml@2.9.0) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d17bab2..dd5d4d5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,5 +7,5 @@ packages: - "surfaces/*" - "templates/*" -onlyBuiltDependencies: - - esbuild +allowBuilds: + esbuild: true diff --git a/templates/module/README.md b/templates/module/README.md index ffbcaf8..1fb6950 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -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. diff --git a/templates/module/drizzle.config.ts b/templates/module/drizzle.config.ts new file mode 100644 index 0000000..9b92095 --- /dev/null +++ b/templates/module/drizzle.config.ts @@ -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, +}); diff --git a/templates/module/drizzle/0000_initial.sql b/templates/module/drizzle/0000_initial.sql new file mode 100644 index 0000000..aabc46a --- /dev/null +++ b/templates/module/drizzle/0000_initial.sql @@ -0,0 +1,4 @@ +CREATE TABLE "example_records" ( + "id" uuid PRIMARY KEY NOT NULL, + "value" text NOT NULL +); diff --git a/templates/module/drizzle/meta/0000_snapshot.json b/templates/module/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..84bb78d --- /dev/null +++ b/templates/module/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} diff --git a/templates/module/drizzle/meta/_ekklesia.json b/templates/module/drizzle/meta/_ekklesia.json new file mode 100644 index 0000000..3a0c035 --- /dev/null +++ b/templates/module/drizzle/meta/_ekklesia.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "moduleId": "example", + "entries": [ + { + "tag": "0000_initial", + "destructive": { + "mode": "none" + } + } + ] +} diff --git a/templates/module/drizzle/meta/_journal.json b/templates/module/drizzle/meta/_journal.json new file mode 100644 index 0000000..724d57a --- /dev/null +++ b/templates/module/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1784638608616, + "tag": "0000_initial", + "breakpoints": true + } + ] +} diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index ab86c9b..ccfe877 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -25,6 +25,7 @@ }, "entrypoints": { "server": "./dist/server/index.js", - "schema": "./dist/schema/index.js" + "schema": "./dist/schema/index.js", + "migrations": "./drizzle" } } diff --git a/templates/module/package.json b/templates/module/package.json index 5facaf6..36f56d3 100644 --- a/templates/module/package.json +++ b/templates/module/package.json @@ -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" }, diff --git a/templates/module/src/schema/index.ts b/templates/module/src/schema/index.ts index 511e3a8..5b42140 100644 --- a/templates/module/src/schema/index.ts +++ b/templates/module/src/schema/index.ts @@ -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", diff --git a/templates/module/src/schema/tables.ts b/templates/module/src/schema/tables.ts new file mode 100644 index 0000000..d6cef3e --- /dev/null +++ b/templates/module/src/schema/tables.ts @@ -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(), +}); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 54f3791..95ca89d 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -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; + 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 () => { diff --git a/templates/module/tsconfig.json b/templates/module/tsconfig.json index 837feb5..6df60d4 100644 --- a/templates/module/tsconfig.json +++ b/templates/module/tsconfig.json @@ -4,5 +4,5 @@ "noEmit": true, "types": ["node", "vitest/globals"] }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"] }