feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-21 08:09:30 -05:00
parent 37f54edd89
commit 88c28d75c9
30 changed files with 2947 additions and 48 deletions
+1
View File
@@ -1,6 +1,7 @@
export * from "./identifiers.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-migration.js";
export * from "./module-runtime.js";
export * from "./module-schema.js";
export * from "./versions.js";
@@ -0,0 +1,93 @@
import { z } from "zod";
import { moduleIdSchema } from "./identifiers.js";
export const DRIZZLE_MIGRATION_DIALECT = "postgresql";
export const DRIZZLE_MIGRATION_FORMAT_VERSION = "7";
export const DRIZZLE_MIGRATION_META_DIRECTORY = "meta";
export const DRIZZLE_MIGRATION_JOURNAL_FILENAME = "_journal.json";
export const EKKLESIA_MIGRATION_POLICY_FILENAME = "_ekklesia.json";
export const DRIZZLE_MIGRATION_ORIGIN_UUID = "00000000-0000-0000-0000-000000000000";
export const moduleMigrationTagSchema = z
.string()
.min(1)
.max(120)
.regex(
/^\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u,
"Must be an indexed lowercase Drizzle migration tag.",
);
export const drizzleMigrationJournalEntrySchema = z
.object({
idx: z.number().int().safe().nonnegative(),
version: z.literal(DRIZZLE_MIGRATION_FORMAT_VERSION),
when: z.number().int().safe().positive(),
tag: z.string().min(1).max(240),
breakpoints: z.boolean(),
})
.strict();
export const drizzleMigrationJournalSchema = z
.object({
version: z.literal(DRIZZLE_MIGRATION_FORMAT_VERSION),
dialect: z.literal(DRIZZLE_MIGRATION_DIALECT),
entries: z.array(drizzleMigrationJournalEntrySchema).min(1),
})
.strict();
export const drizzleMigrationSnapshotHeaderSchema = z
.object({
version: z.literal(DRIZZLE_MIGRATION_FORMAT_VERSION),
dialect: z.literal(DRIZZLE_MIGRATION_DIALECT),
id: z.uuid(),
prevId: z.uuid(),
})
.passthrough();
export const moduleMigrationDestructiveDeclarationSchema = z.discriminatedUnion("mode", [
z.object({ mode: z.literal("none") }).strict(),
z
.object({
mode: z.literal("approval-required"),
reason: z.string().trim().min(1).max(500),
})
.strict(),
]);
export const moduleMigrationPolicyEntrySchema = z
.object({
tag: moduleMigrationTagSchema,
destructive: moduleMigrationDestructiveDeclarationSchema,
})
.strict();
export const moduleMigrationPolicySchema = z
.object({
version: z.literal(1),
moduleId: moduleIdSchema,
entries: z.array(moduleMigrationPolicyEntrySchema).min(1),
})
.strict();
export function parseDrizzleMigrationJournal(input: unknown): DrizzleMigrationJournal {
return drizzleMigrationJournalSchema.parse(input);
}
export function parseDrizzleMigrationSnapshotHeader(
input: unknown,
): DrizzleMigrationSnapshotHeader {
return drizzleMigrationSnapshotHeaderSchema.parse(input);
}
export function parseModuleMigrationPolicy(input: unknown): ModuleMigrationPolicy {
return moduleMigrationPolicySchema.parse(input);
}
export type DrizzleMigrationJournal = z.output<typeof drizzleMigrationJournalSchema>;
export type DrizzleMigrationJournalEntry = z.output<typeof drizzleMigrationJournalEntrySchema>;
export type DrizzleMigrationSnapshotHeader = z.output<typeof drizzleMigrationSnapshotHeaderSchema>;
export type ModuleMigrationDestructiveDeclaration = z.output<
typeof moduleMigrationDestructiveDeclarationSchema
>;
export type ModuleMigrationPolicy = z.output<typeof moduleMigrationPolicySchema>;
export type ModuleMigrationPolicyEntry = z.output<typeof moduleMigrationPolicyEntrySchema>;
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
DRIZZLE_MIGRATION_ORIGIN_UUID,
moduleMigrationTagSchema,
parseDrizzleMigrationJournal,
parseDrizzleMigrationSnapshotHeader,
parseModuleMigrationPolicy,
} from "../src/index.js";
describe("module migration contracts", () => {
it("parses the supported stable Drizzle PostgreSQL journal format", () => {
const journal = parseDrizzleMigrationJournal({
version: "7",
dialect: "postgresql",
entries: [
{
idx: 0,
version: "7",
when: 1_750_000_000_000,
tag: "0000_initial",
breakpoints: true,
},
],
});
expect(journal.entries[0]?.tag).toBe("0000_initial");
});
it("rejects unsupported journal versions, dialects, and unknown metadata", () => {
const base = {
version: "7",
dialect: "postgresql",
entries: [
{
idx: 0,
version: "7",
when: 1,
tag: "0000_initial",
breakpoints: true,
},
],
};
expect(() => parseDrizzleMigrationJournal({ ...base, version: "8" })).toThrow();
expect(() => parseDrizzleMigrationJournal({ ...base, dialect: "sqlite" })).toThrow();
expect(() => parseDrizzleMigrationJournal({ ...base, extra: true })).toThrow();
});
it("parses snapshot identity metadata while allowing Drizzle's schema payload", () => {
const snapshot = parseDrizzleMigrationSnapshotHeader({
version: "7",
dialect: "postgresql",
id: "10000000-0000-4000-8000-000000000001",
prevId: DRIZZLE_MIGRATION_ORIGIN_UUID,
tables: {},
_meta: { tables: {}, schemas: {}, columns: {} },
});
expect(snapshot.prevId).toBe(DRIZZLE_MIGRATION_ORIGIN_UUID);
});
it("requires explicit non-destructive or approval-required policy declarations", () => {
expect(
parseModuleMigrationPolicy({
version: 1,
moduleId: "calendar",
entries: [
{ tag: "0000_initial", destructive: { mode: "none" } },
{
tag: "0001_remove_legacy",
destructive: { mode: "approval-required", reason: "Removes a legacy column." },
},
],
}).entries,
).toHaveLength(2);
expect(() =>
parseModuleMigrationPolicy({
version: 1,
moduleId: "calendar",
entries: [
{
tag: "0000_initial",
destructive: { mode: "approval-required", reason: "Drops data.", approved: true },
},
],
}),
).toThrow();
});
it.each([
"../outside",
"/absolute",
"0000_nested/name",
"0000_back\\slash",
"0000_already.sql",
"0000_Uppercase",
])("rejects unsafe migration tag %s", (tag) => {
expect(moduleMigrationTagSchema.safeParse(tag).success).toBe(false);
});
});