feat: add module migration contracts and validation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user