feat: add module migration contracts and validation
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||
|
||||
function plural(count: number, singular: string): string {
|
||||
return count === 1 ? singular : `${singular}s`;
|
||||
}
|
||||
|
||||
export function formatMigrationValidationReport(
|
||||
aggregate: ModuleMigrationAggregate,
|
||||
): readonly string[] {
|
||||
const moduleCount = aggregate.modules.length;
|
||||
const migrationCount = aggregate.migrations.length;
|
||||
const header = `Migration validation passed (${moduleCount} ${plural(moduleCount, "module")}, ${migrationCount} ${plural(migrationCount, "migration")})${moduleCount === 0 ? "." : ":"}`;
|
||||
const lines = [header];
|
||||
|
||||
for (const module of aggregate.modules) {
|
||||
if (module.migrations.length === 0) {
|
||||
lines.push(`- ${module.moduleId} (no migrations)`);
|
||||
continue;
|
||||
}
|
||||
for (const migration of module.migrations) {
|
||||
lines.push(
|
||||
`- ${migration.identity} sha256=${migration.checksum} destructive=${migration.destructive.mode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatMigrationValidationReport } from "./migration-report.js";
|
||||
import {
|
||||
discoverModuleMigrations,
|
||||
ModuleDiscoveryError,
|
||||
ModuleMigrationDiscoveryError,
|
||||
} from "../modules/index.js";
|
||||
|
||||
interface PackageVersion {
|
||||
version?: unknown;
|
||||
}
|
||||
|
||||
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
|
||||
if (typeof packageData.version !== "string") {
|
||||
throw new TypeError("A required workspace package does not declare a version.");
|
||||
}
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const repositoryRoot = process.cwd();
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
]);
|
||||
const aggregate = await discoverModuleMigrations({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion,
|
||||
});
|
||||
|
||||
for (const line of formatMigrationValidationReport(aggregate)) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
console.error(error.message);
|
||||
for (const issue of error.issues) {
|
||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
|
||||
}
|
||||
} else if (error instanceof ModuleMigrationDiscoveryError) {
|
||||
console.error(error.message);
|
||||
for (const issue of error.issues) {
|
||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
|
||||
}
|
||||
} else {
|
||||
console.error("Migration validation failed unexpectedly.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./discovery.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
DRIZZLE_MIGRATION_JOURNAL_FILENAME,
|
||||
DRIZZLE_MIGRATION_META_DIRECTORY,
|
||||
DRIZZLE_MIGRATION_ORIGIN_UUID,
|
||||
EKKLESIA_MIGRATION_POLICY_FILENAME,
|
||||
type DrizzleMigrationJournal,
|
||||
type DrizzleMigrationSnapshotHeader,
|
||||
type ModuleId,
|
||||
type ModuleMigrationDestructiveDeclaration,
|
||||
type ModuleMigrationPolicy,
|
||||
moduleMigrationTagSchema,
|
||||
parseDrizzleMigrationJournal,
|
||||
parseDrizzleMigrationSnapshotHeader,
|
||||
parseModuleMigrationPolicy,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
} from "./discovery.js";
|
||||
|
||||
export type ModuleMigrationDiscoveryIssueCode =
|
||||
| "MODULE_MIGRATION_CHECKSUM_INVALID"
|
||||
| "MODULE_MIGRATION_DIRECTORY_INVALID"
|
||||
| "MODULE_MIGRATION_EMPTY"
|
||||
| "MODULE_MIGRATION_FILE_INVALID"
|
||||
| "MODULE_MIGRATION_FILE_MISSING"
|
||||
| "MODULE_MIGRATION_FILE_UNDECLARED"
|
||||
| "MODULE_MIGRATION_ID_DUPLICATE"
|
||||
| "MODULE_MIGRATION_ID_INVALID"
|
||||
| "MODULE_MIGRATION_JOURNAL_INVALID"
|
||||
| "MODULE_MIGRATION_METADATA_INCONSISTENT"
|
||||
| "MODULE_MIGRATION_METADATA_INVALID"
|
||||
| "MODULE_MIGRATION_ORDER_INVALID"
|
||||
| "MODULE_MIGRATION_PATH_UNSAFE"
|
||||
| "MODULE_MIGRATION_SNAPSHOT_INVALID"
|
||||
| "MODULE_MIGRATION_UNSUPPORTED_FILE";
|
||||
|
||||
export interface ModuleMigrationDiscoveryIssue {
|
||||
readonly code: ModuleMigrationDiscoveryIssueCode;
|
||||
readonly message: string;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredModuleMigration {
|
||||
readonly approvalRequired: boolean;
|
||||
readonly breakpoints: boolean;
|
||||
readonly checksum: string;
|
||||
readonly destructive: ModuleMigrationDestructiveDeclaration;
|
||||
readonly file: string;
|
||||
readonly identity: string;
|
||||
readonly index: number;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly sql: string;
|
||||
readonly tag: string;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredModuleMigrations {
|
||||
readonly migrations: readonly DiscoveredModuleMigration[];
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
export interface ModuleMigrationAggregate {
|
||||
readonly discoveredModules: readonly DiscoveredModule[];
|
||||
readonly migrations: readonly DiscoveredModuleMigration[];
|
||||
readonly modules: readonly DiscoveredModuleMigrations[];
|
||||
}
|
||||
|
||||
interface DirectoryInventory {
|
||||
readonly metaEntries: ReadonlySet<string>;
|
||||
readonly rootEntries: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const sqlFilenamePattern = /^\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.sql$/u;
|
||||
const snapshotFilenamePattern = /^\d{4,}_snapshot\.json$/u;
|
||||
|
||||
export class ModuleMigrationDiscoveryError extends Error {
|
||||
readonly issues: readonly ModuleMigrationDiscoveryIssue[];
|
||||
|
||||
constructor(issues: readonly ModuleMigrationDiscoveryIssue[]) {
|
||||
const sortedIssues = [...issues].sort(compareIssues);
|
||||
super(`Module migration discovery failed with ${sortedIssues.length} issue(s).`);
|
||||
this.name = "ModuleMigrationDiscoveryError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
this.issues = Object.freeze(sortedIssues.map((issue) => Object.freeze({ ...issue })));
|
||||
}
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function compareIssues(
|
||||
left: ModuleMigrationDiscoveryIssue,
|
||||
right: ModuleMigrationDiscoveryIssue,
|
||||
): number {
|
||||
return (
|
||||
compareText(left.moduleId, right.moduleId) ||
|
||||
compareText(left.path ?? "", right.path ?? "") ||
|
||||
compareText(left.code, right.code) ||
|
||||
compareText(left.message, right.message)
|
||||
);
|
||||
}
|
||||
|
||||
function issue(
|
||||
code: ModuleMigrationDiscoveryIssueCode,
|
||||
moduleId: ModuleId,
|
||||
message: string,
|
||||
pathValue?: string,
|
||||
): ModuleMigrationDiscoveryIssue {
|
||||
return { code, message, moduleId, ...(pathValue ? { path: pathValue } : {}) };
|
||||
}
|
||||
|
||||
function pathIsInside(parent: string, candidate: string): boolean {
|
||||
const relative = path.relative(parent, candidate);
|
||||
return (
|
||||
relative !== "" &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== ".." &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
async function readSafeFile(
|
||||
root: string,
|
||||
relativePath: string,
|
||||
moduleId: ModuleId,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
logicalPath: string,
|
||||
): Promise<Uint8Array | undefined> {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
if (!pathIsInside(root, absolutePath)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata contains an unsafe path.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStats = await lstat(absolutePath);
|
||||
if (fileStats.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration files must not be symbolic links.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (!fileStats.isFile()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration inventory contains a non-file entry.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const canonicalPath = await realpath(absolutePath);
|
||||
if (!pathIsInside(root, canonicalPath)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration files must remain inside the migration directory.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return await readFile(canonicalPath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
issues.push(
|
||||
issue(
|
||||
code === "ENOENT" ? "MODULE_MIGRATION_FILE_MISSING" : "MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
code === "ENOENT"
|
||||
? `Module ${moduleId} is missing a required migration file.`
|
||||
: `Module ${moduleId} migration file could not be read.`,
|
||||
logicalPath,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readInventory(
|
||||
root: string,
|
||||
moduleId: ModuleId,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): Promise<DirectoryInventory | undefined> {
|
||||
let rootEntries: Dirent[];
|
||||
try {
|
||||
rootEntries = (await readdir(root, { withFileTypes: true })).sort((left, right) =>
|
||||
compareText(left.name, right.name),
|
||||
);
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_DIRECTORY_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration directory could not be inspected.`,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rootNames = new Set(rootEntries.map((entry) => entry.name));
|
||||
rootEntries.forEach((entry, index) => {
|
||||
if (entry.name === DRIZZLE_MIGRATION_META_DIRECTORY) {
|
||||
return;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration files must not be symbolic links.`,
|
||||
`files.${index}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sqlFilenamePattern.test(entry.name)) {
|
||||
if (!entry.isFile()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration inventory contains a non-file entry.`,
|
||||
`files.${index}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_UNSUPPORTED_FILE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration directory contains an unsupported entry.`,
|
||||
`files.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const metaPath = path.join(root, DRIZZLE_MIGRATION_META_DIRECTORY);
|
||||
try {
|
||||
const metaStats = await lstat(metaPath);
|
||||
if (metaStats.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata directory must not be a symbolic link.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
if (!metaStats.isDirectory()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_DIRECTORY_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata entry must be a directory.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
const canonicalMetaPath = await realpath(metaPath);
|
||||
if (!pathIsInside(root, canonicalMetaPath)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata must remain inside the migration directory.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
const metaEntries = (await readdir(canonicalMetaPath, { withFileTypes: true })).sort(
|
||||
(left, right) => compareText(left.name, right.name),
|
||||
);
|
||||
const metaNames = new Set(metaEntries.map((entry) => entry.name));
|
||||
metaEntries.forEach((entry, index) => {
|
||||
if (entry.isSymbolicLink()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_PATH_UNSAFE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata files must not be symbolic links.`,
|
||||
`meta.files.${index}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
entry.name === DRIZZLE_MIGRATION_JOURNAL_FILENAME ||
|
||||
entry.name === EKKLESIA_MIGRATION_POLICY_FILENAME ||
|
||||
snapshotFilenamePattern.test(entry.name)
|
||||
) {
|
||||
if (!entry.isFile()) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata contains a non-file entry.`,
|
||||
`meta.files.${index}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_UNSUPPORTED_FILE",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata contains an unsupported entry.`,
|
||||
`meta.files.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return { metaEntries: metaNames, rootEntries: rootNames };
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_DIRECTORY_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration metadata directory could not be inspected.`,
|
||||
"meta",
|
||||
),
|
||||
);
|
||||
return { metaEntries: new Set(), rootEntries: rootNames };
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Uint8Array): unknown {
|
||||
return JSON.parse(utf8Decoder.decode(bytes)) as unknown;
|
||||
}
|
||||
|
||||
function validateJournalOrdering(
|
||||
moduleId: ModuleId,
|
||||
journal: DrizzleMigrationJournal,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): void {
|
||||
const seenTags = new Set<string>();
|
||||
let previousTimestamp = -1;
|
||||
|
||||
journal.entries.forEach((entry, position) => {
|
||||
const entryPath = `journal.entries.${position}`;
|
||||
if (entry.idx !== position) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ORDER_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration indices must be contiguous and journal-ordered.`,
|
||||
`${entryPath}.idx`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!moduleMigrationTagSchema.safeParse(entry.tag).success) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ID_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains an invalid migration identity.`,
|
||||
`${entryPath}.tag`,
|
||||
),
|
||||
);
|
||||
} else if (!entry.tag.startsWith(`${entry.idx.toString().padStart(4, "0")}_`)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ID_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration identities must match their journal indices.`,
|
||||
`${entryPath}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (seenTags.has(entry.tag)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ID_DUPLICATE",
|
||||
moduleId,
|
||||
`Module ${moduleId} declares the same migration identity more than once.`,
|
||||
`${entryPath}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
seenTags.add(entry.tag);
|
||||
if (entry.when <= previousTimestamp) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_ORDER_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration timestamps must increase with journal order.`,
|
||||
`${entryPath}.when`,
|
||||
),
|
||||
);
|
||||
}
|
||||
previousTimestamp = entry.when;
|
||||
});
|
||||
}
|
||||
|
||||
function validatePolicy(
|
||||
moduleId: ModuleId,
|
||||
journal: DrizzleMigrationJournal,
|
||||
policy: ModuleMigrationPolicy,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): void {
|
||||
if (policy.moduleId !== moduleId) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy does not match its manifest ID.`,
|
||||
"policy.moduleId",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (policy.entries.length !== journal.entries.length) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy must match the journal inventory.`,
|
||||
"policy.entries",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const seenTags = new Set<string>();
|
||||
policy.entries.forEach((entry, index) => {
|
||||
if (seenTags.has(entry.tag)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy contains a duplicate entry.`,
|
||||
`policy.entries.${index}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
seenTags.add(entry.tag);
|
||||
if (entry.tag !== journal.entries[index]?.tag) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration policy must preserve journal identity and order.`,
|
||||
`policy.entries.${index}.tag`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function validateSnapshots(
|
||||
root: string,
|
||||
moduleId: ModuleId,
|
||||
journal: DrizzleMigrationJournal,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): Promise<void> {
|
||||
const seenIds = new Set<string>();
|
||||
let previousId: string | undefined = DRIZZLE_MIGRATION_ORIGIN_UUID;
|
||||
|
||||
for (const [position, entry] of journal.entries.entries()) {
|
||||
const prefix = entry.idx.toString().padStart(4, "0");
|
||||
const bytes = await readSafeFile(
|
||||
root,
|
||||
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${prefix}_snapshot.json`,
|
||||
moduleId,
|
||||
issues,
|
||||
`snapshots.${position}`,
|
||||
);
|
||||
if (!bytes) {
|
||||
previousId = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
let snapshot: DrizzleMigrationSnapshotHeader;
|
||||
try {
|
||||
snapshot = parseDrizzleMigrationSnapshotHeader(parseJson(bytes));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_SNAPSHOT_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains invalid Drizzle snapshot metadata.`,
|
||||
`snapshots.${position}`,
|
||||
),
|
||||
);
|
||||
previousId = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
snapshot.id === DRIZZLE_MIGRATION_ORIGIN_UUID ||
|
||||
seenIds.has(snapshot.id) ||
|
||||
(previousId !== undefined && snapshot.prevId !== previousId)
|
||||
) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_SNAPSHOT_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} Drizzle snapshots must form one ordered identity chain.`,
|
||||
`snapshots.${position}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
seenIds.add(snapshot.id);
|
||||
previousId = snapshot.id;
|
||||
}
|
||||
}
|
||||
|
||||
function validateInventory(
|
||||
moduleId: ModuleId,
|
||||
inventory: DirectoryInventory,
|
||||
journal: DrizzleMigrationJournal,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): void {
|
||||
const expectedSql = new Set(
|
||||
journal.entries
|
||||
.filter((entry) => moduleMigrationTagSchema.safeParse(entry.tag).success)
|
||||
.map((entry) => `${entry.tag}.sql`),
|
||||
);
|
||||
const expectedSnapshots = new Set(
|
||||
journal.entries.map((entry) => `${entry.idx.toString().padStart(4, "0")}_snapshot.json`),
|
||||
);
|
||||
|
||||
const undeclaredSql = [...inventory.rootEntries]
|
||||
.filter((name) => sqlFilenamePattern.test(name) && !expectedSql.has(name))
|
||||
.sort(compareText);
|
||||
undeclaredSql.forEach((_name, index) => {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains a SQL migration absent from its journal.`,
|
||||
`undeclaredSql.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const undeclaredSnapshots = [...inventory.metaEntries]
|
||||
.filter((name) => snapshotFilenamePattern.test(name) && !expectedSnapshots.has(name))
|
||||
.sort(compareText);
|
||||
undeclaredSnapshots.forEach((_name, index) => {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
moduleId,
|
||||
`Module ${moduleId} contains a snapshot absent from its journal.`,
|
||||
`undeclaredSnapshots.${index}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectModuleMigrations(
|
||||
discovered: DiscoveredModule,
|
||||
issues: ModuleMigrationDiscoveryIssue[],
|
||||
): Promise<readonly DiscoveredModuleMigration[]> {
|
||||
const moduleId = discovered.manifest.id;
|
||||
const migrationRoot = discovered.entrypoints.migrations;
|
||||
if (!migrationRoot) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
|
||||
const issueCountBeforeModule = issues.length;
|
||||
const inventory = await readInventory(migrationRoot, moduleId, issues);
|
||||
const [journalBytes, policyBytes] = await Promise.all([
|
||||
readSafeFile(
|
||||
migrationRoot,
|
||||
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${DRIZZLE_MIGRATION_JOURNAL_FILENAME}`,
|
||||
moduleId,
|
||||
issues,
|
||||
"journal",
|
||||
),
|
||||
readSafeFile(
|
||||
migrationRoot,
|
||||
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${EKKLESIA_MIGRATION_POLICY_FILENAME}`,
|
||||
moduleId,
|
||||
issues,
|
||||
"policy",
|
||||
),
|
||||
]);
|
||||
|
||||
let journal: DrizzleMigrationJournal | undefined;
|
||||
if (journalBytes) {
|
||||
try {
|
||||
journal = parseDrizzleMigrationJournal(parseJson(journalBytes));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_JOURNAL_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} has invalid Drizzle migration journal metadata.`,
|
||||
"journal",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let policy: ModuleMigrationPolicy | undefined;
|
||||
if (policyBytes) {
|
||||
try {
|
||||
policy = parseModuleMigrationPolicy(parseJson(policyBytes));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_METADATA_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} has invalid migration policy metadata.`,
|
||||
"policy",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!journal) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
|
||||
validateJournalOrdering(moduleId, journal, issues);
|
||||
if (policy) {
|
||||
validatePolicy(moduleId, journal, policy, issues);
|
||||
}
|
||||
if (inventory) {
|
||||
validateInventory(moduleId, inventory, journal, issues);
|
||||
}
|
||||
await validateSnapshots(migrationRoot, moduleId, journal, issues);
|
||||
|
||||
const migrations: DiscoveredModuleMigration[] = [];
|
||||
for (const [position, entry] of journal.entries.entries()) {
|
||||
if (!moduleMigrationTagSchema.safeParse(entry.tag).success) {
|
||||
continue;
|
||||
}
|
||||
const sqlBytes = await readSafeFile(
|
||||
migrationRoot,
|
||||
`${entry.tag}.sql`,
|
||||
moduleId,
|
||||
issues,
|
||||
`migrations.${position}.sql`,
|
||||
);
|
||||
if (!sqlBytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const checksum = createHash("sha256").update(sqlBytes).digest("hex");
|
||||
if (!/^[a-f0-9]{64}$/u.test(checksum)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_CHECKSUM_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} migration checksum could not be calculated.`,
|
||||
`migrations.${position}.checksum`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql: string;
|
||||
try {
|
||||
sql = utf8Decoder.decode(sqlBytes);
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} SQL migrations must be valid UTF-8 text.`,
|
||||
`migrations.${position}.sql`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (sql.trim().length === 0) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_MIGRATION_EMPTY",
|
||||
moduleId,
|
||||
`Module ${moduleId} SQL migrations must not be empty.`,
|
||||
`migrations.${position}.sql`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const policyEntry = policy?.entries[position];
|
||||
if (!policyEntry || policyEntry.tag !== entry.tag) {
|
||||
continue;
|
||||
}
|
||||
const { destructive } = policyEntry;
|
||||
migrations.push(
|
||||
Object.freeze({
|
||||
approvalRequired: destructive.mode === "approval-required",
|
||||
breakpoints: entry.breakpoints,
|
||||
checksum,
|
||||
destructive: Object.freeze({ ...destructive }),
|
||||
file: `${entry.tag}.sql`,
|
||||
identity: `${moduleId}:${entry.tag}`,
|
||||
index: entry.idx,
|
||||
moduleId,
|
||||
sql,
|
||||
tag: entry.tag,
|
||||
timestamp: entry.when,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length > issueCountBeforeModule) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
return Object.freeze(migrations);
|
||||
}
|
||||
|
||||
export async function discoverModuleMigrations(
|
||||
options: ModuleDiscoveryOptions,
|
||||
): Promise<ModuleMigrationAggregate> {
|
||||
const discoveredModules = await discoverModules(options);
|
||||
const issues: ModuleMigrationDiscoveryIssue[] = [];
|
||||
const modules: DiscoveredModuleMigrations[] = [];
|
||||
|
||||
for (const discovered of discoveredModules) {
|
||||
modules.push(
|
||||
Object.freeze({
|
||||
migrations: await inspectModuleMigrations(discovered, issues),
|
||||
moduleId: discovered.manifest.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleMigrationDiscoveryError(issues);
|
||||
}
|
||||
|
||||
const frozenModules = Object.freeze(modules);
|
||||
const migrations = Object.freeze(frozenModules.flatMap((module) => module.migrations));
|
||||
return Object.freeze({ discoveredModules, migrations, modules: frozenModules });
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { formatMigrationValidationReport } from "../src/cli/migration-report.js";
|
||||
import {
|
||||
discoverModuleMigrations,
|
||||
ModuleDiscoveryError,
|
||||
ModuleMigrationDiscoveryError,
|
||||
type ModuleMigrationDiscoveryIssueCode,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
interface MigrationFixture {
|
||||
readonly breakpoints?: boolean;
|
||||
readonly destructive?:
|
||||
| { readonly mode: "none" }
|
||||
| { readonly mode: "approval-required"; readonly reason: string };
|
||||
readonly snapshotId?: string;
|
||||
readonly sql: string | Uint8Array;
|
||||
readonly tag: string;
|
||||
readonly timestamp?: number;
|
||||
}
|
||||
|
||||
interface MigrationModuleFixture {
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly manifest?: Record<string, unknown>;
|
||||
readonly migrations?: readonly MigrationFixture[] | false;
|
||||
readonly schemaSource?: string;
|
||||
readonly serverSource?: string;
|
||||
}
|
||||
|
||||
interface CreatedMigrationModule {
|
||||
readonly migrationDirectory?: string;
|
||||
readonly moduleDirectory: string;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function createModulesDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-migrations-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function snapshotId(index: number): string {
|
||||
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
function manifestFor(
|
||||
id: string,
|
||||
options: {
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly migrations?: boolean;
|
||||
readonly schema?: boolean;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
|
||||
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMigrations(id: string): readonly MigrationFixture[] {
|
||||
return [
|
||||
{
|
||||
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
||||
tag: "0000_initial",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function createMigrationModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
fixture: MigrationModuleFixture = {},
|
||||
): Promise<CreatedMigrationModule> {
|
||||
const migrations =
|
||||
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
if (fixture.schemaSource !== undefined) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
|
||||
}
|
||||
if (migrationDirectory) {
|
||||
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify(
|
||||
fixture.manifest ??
|
||||
manifestFor(id, {
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
migrations: migrations !== undefined,
|
||||
schema: fixture.schemaSource !== undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||
),
|
||||
...(fixture.schemaSource === undefined
|
||||
? []
|
||||
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
|
||||
]);
|
||||
|
||||
if (!migrationDirectory || !migrations) {
|
||||
return { moduleDirectory };
|
||||
}
|
||||
|
||||
const journalEntries = migrations.map((migration, index) => ({
|
||||
idx: index,
|
||||
version: "7",
|
||||
when: migration.timestamp ?? 1_700_000_000_000 + index,
|
||||
tag: migration.tag,
|
||||
breakpoints: migration.breakpoints ?? true,
|
||||
}));
|
||||
const policyEntries = migrations.map((migration) => ({
|
||||
tag: migration.tag,
|
||||
destructive: migration.destructive ?? { mode: "none" },
|
||||
}));
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(migrationDirectory, "meta/_journal.json"),
|
||||
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
|
||||
),
|
||||
writeFile(
|
||||
path.join(migrationDirectory, "meta/_ekklesia.json"),
|
||||
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
|
||||
),
|
||||
...migrations.flatMap((migration, index) => {
|
||||
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
|
||||
const previousId =
|
||||
index === 0
|
||||
? originSnapshotId
|
||||
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
|
||||
return [
|
||||
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
|
||||
writeFile(
|
||||
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: idForSnapshot,
|
||||
prevId: previousId,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}),
|
||||
]);
|
||||
|
||||
return { migrationDirectory, moduleDirectory };
|
||||
}
|
||||
|
||||
function discoveryOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
};
|
||||
}
|
||||
|
||||
async function captureMigrationError(
|
||||
modulesDirectory: string,
|
||||
): Promise<ModuleMigrationDiscoveryError> {
|
||||
try {
|
||||
await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleMigrationDiscoveryError) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected module migration discovery to fail.");
|
||||
}
|
||||
|
||||
function issueCodes(error: ModuleMigrationDiscoveryError): ModuleMigrationDiscoveryIssueCode[] {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
function sha256(contents: string | Uint8Array): string {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
describe("module migration discovery", () => {
|
||||
it("aggregates dependency-first modules and journal-ordered migrations with an exact report", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const peopleInitial = 'CREATE TABLE "people_records" ("id" integer);\n';
|
||||
const peopleArchive = new TextEncoder().encode(
|
||||
'ALTER TABLE "people_records" ADD COLUMN "archived" boolean;\r\n',
|
||||
);
|
||||
const calendarEvents = 'CREATE TABLE "calendar_events" ("id" integer);\n';
|
||||
await createMigrationModule(modulesDirectory, "calendar", {
|
||||
dependencies: { modules: { required: { people: "^0.1.0" } } },
|
||||
migrations: [{ sql: calendarEvents, tag: "0000_events" }],
|
||||
});
|
||||
await createMigrationModule(modulesDirectory, "people", {
|
||||
migrations: [
|
||||
{ sql: peopleInitial, tag: "0000_initial" },
|
||||
{
|
||||
destructive: {
|
||||
mode: "approval-required",
|
||||
reason: "Existing deployments must approve the archive-column rollout.",
|
||||
},
|
||||
sql: peopleArchive,
|
||||
tag: "0001_archive",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["people", "calendar"]);
|
||||
expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([
|
||||
"people:0000_initial",
|
||||
"people:0001_archive",
|
||||
"calendar:0000_events",
|
||||
]);
|
||||
expect(aggregate.migrations.map((migration) => migration.checksum)).toEqual([
|
||||
sha256(peopleInitial),
|
||||
sha256(peopleArchive),
|
||||
sha256(calendarEvents),
|
||||
]);
|
||||
expect(aggregate.migrations[1]).toMatchObject({
|
||||
approvalRequired: true,
|
||||
breakpoints: true,
|
||||
destructive: {
|
||||
mode: "approval-required",
|
||||
reason: "Existing deployments must approve the archive-column rollout.",
|
||||
},
|
||||
file: "0001_archive.sql",
|
||||
index: 1,
|
||||
timestamp: 1_700_000_000_001,
|
||||
});
|
||||
expect(formatMigrationValidationReport(aggregate)).toEqual([
|
||||
"Migration validation passed (2 modules, 3 migrations):",
|
||||
`- people:0000_initial sha256=${sha256(peopleInitial)} destructive=none`,
|
||||
`- people:0001_archive sha256=${sha256(peopleArchive)} destructive=approval-required`,
|
||||
`- calendar:0000_events sha256=${sha256(calendarEvents)} destructive=none`,
|
||||
]);
|
||||
expect(Object.isFrozen(aggregate)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.modules)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.modules[0])).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.migrations)).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.migrations[0])).toBe(true);
|
||||
expect(Object.isFrozen(aggregate.migrations[1]?.destructive)).toBe(true);
|
||||
expect(Object.isFrozen(formatMigrationValidationReport(aggregate))).toBe(true);
|
||||
});
|
||||
|
||||
it("hashes exact SQL bytes and changes only the checksum when content changes", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const original = new TextEncoder().encode("SELECT 1;\n");
|
||||
const changed = new TextEncoder().encode("SELECT 1;\r\n");
|
||||
const fixture = await createMigrationModule(modulesDirectory, "exact", {
|
||||
migrations: [{ sql: original, tag: "0000_initial" }],
|
||||
});
|
||||
|
||||
const first = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
expect(first.migrations[0]).toMatchObject({
|
||||
checksum: sha256(original),
|
||||
identity: "exact:0000_initial",
|
||||
sql: "SELECT 1;\n",
|
||||
});
|
||||
|
||||
await writeFile(path.join(fixture.migrationDirectory ?? "", "0000_initial.sql"), changed);
|
||||
const second = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(second.migrations[0]?.identity).toBe(first.migrations[0]?.identity);
|
||||
expect(second.migrations[0]?.checksum).toBe(sha256(changed));
|
||||
expect(second.migrations[0]?.checksum).not.toBe(first.migrations[0]?.checksum);
|
||||
});
|
||||
|
||||
it("supports modules without migrations without importing server or schema code", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createMigrationModule(modulesDirectory, "schema-only", {
|
||||
migrations: false,
|
||||
schemaSource: 'throw new Error("schema entrypoint must remain unloaded");',
|
||||
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
|
||||
});
|
||||
await createMigrationModule(modulesDirectory, "server-only", {
|
||||
migrations: false,
|
||||
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.discoveredModules).toHaveLength(2);
|
||||
expect(aggregate.modules).toEqual([
|
||||
{ migrations: [], moduleId: "schema-only" },
|
||||
{ migrations: [], moduleId: "server-only" },
|
||||
]);
|
||||
expect(aggregate.migrations).toEqual([]);
|
||||
expect(formatMigrationValidationReport(aggregate)).toEqual([
|
||||
"Migration validation passed (2 modules, 0 migrations):",
|
||||
"- schema-only (no migrations)",
|
||||
"- server-only (no migrations)",
|
||||
]);
|
||||
expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true);
|
||||
});
|
||||
|
||||
it("completes static discovery before inspecting migration contents", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const valid = await createMigrationModule(modulesDirectory, "migration-invalid");
|
||||
await writeFile(
|
||||
path.join(valid.migrationDirectory ?? "", "meta/_journal.json"),
|
||||
'{"secret":"postgres://admin:password@database/private"',
|
||||
);
|
||||
await createMigrationModule(modulesDirectory, "static-invalid", {
|
||||
manifest: {
|
||||
...manifestFor("static-invalid", { migrations: false }),
|
||||
entrypoints: { server: "./dist/server/missing.js" },
|
||||
},
|
||||
migrations: false,
|
||||
});
|
||||
|
||||
let discoveryError: ModuleDiscoveryError | undefined;
|
||||
try {
|
||||
await discoverModuleMigrations(discoveryOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
discoveryError = error;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(discoveryError?.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_MISSING",
|
||||
moduleId: "static-invalid",
|
||||
path: "entrypoints.server",
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(discoveryError)).not.toContain("postgres://");
|
||||
});
|
||||
|
||||
it("rejects malformed journal and policy metadata without leaking their contents", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const journalInvalid = await createMigrationModule(modulesDirectory, "journal-invalid");
|
||||
const policyInvalid = await createMigrationModule(modulesDirectory, "policy-invalid");
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(journalInvalid.migrationDirectory ?? "", "meta/_journal.json"),
|
||||
'{"url":"postgres://admin:password@database/private","path":"/machine/journal"',
|
||||
),
|
||||
writeFile(
|
||||
path.join(policyInvalid.migrationDirectory ?? "", "meta/_ekklesia.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
moduleId: "policy-invalid",
|
||||
entries: [
|
||||
{
|
||||
tag: "0000_initial",
|
||||
destructive: { mode: "none" },
|
||||
approved: "token-at-/machine/policy",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual([
|
||||
"MODULE_MIGRATION_JOURNAL_INVALID",
|
||||
"MODULE_MIGRATION_METADATA_INVALID",
|
||||
]);
|
||||
const report = `${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`;
|
||||
expect(report).not.toContain("postgres://");
|
||||
expect(report).not.toContain("password");
|
||||
expect(report).not.toContain("/machine/journal");
|
||||
expect(report).not.toContain("token-at-/machine/policy");
|
||||
expect(report).not.toContain(modulesDirectory);
|
||||
});
|
||||
|
||||
it("rejects duplicate identities, invalid ordering, and inconsistent destructive policy", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "inconsistent", {
|
||||
migrations: [
|
||||
{ sql: "SELECT 1;\n", tag: "0000_first" },
|
||||
{ sql: "SELECT 2;\n", tag: "0001_second" },
|
||||
],
|
||||
});
|
||||
const root = fixture.migrationDirectory ?? "";
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(root, "meta/_journal.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 1,
|
||||
version: "7",
|
||||
when: 200,
|
||||
tag: "0001_second",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
version: "7",
|
||||
when: 100,
|
||||
tag: "0001_second",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "meta/_ekklesia.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
moduleId: "wrong-module",
|
||||
entries: [
|
||||
{ tag: "0001_second", destructive: { mode: "none" } },
|
||||
{ tag: "0001_second", destructive: { mode: "none" } },
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MIGRATION_ID_DUPLICATE",
|
||||
"MODULE_MIGRATION_METADATA_INCONSISTENT",
|
||||
"MODULE_MIGRATION_ORDER_INVALID",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
issueCodes(error).filter((code) => code === "MODULE_MIGRATION_METADATA_INCONSISTENT"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
issueCodes(error).filter((code) => code === "MODULE_MIGRATION_ORDER_INVALID"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects unsafe journal tags before deriving or reading a migration path", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "unsafe-tag");
|
||||
const root = fixture.migrationDirectory ?? "";
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(root, "meta/_journal.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
version: "7",
|
||||
when: 1_700_000_000_000,
|
||||
tag: "../outside",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(fixture.moduleDirectory, "outside.sql"),
|
||||
"SELECT 'database-password at /machine/outside';\n",
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toContain("MODULE_MIGRATION_ID_INVALID");
|
||||
expect(error.issues).not.toContainEqual(expect.objectContaining({ path: "migrations.0.sql" }));
|
||||
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
|
||||
expect(report).not.toContain("../outside");
|
||||
expect(report).not.toContain("database-password");
|
||||
expect(report).not.toContain("/machine/outside");
|
||||
});
|
||||
|
||||
it("rejects missing, undeclared, unsupported, empty, and invalid UTF-8 migration files", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const missing = await createMigrationModule(modulesDirectory, "missing");
|
||||
const inventory = await createMigrationModule(modulesDirectory, "inventory");
|
||||
await createMigrationModule(modulesDirectory, "empty", {
|
||||
migrations: [{ sql: " \n\t", tag: "0000_initial" }],
|
||||
});
|
||||
await createMigrationModule(modulesDirectory, "invalid-utf8", {
|
||||
migrations: [{ sql: Uint8Array.from([0xff, 0xfe, 0xfd]), tag: "0000_initial" }],
|
||||
});
|
||||
await Promise.all([
|
||||
rm(path.join(missing.migrationDirectory ?? "", "0000_initial.sql")),
|
||||
writeFile(path.join(inventory.migrationDirectory ?? "", "0001_orphan.sql"), "SELECT 2;\n"),
|
||||
writeFile(path.join(inventory.migrationDirectory ?? "", "run.ts"), "throw new Error();\n"),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MIGRATION_EMPTY",
|
||||
"MODULE_MIGRATION_FILE_INVALID",
|
||||
"MODULE_MIGRATION_FILE_MISSING",
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
"MODULE_MIGRATION_UNSUPPORTED_FILE",
|
||||
]),
|
||||
);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "missing")).toBe(true);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "inventory")).toBe(true);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "empty")).toBe(true);
|
||||
expect(error.issues.some((issue) => issue.moduleId === "invalid-utf8")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing, broken, and undeclared Drizzle snapshots", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "snapshots", {
|
||||
migrations: [
|
||||
{ sql: "SELECT 1;\n", tag: "0000_first" },
|
||||
{ sql: "SELECT 2;\n", tag: "0001_second" },
|
||||
],
|
||||
});
|
||||
const missing = await createMigrationModule(modulesDirectory, "snapshot-missing");
|
||||
const root = fixture.migrationDirectory ?? "";
|
||||
await Promise.all([
|
||||
rm(path.join(missing.migrationDirectory ?? "", "meta/0000_snapshot.json")),
|
||||
writeFile(
|
||||
path.join(root, "meta/0001_snapshot.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: snapshotId(1),
|
||||
prevId: originSnapshotId,
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "meta/9999_snapshot.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
prevId: snapshotId(1),
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MIGRATION_FILE_MISSING",
|
||||
"MODULE_MIGRATION_FILE_UNDECLARED",
|
||||
"MODULE_MIGRATION_SNAPSHOT_INVALID",
|
||||
]),
|
||||
);
|
||||
expect(error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_MIGRATION_FILE_MISSING",
|
||||
moduleId: "snapshot-missing",
|
||||
path: "snapshots.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe migration symlinks and sanitizes the target path", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const fixture = await createMigrationModule(modulesDirectory, "linked");
|
||||
const outsideDirectory = await createModulesDirectory();
|
||||
const outsideSql = path.join(outsideDirectory, "database-password.sql");
|
||||
const migrationFile = path.join(fixture.migrationDirectory ?? "", "0000_initial.sql");
|
||||
await writeFile(outsideSql, "SELECT 'secret-at-/machine/private';\n");
|
||||
await rm(migrationFile);
|
||||
await symlink(outsideSql, migrationFile);
|
||||
|
||||
const error = await captureMigrationError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toContain("MODULE_MIGRATION_PATH_UNSAFE");
|
||||
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
|
||||
expect(report).not.toContain("database-password");
|
||||
expect(report).not.toContain("secret-at-/machine/private");
|
||||
expect(report).not.toContain(outsideDirectory);
|
||||
});
|
||||
|
||||
it("produces deterministic sanitized errors and no partial result when any module is invalid", async () => {
|
||||
const firstModulesDirectory = await createModulesDirectory();
|
||||
const secondModulesDirectory = await createModulesDirectory();
|
||||
for (const modulesDirectory of [firstModulesDirectory, secondModulesDirectory]) {
|
||||
await createMigrationModule(modulesDirectory, "valid");
|
||||
const broken = await createMigrationModule(modulesDirectory, "broken");
|
||||
await writeFile(
|
||||
path.join(broken.migrationDirectory ?? "", "meta/_journal.json"),
|
||||
'{"secret":"https://user:[email protected]/private","sql":"DROP TABLE private"',
|
||||
);
|
||||
}
|
||||
|
||||
const [firstError, secondError] = await Promise.all([
|
||||
captureMigrationError(firstModulesDirectory),
|
||||
captureMigrationError(secondModulesDirectory),
|
||||
]);
|
||||
|
||||
expect(firstError.issues).toEqual(secondError.issues);
|
||||
expect(firstError.stack).toBe(secondError.stack);
|
||||
expect(firstError.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "MODULE_MIGRATION_JOURNAL_INVALID",
|
||||
moduleId: "broken",
|
||||
path: "journal",
|
||||
}),
|
||||
]);
|
||||
const report = `${firstError.message}\n${firstError.stack}\n${JSON.stringify(firstError.issues)}`;
|
||||
expect(report).not.toContain("https://");
|
||||
expect(report).not.toContain("password");
|
||||
expect(report).not.toContain("DROP TABLE");
|
||||
expect(report).not.toContain(firstModulesDirectory);
|
||||
expect(Object.isFrozen(firstError.issues)).toBe(true);
|
||||
expect(Object.isFrozen(firstError.issues[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user