Skip to content

Commit 6867e5d

Browse files
authored
fix(workspace-server): tolerate already-applied schema in db migrations (#3259)
1 parent 0707768 commit 6867e5d

4 files changed

Lines changed: 188 additions & 4 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import path from "node:path";
4+
import Database from "better-sqlite3";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
7+
import { runMigrations } from "./migrate";
8+
9+
const MIGRATIONS_FOLDER = path.resolve(__dirname, "migrations");
10+
11+
const MID_HISTORY_ADD_COLUMN_TIMESTAMP = 1782781314961;
12+
13+
let sqlite: InstanceType<typeof Database>;
14+
15+
beforeEach(() => {
16+
sqlite = new Database(":memory:");
17+
sqlite.pragma("foreign_keys = ON");
18+
});
19+
20+
afterEach(() => {
21+
sqlite.close();
22+
});
23+
24+
function ledgerMax(db: InstanceType<typeof Database>): number | null {
25+
const row = db
26+
.prepare("SELECT MAX(created_at) AS max FROM __drizzle_migrations")
27+
.get() as { max: number | null };
28+
return row.max;
29+
}
30+
31+
function ledgerHas(
32+
db: InstanceType<typeof Database>,
33+
timestamp: number,
34+
): boolean {
35+
const row = db
36+
.prepare(
37+
"SELECT COUNT(*) AS count FROM __drizzle_migrations WHERE created_at = ?",
38+
)
39+
.get(timestamp) as { count: number };
40+
return row.count > 0;
41+
}
42+
43+
function hasColumn(
44+
db: InstanceType<typeof Database>,
45+
table: string,
46+
column: string,
47+
): boolean {
48+
return db
49+
.prepare(`PRAGMA table_info(${table})`)
50+
.all()
51+
.some((c) => (c as { name: string }).name === column);
52+
}
53+
54+
describe("runMigrations", () => {
55+
it("applies every migration on a fresh database", () => {
56+
runMigrations(sqlite, MIGRATIONS_FOLDER);
57+
58+
expect(hasColumn(sqlite, "workspaces", "pr_urls")).toBe(true);
59+
expect(ledgerMax(sqlite)).not.toBeNull();
60+
});
61+
62+
it("is a no-op when run twice", () => {
63+
runMigrations(sqlite, MIGRATIONS_FOLDER);
64+
const afterFirst = ledgerMax(sqlite);
65+
66+
expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow();
67+
expect(ledgerMax(sqlite)).toBe(afterFirst);
68+
});
69+
70+
it("boots when the schema is already ahead of the migration ledger", () => {
71+
runMigrations(sqlite, MIGRATIONS_FOLDER);
72+
const latest = ledgerMax(sqlite);
73+
74+
sqlite
75+
.prepare("DELETE FROM __drizzle_migrations WHERE created_at = ?")
76+
.run(latest);
77+
expect(ledgerMax(sqlite)).not.toBe(latest);
78+
79+
expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow();
80+
expect(hasColumn(sqlite, "workspaces", "pr_urls")).toBe(true);
81+
expect(ledgerMax(sqlite)).toBe(latest);
82+
});
83+
84+
it("re-applies a missing mid-history ledger entry", () => {
85+
runMigrations(sqlite, MIGRATIONS_FOLDER);
86+
87+
sqlite
88+
.prepare("DELETE FROM __drizzle_migrations WHERE created_at = ?")
89+
.run(MID_HISTORY_ADD_COLUMN_TIMESTAMP);
90+
expect(ledgerHas(sqlite, MID_HISTORY_ADD_COLUMN_TIMESTAMP)).toBe(false);
91+
92+
expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow();
93+
expect(ledgerHas(sqlite, MID_HISTORY_ADD_COLUMN_TIMESTAMP)).toBe(true);
94+
});
95+
96+
it("propagates errors other than duplicate-column conflicts", () => {
97+
const dir = writeTempMigration("DROP TABLE `table_that_does_not_exist`;");
98+
try {
99+
expect(() => runMigrations(sqlite, dir)).toThrow(/no such table/i);
100+
} finally {
101+
rmSync(dir, { recursive: true, force: true });
102+
}
103+
});
104+
105+
it("does not swallow an 'already exists' conflict from a new migration", () => {
106+
sqlite.exec("CREATE TABLE existing_table (id text)");
107+
const dir = writeTempMigration(
108+
"CREATE TABLE `existing_table` (`id` text);",
109+
);
110+
try {
111+
expect(() => runMigrations(sqlite, dir)).toThrow(/already exists/i);
112+
} finally {
113+
rmSync(dir, { recursive: true, force: true });
114+
}
115+
});
116+
});
117+
118+
function writeTempMigration(sql: string): string {
119+
const dir = mkdtempSync(path.join(tmpdir(), "migrate-test-"));
120+
mkdirSync(path.join(dir, "meta"), { recursive: true });
121+
writeFileSync(
122+
path.join(dir, "meta", "_journal.json"),
123+
JSON.stringify({
124+
version: "7",
125+
dialect: "sqlite",
126+
entries: [
127+
{ idx: 0, version: "6", when: 1, tag: "0000_temp", breakpoints: true },
128+
],
129+
}),
130+
);
131+
writeFileSync(path.join(dir, "0000_temp.sql"), sql);
132+
return dir;
133+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type Database from "better-sqlite3";
2+
import { readMigrationFiles } from "drizzle-orm/migrator";
3+
4+
type SqliteDatabase = InstanceType<typeof Database>;
5+
6+
function isDuplicateColumnError(error: unknown): boolean {
7+
return error instanceof Error && /duplicate column name/i.test(error.message);
8+
}
9+
10+
export function runMigrations(
11+
sqlite: SqliteDatabase,
12+
migrationsFolder: string,
13+
): void {
14+
const migrations = readMigrationFiles({ migrationsFolder });
15+
16+
sqlite.exec(
17+
"CREATE TABLE IF NOT EXISTS __drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)",
18+
);
19+
20+
const appliedTimestamps = new Set(
21+
sqlite
22+
.prepare("SELECT created_at FROM __drizzle_migrations")
23+
.all()
24+
.map((row) => Number((row as { created_at: number }).created_at)),
25+
);
26+
27+
const recordMigration = sqlite.prepare(
28+
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)",
29+
);
30+
31+
const applyPending = sqlite.transaction(() => {
32+
for (const migration of migrations) {
33+
if (appliedTimestamps.has(migration.folderMillis)) {
34+
continue;
35+
}
36+
for (const statement of migration.sql) {
37+
try {
38+
sqlite.exec(statement);
39+
} catch (error) {
40+
if (isDuplicateColumnError(error)) {
41+
continue;
42+
}
43+
throw error;
44+
}
45+
}
46+
recordMigration.run(migration.hash, migration.folderMillis);
47+
}
48+
});
49+
50+
applyPending();
51+
}

packages/workspace-server/src/db/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {
88
type BetterSQLite3Database,
99
drizzle,
1010
} from "drizzle-orm/better-sqlite3";
11-
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
1211
import { inject, injectable, postConstruct, preDestroy } from "inversify";
1312

13+
import { runMigrations } from "./migrate";
1414
import * as schema from "./schema";
1515

1616
const MIGRATIONS_FOLDER = path.join(__dirname, "db-migrations");
@@ -49,7 +49,7 @@ export class DatabaseService {
4949
this._sqlite.pragma("journal_mode = WAL");
5050
this._sqlite.pragma("foreign_keys = ON");
5151
this._db = drizzle(this._sqlite, { schema, casing: "snake_case" });
52-
migrate(this._db, { migrationsFolder: MIGRATIONS_FOLDER });
52+
runMigrations(this._sqlite, MIGRATIONS_FOLDER);
5353
}
5454

5555
@preDestroy()

packages/workspace-server/src/db/test-helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {
44
type BetterSQLite3Database,
55
drizzle,
66
} from "drizzle-orm/better-sqlite3";
7-
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
87

8+
import { runMigrations } from "./migrate";
99
import * as schema from "./schema";
1010

1111
const MIGRATIONS_FOLDER = path.resolve(__dirname, "migrations");
@@ -20,7 +20,7 @@ export function createTestDb(): TestDatabase {
2020
sqlite.pragma("foreign_keys = ON");
2121

2222
const db = drizzle(sqlite, { schema, casing: "snake_case" });
23-
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
23+
runMigrations(sqlite, MIGRATIONS_FOLDER);
2424

2525
return {
2626
db,

0 commit comments

Comments
 (0)