Skip to content

Commit df0d067

Browse files
dmealingclaude
andcommitted
fix(migrate-ts): exclude baseline marker from migration-listing primitives (rollback-all)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8bf1660 commit df0d067

2 files changed

Lines changed: 92 additions & 3 deletions

File tree

server/typescript/packages/migrate-ts/src/apply/ledger.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,14 @@ export async function appliedNames(
175175
return new Set((await appliedRecords(db, dialect, opts)).keys());
176176
}
177177

178-
/** Return a name→checksum map for all applied migrations (tamper-guard input). */
178+
/**
179+
* Return a name→checksum map for all applied migrations (tamper-guard input).
180+
*
181+
* The {@link BASELINE_NAME} marker row is excluded at the SQL level: it is a
182+
* marker, NOT a migration, so no migration-listing consumer (e.g. rollback-all,
183+
* which derives its work list from these names) should ever see it. The baseline
184+
* is read independently via {@link baselineRecord}.
185+
*/
179186
export async function appliedRecords(
180187
db: Kysely<Record<string, unknown>>,
181188
dialect: LedgerDialect = "sqlite",
@@ -185,7 +192,7 @@ export async function appliedRecords(
185192
// Raw select keeps this dialect-portable and sidesteps typing the dynamic
186193
// table name against the untyped Kysely<Record<string, unknown>> schema.
187194
const result = await sql<{ name: string; checksum: string }>`
188-
SELECT name, checksum FROM ${ledger.ref}
195+
SELECT name, checksum FROM ${ledger.ref} WHERE name != ${BASELINE_NAME}
189196
`.execute(db);
190197
const map = new Map<string, string>();
191198
for (const row of result.rows) {
@@ -204,7 +211,7 @@ export const BASELINE_NAME = "0000-baseline";
204211
*/
205212
export async function recordBaseline(
206213
db: Kysely<Record<string, unknown>>,
207-
dialect: LedgerDialect = "sqlite",
214+
dialect: LedgerDialect,
208215
checksum: string,
209216
opts?: LedgerOptions,
210217
): Promise<void> {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// test/integrity/baseline-rollback.test.ts
2+
import { describe, test, expect, afterAll } from "bun:test";
3+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { Kysely, sql } from "kysely";
7+
import { LibsqlDialect } from "@libsql/kysely-libsql";
8+
import { applyPending, rollbackTo } from "../../src/apply/apply.js";
9+
import { recordBaseline, baselineRecord } from "../../src/apply/ledger.js";
10+
11+
const tmps: string[] = [];
12+
function db(file: string) {
13+
return new Kysely<Record<string, unknown>>({
14+
dialect: new LibsqlDialect({ url: `file:${file}` }),
15+
});
16+
}
17+
async function root() {
18+
const d = await mkdtemp(join(tmpdir(), "baseline-rollback-"));
19+
tmps.push(d);
20+
return d;
21+
}
22+
async function writeMigration(
23+
dir: string,
24+
name: string,
25+
up: string,
26+
down: string,
27+
): Promise<void> {
28+
const mdir = join(dir, name);
29+
await mkdir(mdir, { recursive: true });
30+
await writeFile(join(mdir, "up.sql"), up, "utf8");
31+
await writeFile(join(mdir, "down.sql"), down, "utf8");
32+
}
33+
async function tableExists(
34+
k: Kysely<Record<string, unknown>>,
35+
name: string,
36+
): Promise<boolean> {
37+
const r = await sql<{ name: string }>`
38+
SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${name}
39+
`.execute(k);
40+
return r.rows.length > 0;
41+
}
42+
43+
afterAll(async () => {
44+
for (const d of tmps) await rm(d, { recursive: true, force: true });
45+
});
46+
47+
describe("rollback-all with a baseline marker present", () => {
48+
test("rollback-all ignores the baseline marker and runs the real down", async () => {
49+
const r = await root();
50+
const migrationsDir = join(r, "migrations");
51+
await writeMigration(
52+
migrationsDir,
53+
"20260101000000-init",
54+
`CREATE TABLE t ( id INTEGER NOT NULL PRIMARY KEY );`,
55+
`DROP TABLE t;`,
56+
);
57+
const k = db(join(r, "rb.db"));
58+
try {
59+
// Apply the one real migration.
60+
await applyPending(k, migrationsDir, { dryRun: false, dialect: "sqlite" });
61+
expect(await tableExists(k, "t")).toBe(true);
62+
63+
// Record a baseline marker row in the ledger (NOT a migration).
64+
await recordBaseline(k, "sqlite", "somehash");
65+
66+
// Rollback ALL — must NOT throw on the 0000-baseline sentinel.
67+
const result = await rollbackTo(k, migrationsDir, null, {
68+
dialect: "sqlite",
69+
});
70+
71+
// The real migration's down ran (table gone), baseline was not treated
72+
// as a migration.
73+
expect(result.rolledBack).toEqual(["20260101000000-init"]);
74+
expect(await tableExists(k, "t")).toBe(false);
75+
76+
// Baseline is still readable (the filter only hides it from listings).
77+
expect((await baselineRecord(k, "sqlite"))?.checksum).toBe("somehash");
78+
} finally {
79+
await k.destroy();
80+
}
81+
});
82+
});

0 commit comments

Comments
 (0)