|
|
|
@@ -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 });
|
|
|
|
|
}
|