Skip to content

Commit a28064c

Browse files
committed
fix(workspace-server): tolerate already-applied schema in db migrations
1 parent 2ecc6b6 commit a28064c

4 files changed

Lines changed: 167 additions & 4 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
let sqlite: InstanceType<typeof Database>;
12+
13+
beforeEach(() => {
14+
sqlite = new Database(":memory:");
15+
sqlite.pragma("foreign_keys = ON");
16+
});
17+
18+
afterEach(() => {
19+
sqlite.close();
20+
});
21+
22+
function ledgerMax(db: InstanceType<typeof Database>): number | null {
23+
const row = db
24+
.prepare("SELECT MAX(created_at) AS max FROM __drizzle_migrations")
25+
.get() as { max: number | null };
26+
return row.max;
27+
}
28+
29+
function hasColumn(
30+
db: InstanceType<typeof Database>,
31+
table: string,
32+
column: string,
33+
): boolean {
34+
return db
35+
.prepare(`PRAGMA table_info(${table})`)
36+
.all()
37+
.some((c) => (c as { name: string }).name === column);
38+
}
39+
40+
describe("runMigrations", () => {
41+
it("applies every migration on a fresh database", () => {
42+
runMigrations(sqlite, MIGRATIONS_FOLDER);
43+
44+
expect(hasColumn(sqlite, "workspaces", "pr_urls")).toBe(true);
45+
expect(ledgerMax(sqlite)).not.toBeNull();
46+
});
47+
48+
it("is a no-op when run twice", () => {
49+
runMigrations(sqlite, MIGRATIONS_FOLDER);
50+
const afterFirst = ledgerMax(sqlite);
51+
52+
expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow();
53+
expect(ledgerMax(sqlite)).toBe(afterFirst);
54+
});
55+
56+
it("boots when the schema is already ahead of the migration ledger", () => {
57+
runMigrations(sqlite, MIGRATIONS_FOLDER);
58+
const latest = ledgerMax(sqlite);
59+
60+
sqlite
61+
.prepare("DELETE FROM __drizzle_migrations WHERE created_at = ?")
62+
.run(latest);
63+
expect(ledgerMax(sqlite)).not.toBe(latest);
64+
65+
expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow();
66+
expect(hasColumn(sqlite, "workspaces", "pr_urls")).toBe(true);
67+
expect(ledgerMax(sqlite)).toBe(latest);
68+
});
69+
70+
it("propagates errors that are not benign schema conflicts", () => {
71+
const dir = mkdtempSync(path.join(tmpdir(), "migrate-test-"));
72+
try {
73+
mkdirSync(path.join(dir, "meta"), { recursive: true });
74+
writeFileSync(
75+
path.join(dir, "meta", "_journal.json"),
76+
JSON.stringify({
77+
version: "7",
78+
dialect: "sqlite",
79+
entries: [
80+
{
81+
idx: 0,
82+
version: "6",
83+
when: 1,
84+
tag: "0000_broken",
85+
breakpoints: true,
86+
},
87+
],
88+
}),
89+
);
90+
writeFileSync(
91+
path.join(dir, "0000_broken.sql"),
92+
"DROP TABLE `table_that_does_not_exist`;",
93+
);
94+
95+
expect(() => runMigrations(sqlite, dir)).toThrow(/no such table/i);
96+
} finally {
97+
rmSync(dir, { recursive: true, force: true });
98+
}
99+
});
100+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type Database from "better-sqlite3";
2+
import { readMigrationFiles } from "drizzle-orm/migrator";
3+
4+
type SqliteDatabase = InstanceType<typeof Database>;
5+
6+
const BENIGN_SCHEMA_CONFLICT_PATTERNS = [
7+
/duplicate column name/i,
8+
/already exists/i,
9+
];
10+
11+
function isBenignSchemaConflict(error: unknown): boolean {
12+
return (
13+
error instanceof Error &&
14+
BENIGN_SCHEMA_CONFLICT_PATTERNS.some((pattern) =>
15+
pattern.test(error.message),
16+
)
17+
);
18+
}
19+
20+
export function runMigrations(
21+
sqlite: SqliteDatabase,
22+
migrationsFolder: string,
23+
): void {
24+
const migrations = readMigrationFiles({ migrationsFolder });
25+
26+
sqlite.exec(
27+
"CREATE TABLE IF NOT EXISTS __drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)",
28+
);
29+
30+
const lastApplied = sqlite
31+
.prepare(
32+
"SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1",
33+
)
34+
.get() as { created_at: number } | undefined;
35+
36+
const recordMigration = sqlite.prepare(
37+
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)",
38+
);
39+
40+
const applyPending = sqlite.transaction(() => {
41+
for (const migration of migrations) {
42+
if (
43+
lastApplied &&
44+
Number(lastApplied.created_at) >= migration.folderMillis
45+
) {
46+
continue;
47+
}
48+
for (const statement of migration.sql) {
49+
try {
50+
sqlite.exec(statement);
51+
} catch (error) {
52+
if (isBenignSchemaConflict(error)) {
53+
continue;
54+
}
55+
throw error;
56+
}
57+
}
58+
recordMigration.run(migration.hash, migration.folderMillis);
59+
}
60+
});
61+
62+
applyPending();
63+
}

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");
@@ -39,7 +39,7 @@ export class DatabaseService {
3939
this._sqlite.pragma("journal_mode = WAL");
4040
this._sqlite.pragma("foreign_keys = ON");
4141
this._db = drizzle(this._sqlite, { schema, casing: "snake_case" });
42-
migrate(this._db, { migrationsFolder: MIGRATIONS_FOLDER });
42+
runMigrations(this._sqlite, MIGRATIONS_FOLDER);
4343
}
4444

4545
@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)