@voyant-travel/framework-migrations 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The framework-shipped standard-profile migration bundle (D.1). Loads this
3
+ * package's `migrations/` folder as the `framework` collector source (priority
4
+ * 0 — applied before the deployment's own migrations).
5
+ *
6
+ * Generated by `scripts/generate-framework-migration-bundle.mjs` from the
7
+ * standard-profile aggregate schema. See `docs/architecture/migration-collector-d1.md`.
8
+ */
9
+ import type { MigrationSource } from "./collector.js";
10
+ /** Absolute path to the shipped bundle folder (resolved relative to this module). */
11
+ export declare function frameworkBundleDir(): string;
12
+ /**
13
+ * The framework bundle as a collector source. `priority: 0` so it applies
14
+ * before deployment migrations (their link tables FK into framework tables).
15
+ */
16
+ export declare function loadFrameworkBundleSource(dir?: string): Promise<MigrationSource>;
17
+ //# sourceMappingURL=bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../src/bundle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAGrD,qFAAqF;AACrF,wBAAgB,kBAAkB,IAAI,MAAM,CAG3C;AAED;;;GAGG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,GAAE,MAA6B,GACjC,OAAO,CAAC,eAAe,CAAC,CAM1B"}
package/dist/bundle.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The framework-shipped standard-profile migration bundle (D.1). Loads this
3
+ * package's `migrations/` folder as the `framework` collector source (priority
4
+ * 0 — applied before the deployment's own migrations).
5
+ *
6
+ * Generated by `scripts/generate-framework-migration-bundle.mjs` from the
7
+ * standard-profile aggregate schema. See `docs/architecture/migration-collector-d1.md`.
8
+ */
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { loadMigrationFolder } from "./load-folder.js";
12
+ /** Absolute path to the shipped bundle folder (resolved relative to this module). */
13
+ export function frameworkBundleDir() {
14
+ // src/bundle.ts → package root → migrations/ (dist/bundle.js → package root → migrations/)
15
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "migrations");
16
+ }
17
+ /**
18
+ * The framework bundle as a collector source. `priority: 0` so it applies
19
+ * before deployment migrations (their link tables FK into framework tables).
20
+ */
21
+ export async function loadFrameworkBundleSource(dir = frameworkBundleDir()) {
22
+ return {
23
+ name: "framework",
24
+ priority: 0,
25
+ migrations: await loadMigrationFolder(dir),
26
+ };
27
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bundle.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle.test.d.ts","sourceRoot":"","sources":["../src/bundle.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { frameworkBundleDir, loadFrameworkBundleSource } from "./bundle.js";
3
+ describe("framework bundle", () => {
4
+ it("loads the shipped bundle as the framework collector source", async () => {
5
+ const source = await loadFrameworkBundleSource();
6
+ expect(source.name).toBe("framework");
7
+ // priority 0 — applies before deployment migrations (their links FK into
8
+ // framework tables).
9
+ expect(source.priority).toBe(0);
10
+ expect(source.migrations.length).toBeGreaterThan(0);
11
+ // The frozen baseline is first.
12
+ expect(source.migrations[0]?.tag).toBe("0000_framework_baseline");
13
+ expect(source.migrations[0]?.sql).toContain("CREATE TABLE");
14
+ });
15
+ it("resolves a bundle dir ending in /migrations", () => {
16
+ expect(frameworkBundleDir().endsWith("/migrations")).toBe(true);
17
+ });
18
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Multi-source migration collector (consolidated-deployments RFC, Workstream
3
+ * D.1). Applies migrations from ordered sources — the framework-shipped bundle
4
+ * first, then the deployment's own migrations — recording each in a single
5
+ * version-independent ledger keyed by `(source, tag, content_hash)`.
6
+ *
7
+ * Properties (validated in `spikes/d1-migration-collector` + the integration
8
+ * tests): framework-first ordering, idempotent re-runs, an upgrade applies only
9
+ * the new migrations, and a shipped migration is immutable once applied (a
10
+ * content-hash change is a hard error). See `docs/architecture/migration-collector-d1.md`.
11
+ */
12
+ /** One migration: a tag unique within its source + raw SQL. */
13
+ export interface MigrationStatement {
14
+ /** Unique-within-source tag, e.g. `0001_init`. */
15
+ tag: string;
16
+ /** Raw SQL; drizzle `--> statement-breakpoint` separators are honored. */
17
+ sql: string;
18
+ }
19
+ /** An ordered migration source (e.g. the framework bundle, or the deployment). */
20
+ export interface MigrationSource {
21
+ /** Name recorded in the ledger, e.g. `framework` / `deployment`. */
22
+ name: string;
23
+ /**
24
+ * Apply order across sources within a run (lower first). Framework < deployment
25
+ * is load-bearing — deployment link tables FK into framework tables.
26
+ */
27
+ priority: number;
28
+ /** Migrations in their in-source apply order. */
29
+ migrations: MigrationStatement[];
30
+ }
31
+ export interface PlannedMigration {
32
+ source: string;
33
+ tag: string;
34
+ sql: string;
35
+ /** sha256 of the raw SQL (immutability key). */
36
+ contentHash: string;
37
+ }
38
+ /** Minimal pg-compatible client (keeps this package free of a `pg` dependency). */
39
+ export interface MigrationClient {
40
+ query(sql: string, params?: unknown[]): Promise<{
41
+ rows: Array<Record<string, unknown>>;
42
+ }>;
43
+ }
44
+ export interface ApplyMigrationsOptions {
45
+ /** Ledger schema (default `drizzle`). */
46
+ ledgerSchema?: string;
47
+ /** Ledger table (default `_voyant_migrations`). */
48
+ ledgerTable?: string;
49
+ /** Called with `"{source}/{tag}"` after each migration is applied. */
50
+ onApplied?: (id: string) => void;
51
+ }
52
+ /** Thrown when an already-applied `(source, tag)` arrives with changed SQL. */
53
+ export declare class MigrationImmutabilityError extends Error {
54
+ readonly source: string;
55
+ readonly tag: string;
56
+ readonly ledgerHash: string;
57
+ readonly currentHash: string;
58
+ constructor(source: string, tag: string, ledgerHash: string, currentHash: string);
59
+ }
60
+ /**
61
+ * Deterministic apply order across sources: `(source.priority, in-source index)`.
62
+ * Mutating a source's `migrations` order is significant — keep them append-only.
63
+ */
64
+ export declare function planMigrations(sources: MigrationSource[]): PlannedMigration[];
65
+ /**
66
+ * Baseline an EXISTING deployment onto the collector ledger: record every
67
+ * planned migration as already-applied **without executing its SQL** — for a DB
68
+ * whose schema already matches `sources` (materialised by the legacy runner +
69
+ * `drizzle-kit push`). Idempotent (`ON CONFLICT DO NOTHING`); the caller MUST
70
+ * have verified schema parity first, since this asserts "the schema is already
71
+ * here" without checking. Returns the `"{source}/{tag}"` ids newly recorded.
72
+ *
73
+ * See the cutover section of docs/architecture/migration-collector-d1.md.
74
+ */
75
+ export declare function importBaseline(client: MigrationClient, sources: MigrationSource[], options?: ApplyMigrationsOptions): Promise<string[]>;
76
+ /**
77
+ * Apply every pending migration across `sources` in plan order, recording each
78
+ * in the ledger. Idempotent (already-applied identical migrations are skipped);
79
+ * throws {@link MigrationImmutabilityError} if an applied migration's SQL
80
+ * changed. Each migration + its ledger row commit atomically. Returns the list
81
+ * of `"{source}/{tag}"` applied this run, in apply order.
82
+ */
83
+ export declare function applyMigrations(client: MigrationClient, sources: MigrationSource[], options?: ApplyMigrationsOptions): Promise<string[]>;
84
+ //# sourceMappingURL=collector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collector.d.ts","sourceRoot":"","sources":["../src/collector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,+DAA+D;AAC/D,MAAM,WAAW,kBAAkB;IACjC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAA;IACX,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,UAAU,EAAE,kBAAkB,EAAE,CAAA;CACjC;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,gDAAgD;IAChD,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,mFAAmF;AACnF,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;KAAE,CAAC,CAAA;CAC1F;AAED,MAAM,WAAW,sBAAsB;IACrC,yCAAyC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CACjC;AAED,+EAA+E;AAC/E,qBAAa,0BAA2B,SAAQ,KAAK;IAEjD,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,QAAQ,CAAC,UAAU,EAAE,MAAM;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM;gBAHnB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM;CAS/B;AAYD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,GAAG,gBAAgB,EAAE,CAc7E;AA4BD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,eAAe,EACvB,OAAO,EAAE,eAAe,EAAE,EAC1B,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,MAAM,EAAE,CAAC,CAkBnB;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,eAAe,EACvB,OAAO,EAAE,eAAe,EAAE,EAC1B,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,MAAM,EAAE,CAAC,CAsCnB"}
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Multi-source migration collector (consolidated-deployments RFC, Workstream
3
+ * D.1). Applies migrations from ordered sources — the framework-shipped bundle
4
+ * first, then the deployment's own migrations — recording each in a single
5
+ * version-independent ledger keyed by `(source, tag, content_hash)`.
6
+ *
7
+ * Properties (validated in `spikes/d1-migration-collector` + the integration
8
+ * tests): framework-first ordering, idempotent re-runs, an upgrade applies only
9
+ * the new migrations, and a shipped migration is immutable once applied (a
10
+ * content-hash change is a hard error). See `docs/architecture/migration-collector-d1.md`.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ /** Thrown when an already-applied `(source, tag)` arrives with changed SQL. */
14
+ export class MigrationImmutabilityError extends Error {
15
+ source;
16
+ tag;
17
+ ledgerHash;
18
+ currentHash;
19
+ constructor(source, tag, ledgerHash, currentHash) {
20
+ super(`migration immutability violation: ${source}/${tag} changed after it was applied ` +
21
+ `(ledger ${ledgerHash.slice(0, 12)}… != current ${currentHash.slice(0, 12)}…). ` +
22
+ "Shipped migrations are immutable — add a new migration instead of editing an applied one.");
23
+ this.source = source;
24
+ this.tag = tag;
25
+ this.ledgerHash = ledgerHash;
26
+ this.currentHash = currentHash;
27
+ this.name = "MigrationImmutabilityError";
28
+ }
29
+ }
30
+ const contentHash = (sql) => createHash("sha256").update(sql).digest("hex");
31
+ /** Split a drizzle migration's SQL into individual statements. */
32
+ function splitStatements(sql) {
33
+ return sql
34
+ .split("--> statement-breakpoint")
35
+ .map((s) => s.trim())
36
+ .filter(Boolean);
37
+ }
38
+ /**
39
+ * Deterministic apply order across sources: `(source.priority, in-source index)`.
40
+ * Mutating a source's `migrations` order is significant — keep them append-only.
41
+ */
42
+ export function planMigrations(sources) {
43
+ return sources
44
+ .flatMap((source) => source.migrations.map((m, seq) => ({
45
+ source: source.name,
46
+ priority: source.priority,
47
+ seq,
48
+ tag: m.tag,
49
+ sql: m.sql,
50
+ contentHash: contentHash(m.sql),
51
+ })))
52
+ .sort((a, b) => a.priority - b.priority || a.seq - b.seq)
53
+ .map(({ source, tag, sql, contentHash: hash }) => ({ source, tag, sql, contentHash: hash }));
54
+ }
55
+ function qualifiedLedger(options) {
56
+ const schema = options?.ledgerSchema ?? "drizzle";
57
+ const table = options?.ledgerTable ?? "_voyant_migrations";
58
+ return `"${schema}"."${table}"`;
59
+ }
60
+ /** Create the ledger schema + table if absent. Shared by apply + baseline. */
61
+ async function ensureLedger(client, options) {
62
+ const ledger = qualifiedLedger(options);
63
+ const schema = options?.ledgerSchema ?? "drizzle";
64
+ await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
65
+ await client.query(`CREATE TABLE IF NOT EXISTS ${ledger} (
66
+ "source" text NOT NULL,
67
+ "tag" text NOT NULL,
68
+ "content_hash" text NOT NULL,
69
+ "applied_at" timestamptz NOT NULL DEFAULT now(),
70
+ PRIMARY KEY ("source", "tag")
71
+ )`);
72
+ return ledger;
73
+ }
74
+ /**
75
+ * Baseline an EXISTING deployment onto the collector ledger: record every
76
+ * planned migration as already-applied **without executing its SQL** — for a DB
77
+ * whose schema already matches `sources` (materialised by the legacy runner +
78
+ * `drizzle-kit push`). Idempotent (`ON CONFLICT DO NOTHING`); the caller MUST
79
+ * have verified schema parity first, since this asserts "the schema is already
80
+ * here" without checking. Returns the `"{source}/{tag}"` ids newly recorded.
81
+ *
82
+ * See the cutover section of docs/architecture/migration-collector-d1.md.
83
+ */
84
+ export async function importBaseline(client, sources, options) {
85
+ const ledger = await ensureLedger(client, options);
86
+ const imported = [];
87
+ for (const m of planMigrations(sources)) {
88
+ const res = await client.query(`INSERT INTO ${ledger} ("source", "tag", "content_hash") VALUES ($1, $2, $3)
89
+ ON CONFLICT ("source", "tag") DO NOTHING`, [m.source, m.tag, m.contentHash]);
90
+ // node-postgres exposes rowCount; treat absent (0/undefined) as "already there".
91
+ const inserted = res.rowCount ?? 0;
92
+ if (inserted > 0) {
93
+ const id = `${m.source}/${m.tag}`;
94
+ imported.push(id);
95
+ options?.onApplied?.(id);
96
+ }
97
+ }
98
+ return imported;
99
+ }
100
+ /**
101
+ * Apply every pending migration across `sources` in plan order, recording each
102
+ * in the ledger. Idempotent (already-applied identical migrations are skipped);
103
+ * throws {@link MigrationImmutabilityError} if an applied migration's SQL
104
+ * changed. Each migration + its ledger row commit atomically. Returns the list
105
+ * of `"{source}/{tag}"` applied this run, in apply order.
106
+ */
107
+ export async function applyMigrations(client, sources, options) {
108
+ const ledger = await ensureLedger(client, options);
109
+ const applied = [];
110
+ for (const m of planMigrations(sources)) {
111
+ const seen = await client.query(`SELECT "content_hash" FROM ${ledger} WHERE "source" = $1 AND "tag" = $2`, [m.source, m.tag]);
112
+ if (seen.rows.length > 0) {
113
+ const ledgerHash = String(seen.rows[0]?.content_hash ?? "");
114
+ if (ledgerHash !== m.contentHash) {
115
+ throw new MigrationImmutabilityError(m.source, m.tag, ledgerHash, m.contentHash);
116
+ }
117
+ continue; // already applied, identical → no-op
118
+ }
119
+ await client.query("BEGIN");
120
+ try {
121
+ for (const statement of splitStatements(m.sql)) {
122
+ await client.query(statement);
123
+ }
124
+ await client.query(`INSERT INTO ${ledger} ("source", "tag", "content_hash") VALUES ($1, $2, $3)`, [m.source, m.tag, m.contentHash]);
125
+ await client.query("COMMIT");
126
+ }
127
+ catch (error) {
128
+ await client.query("ROLLBACK");
129
+ throw error;
130
+ }
131
+ const id = `${m.source}/${m.tag}`;
132
+ applied.push(id);
133
+ options?.onApplied?.(id);
134
+ }
135
+ return applied;
136
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=collector.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collector.test.d.ts","sourceRoot":"","sources":["../src/collector.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,167 @@
1
+ import { Client } from "pg";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
+ import { applyMigrations, importBaseline, MigrationImmutabilityError, planMigrations, } from "./collector.js";
4
+ const DB_URL = process.env.TEST_DATABASE_URL;
5
+ const SCHEMA = "voyant_fwmig_test";
6
+ // ---- planMigrations: pure, no DB --------------------------------------------
7
+ describe("planMigrations", () => {
8
+ it("orders by (source priority, in-source sequence)", () => {
9
+ const plan = planMigrations([
10
+ { name: "deployment", priority: 1, migrations: [{ tag: "d0", sql: "select 1" }] },
11
+ {
12
+ name: "framework",
13
+ priority: 0,
14
+ migrations: [
15
+ { tag: "f0", sql: "select 1" },
16
+ { tag: "f1", sql: "select 2" },
17
+ ],
18
+ },
19
+ ]);
20
+ expect(plan.map((p) => `${p.source}/${p.tag}`)).toEqual([
21
+ "framework/f0",
22
+ "framework/f1",
23
+ "deployment/d0",
24
+ ]);
25
+ });
26
+ it("hashes by SQL content (different SQL → different hash)", () => {
27
+ const [a] = planMigrations([{ name: "x", priority: 0, migrations: [{ tag: "t", sql: "A" }] }]);
28
+ const [b] = planMigrations([{ name: "x", priority: 0, migrations: [{ tag: "t", sql: "B" }] }]);
29
+ expect(a?.contentHash).toBeTypeOf("string");
30
+ expect(a?.contentHash).not.toBe(b?.contentHash);
31
+ });
32
+ });
33
+ // ---- applyMigrations: integration (the spike's scenarios) -------------------
34
+ function sources() {
35
+ return {
36
+ framework: {
37
+ name: "framework",
38
+ priority: 0,
39
+ migrations: [
40
+ { tag: "0001_init", sql: `CREATE TABLE ${SCHEMA}.bookings (id text PRIMARY KEY);` },
41
+ { tag: "0002_add_status", sql: `ALTER TABLE ${SCHEMA}.bookings ADD COLUMN status text;` },
42
+ ],
43
+ },
44
+ deployment: {
45
+ name: "deployment",
46
+ priority: 1,
47
+ // FK into a framework table — fails outright if applied before framework.
48
+ migrations: [
49
+ {
50
+ tag: "0001_acme_notes",
51
+ sql: `CREATE TABLE ${SCHEMA}.acme_notes (id text PRIMARY KEY, booking_id text REFERENCES ${SCHEMA}.bookings(id));`,
52
+ },
53
+ ],
54
+ },
55
+ };
56
+ }
57
+ const ledgerOpts = { ledgerSchema: SCHEMA, ledgerTable: "_voyant_migrations" };
58
+ describe.skipIf(!DB_URL)("applyMigrations (integration)", () => {
59
+ let client;
60
+ beforeAll(async () => {
61
+ client = new Client({ connectionString: DB_URL });
62
+ await client.connect();
63
+ });
64
+ afterAll(async () => {
65
+ if (client) {
66
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
67
+ await client.end();
68
+ }
69
+ });
70
+ beforeEach(async () => {
71
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
72
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
73
+ });
74
+ async function tableExists(name) {
75
+ const r = await client.query("SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", [SCHEMA, name]);
76
+ return r.rows.length > 0;
77
+ }
78
+ it("scenario 1+5 — fresh apply is framework-first; deployment FK resolves", async () => {
79
+ const s = sources();
80
+ const applied = await applyMigrations(client, [s.framework, s.deployment], ledgerOpts);
81
+ expect(applied).toEqual([
82
+ "framework/0001_init",
83
+ "framework/0002_add_status",
84
+ "deployment/0001_acme_notes",
85
+ ]);
86
+ expect(await tableExists("bookings")).toBe(true);
87
+ // The deployment table FKs into the framework table — exists only because
88
+ // framework was applied first (ordering is load-bearing).
89
+ expect(await tableExists("acme_notes")).toBe(true);
90
+ });
91
+ it("scenario 2 — re-run is idempotent (applies nothing)", async () => {
92
+ const s = sources();
93
+ await applyMigrations(client, [s.framework, s.deployment], ledgerOpts);
94
+ const second = await applyMigrations(client, [s.framework, s.deployment], ledgerOpts);
95
+ expect(second).toEqual([]);
96
+ });
97
+ it("scenario 3 — a framework upgrade applies only the new migration", async () => {
98
+ const s = sources();
99
+ await applyMigrations(client, [s.framework, s.deployment], ledgerOpts);
100
+ const upgraded = sources();
101
+ upgraded.framework.migrations.push({
102
+ tag: "0003_add_index",
103
+ sql: `CREATE INDEX bookings_status_idx ON ${SCHEMA}.bookings (status);`,
104
+ });
105
+ const applied = await applyMigrations(client, [upgraded.framework, upgraded.deployment], ledgerOpts);
106
+ expect(applied).toEqual(["framework/0003_add_index"]);
107
+ });
108
+ it("scenario 4 — editing an applied migration is a hard error", async () => {
109
+ const s = sources();
110
+ await applyMigrations(client, [s.framework, s.deployment], ledgerOpts);
111
+ const tampered = sources();
112
+ tampered.framework.migrations[0].sql =
113
+ `CREATE TABLE ${SCHEMA}.bookings (id text PRIMARY KEY, tampered boolean);`;
114
+ await expect(applyMigrations(client, [tampered.framework, tampered.deployment], ledgerOpts)).rejects.toBeInstanceOf(MigrationImmutabilityError);
115
+ });
116
+ it("splits drizzle statement-breakpoints within one migration", async () => {
117
+ const multi = {
118
+ name: "framework",
119
+ priority: 0,
120
+ migrations: [
121
+ {
122
+ tag: "0001_multi",
123
+ sql: `CREATE TABLE ${SCHEMA}.a (id text);\n--> statement-breakpoint\nCREATE TABLE ${SCHEMA}.b (id text);`,
124
+ },
125
+ ],
126
+ };
127
+ await applyMigrations(client, [multi], ledgerOpts);
128
+ expect(await tableExists("a")).toBe(true);
129
+ expect(await tableExists("b")).toBe(true);
130
+ });
131
+ // ---- importBaseline: record an existing schema without re-executing --------
132
+ it("baseline records the ledger WITHOUT executing any SQL", async () => {
133
+ const s = sources();
134
+ const imported = await importBaseline(client, [s.framework, s.deployment], ledgerOpts);
135
+ // Every planned migration is recorded …
136
+ expect(imported).toEqual([
137
+ "framework/0001_init",
138
+ "framework/0002_add_status",
139
+ "deployment/0001_acme_notes",
140
+ ]);
141
+ // … but none of their SQL ran (the schema is assumed already present).
142
+ expect(await tableExists("bookings")).toBe(false);
143
+ expect(await tableExists("acme_notes")).toBe(false);
144
+ });
145
+ it("a baselined deployment then applies nothing (ledger interop with applyMigrations)", async () => {
146
+ const s = sources();
147
+ await importBaseline(client, [s.framework, s.deployment], ledgerOpts);
148
+ // applyMigrations sees the baselined (source, tag) rows and skips them —
149
+ // so it does NOT try to re-create the already-existing schema.
150
+ const applied = await applyMigrations(client, [s.framework, s.deployment], ledgerOpts);
151
+ expect(applied).toEqual([]);
152
+ // A genuinely new migration still applies after a baseline.
153
+ const upgraded = sources();
154
+ upgraded.framework.migrations.push({
155
+ tag: "0003_add_index",
156
+ sql: `CREATE TABLE ${SCHEMA}.bookings (id text PRIMARY KEY); CREATE INDEX bookings_status_idx ON ${SCHEMA}.bookings (id);`,
157
+ });
158
+ const next = await applyMigrations(client, [upgraded.framework, upgraded.deployment], ledgerOpts);
159
+ expect(next).toEqual(["framework/0003_add_index"]);
160
+ });
161
+ it("baseline is idempotent (re-run records nothing new)", async () => {
162
+ const s = sources();
163
+ await importBaseline(client, [s.framework, s.deployment], ledgerOpts);
164
+ const second = await importBaseline(client, [s.framework, s.deployment], ledgerOpts);
165
+ expect(second).toEqual([]);
166
+ });
167
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@voyant-travel/framework-migrations` — the D.1 multi-source migration
3
+ * collector + (in a later slice) the framework-shipped standard-profile bundle.
4
+ *
5
+ * See `docs/architecture/migration-collector-d1.md`.
6
+ */
7
+ export { frameworkBundleDir, loadFrameworkBundleSource } from "./bundle.js";
8
+ export { type ApplyMigrationsOptions, applyMigrations, importBaseline, type MigrationClient, MigrationImmutabilityError, type MigrationSource, type MigrationStatement, type PlannedMigration, planMigrations, } from "./collector.js";
9
+ export { loadMigrationFolder } from "./load-folder.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,kBAAkB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;AAC3E,OAAO,EACL,KAAK,sBAAsB,EAC3B,eAAe,EACf,cAAc,EACd,KAAK,eAAe,EACpB,0BAA0B,EAC1B,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,cAAc,GACf,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@voyant-travel/framework-migrations` — the D.1 multi-source migration
3
+ * collector + (in a later slice) the framework-shipped standard-profile bundle.
4
+ *
5
+ * See `docs/architecture/migration-collector-d1.md`.
6
+ */
7
+ export { frameworkBundleDir, loadFrameworkBundleSource } from "./bundle.js";
8
+ export { applyMigrations, importBaseline, MigrationImmutabilityError, planMigrations, } from "./collector.js";
9
+ export { loadMigrationFolder } from "./load-folder.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Load a drizzle migrations folder (`meta/_journal.json` + `*.sql`) into the
3
+ * ordered `MigrationStatement[]` the collector consumes. The framework bundle
4
+ * and the deployment's own `migrations/` are both drizzle folders, so the same
5
+ * loader reads either.
6
+ */
7
+ import type { MigrationStatement } from "./collector.js";
8
+ /**
9
+ * Read `<folder>/meta/_journal.json` and each `<folder>/<tag>.sql`, returning
10
+ * statements in journal order (ascending `when`). Throws if a referenced SQL
11
+ * file is missing — a journal/file mismatch is a packaging error, not something
12
+ * to apply partially.
13
+ */
14
+ export declare function loadMigrationFolder(folder: string): Promise<MigrationStatement[]>;
15
+ //# sourceMappingURL=load-folder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-folder.d.ts","sourceRoot":"","sources":["../src/load-folder.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAWxD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAWvF"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Load a drizzle migrations folder (`meta/_journal.json` + `*.sql`) into the
3
+ * ordered `MigrationStatement[]` the collector consumes. The framework bundle
4
+ * and the deployment's own `migrations/` are both drizzle folders, so the same
5
+ * loader reads either.
6
+ */
7
+ import { readFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ /**
10
+ * Read `<folder>/meta/_journal.json` and each `<folder>/<tag>.sql`, returning
11
+ * statements in journal order (ascending `when`). Throws if a referenced SQL
12
+ * file is missing — a journal/file mismatch is a packaging error, not something
13
+ * to apply partially.
14
+ */
15
+ export async function loadMigrationFolder(folder) {
16
+ const journalRaw = await readFile(join(folder, "meta", "_journal.json"), "utf8");
17
+ const journal = JSON.parse(journalRaw);
18
+ const entries = [...journal.entries].sort((a, b) => a.when - b.when);
19
+ const statements = [];
20
+ for (const entry of entries) {
21
+ const sql = await readFile(join(folder, `${entry.tag}.sql`), "utf8");
22
+ statements.push({ tag: entry.tag, sql });
23
+ }
24
+ return statements;
25
+ }