Skip to content

Commit 4c9c3eb

Browse files
committed
Add error handling for outdated clients running against newer DB schemas
1 parent 1530dd8 commit 4c9c3eb

3 files changed

Lines changed: 298 additions & 0 deletions

File tree

apps/cli/release-notes/next.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Tool dispatch, plugins, storage, schema, and transport are now fully instrumente
7878
- Upgrade: preserve legacy OAuth connection backfills after the `connection.kind` column is removed.
7979
- OpenAPI: refreshing or editing sources with legacy inline secret/OAuth config now materializes the new source binding rows instead of dropping credentials.
8080
- Keychain: skip provider registration when the OS backend is unreachable (no more startup failure when running headless on Linux without a keyring).
81+
- Local database: fail early with guidance when an older Executor build opens a data directory migrated by a newer build, instead of surfacing a low-level SQLite schema error.
8182
- Local server: return 404 for missing static assets instead of serving HTML.
8283
- Tests: Windows compatibility across the suite.
8384

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { Database } from "bun:sqlite";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, describe, expect, it } from "@effect/vitest";
6+
import { drizzle } from "drizzle-orm/bun-sqlite";
7+
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
8+
9+
import {
10+
LocalDatabaseMigrationHistoryMismatch,
11+
LocalDatabaseSchemaTooNew,
12+
checkDrizzleMigrationCompatibility,
13+
readAppliedDrizzleMigrationHashes,
14+
readBundledDrizzleMigrationHashes,
15+
} from "./executor";
16+
17+
const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18+
19+
const workDirs: string[] = [];
20+
21+
const tempDb = (): { db: Database; path: string } => {
22+
const dir = mkdtempSync(join(tmpdir(), "executor-schema-compat-"));
23+
workDirs.push(dir);
24+
const path = join(dir, "data.db");
25+
return { db: new Database(path), path };
26+
};
27+
28+
const createMigrationTable = (db: Database): void => {
29+
db.exec(`
30+
CREATE TABLE __drizzle_migrations (
31+
id INTEGER PRIMARY KEY AUTOINCREMENT,
32+
hash TEXT NOT NULL,
33+
created_at NUMERIC
34+
)
35+
`);
36+
};
37+
38+
const insertMigrationHashes = (db: Database, hashes: ReadonlyArray<string>): void => {
39+
const stmt = db.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)");
40+
for (const [index, hash] of hashes.entries()) {
41+
stmt.run(hash, index + 1);
42+
}
43+
};
44+
45+
afterEach(() => {
46+
for (const dir of workDirs.splice(0)) {
47+
rmSync(dir, { recursive: true, force: true });
48+
}
49+
});
50+
51+
describe("Drizzle migration compatibility preflight", () => {
52+
it("allows a fresh DB without __drizzle_migrations", () => {
53+
const { db, path } = tempDb();
54+
try {
55+
expect(() =>
56+
checkDrizzleMigrationCompatibility({
57+
sqlite: db,
58+
dbPath: path,
59+
migrationsFolder: MIGRATIONS_FOLDER,
60+
}),
61+
).not.toThrow();
62+
} finally {
63+
db.close();
64+
}
65+
});
66+
67+
it("allows an existing but empty __drizzle_migrations table", () => {
68+
const { db, path } = tempDb();
69+
try {
70+
createMigrationTable(db);
71+
72+
expect(() =>
73+
checkDrizzleMigrationCompatibility({
74+
sqlite: db,
75+
dbPath: path,
76+
migrationsFolder: MIGRATIONS_FOLDER,
77+
}),
78+
).not.toThrow();
79+
} finally {
80+
db.close();
81+
}
82+
});
83+
84+
it("computes bundled hashes that exactly match hashes written by Drizzle", () => {
85+
const { db } = tempDb();
86+
try {
87+
migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
88+
89+
expect(readAppliedDrizzleMigrationHashes(db)).toEqual(
90+
readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER),
91+
);
92+
} finally {
93+
db.close();
94+
}
95+
});
96+
97+
it("throws LocalDatabaseSchemaTooNew when the DB has more migrations than the binary", () => {
98+
const { db, path } = tempDb();
99+
try {
100+
const bundled = readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER);
101+
createMigrationTable(db);
102+
insertMigrationHashes(db, [...bundled, "future-migration-hash"]);
103+
104+
expect(() =>
105+
checkDrizzleMigrationCompatibility({
106+
sqlite: db,
107+
dbPath: path,
108+
migrationsFolder: MIGRATIONS_FOLDER,
109+
}),
110+
).toThrow(LocalDatabaseSchemaTooNew);
111+
112+
try {
113+
checkDrizzleMigrationCompatibility({
114+
sqlite: db,
115+
dbPath: path,
116+
migrationsFolder: MIGRATIONS_FOLDER,
117+
});
118+
} catch (error) {
119+
expect(error).toBeInstanceOf(LocalDatabaseSchemaTooNew);
120+
expect((error as Error).message).toContain(
121+
"This Executor binary is older than the schema",
122+
);
123+
expect((error as Error).message).toContain("Use a newer Executor binary");
124+
}
125+
} finally {
126+
db.close();
127+
}
128+
});
129+
130+
it("throws LocalDatabaseMigrationHistoryMismatch when hashes diverge", () => {
131+
const { db, path } = tempDb();
132+
try {
133+
const bundled = readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER);
134+
createMigrationTable(db);
135+
insertMigrationHashes(db, ["different-migration-hash", ...bundled.slice(1)]);
136+
137+
expect(() =>
138+
checkDrizzleMigrationCompatibility({
139+
sqlite: db,
140+
dbPath: path,
141+
migrationsFolder: MIGRATIONS_FOLDER,
142+
}),
143+
).toThrow(LocalDatabaseMigrationHistoryMismatch);
144+
145+
try {
146+
checkDrizzleMigrationCompatibility({
147+
sqlite: db,
148+
dbPath: path,
149+
migrationsFolder: MIGRATIONS_FOLDER,
150+
});
151+
} catch (error) {
152+
expect(error).toBeInstanceOf(LocalDatabaseMigrationHistoryMismatch);
153+
expect((error as Error).message).toContain(
154+
"does not match this Executor build",
155+
);
156+
expect((error as Error).message).toContain("restore a backup");
157+
}
158+
} finally {
159+
db.close();
160+
}
161+
});
162+
163+
it("allows an older DB whose migration history is a bundled prefix", () => {
164+
const { db, path } = tempDb();
165+
try {
166+
const bundled = readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER);
167+
createMigrationTable(db);
168+
insertMigrationHashes(db, bundled.slice(0, 1));
169+
170+
expect(() =>
171+
checkDrizzleMigrationCompatibility({
172+
sqlite: db,
173+
dbPath: path,
174+
migrationsFolder: MIGRATIONS_FOLDER,
175+
}),
176+
).not.toThrow();
177+
} finally {
178+
db.close();
179+
}
180+
});
181+
});

apps/local/src/server/executor.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ class LocalExecutorTag extends Context.Service<LocalExecutorTag, LocalExecutorBu
9797

9898
export type LocalExecutor = LocalExecutorBundle["executor"];
9999

100+
export class LocalDatabaseSchemaTooNew extends Data.TaggedError("LocalDatabaseSchemaTooNew")<{
101+
readonly message: string;
102+
readonly dbPath: string;
103+
readonly appliedMigrationCount: number;
104+
readonly knownMigrationCount: number;
105+
}> {}
106+
107+
export class LocalDatabaseMigrationHistoryMismatch extends Data.TaggedError(
108+
"LocalDatabaseMigrationHistoryMismatch",
109+
)<{
110+
readonly message: string;
111+
readonly dbPath: string;
112+
readonly migrationIndex: number;
113+
readonly appliedHash: string | undefined;
114+
readonly knownHash: string | undefined;
115+
}> {}
116+
100117
class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{
101118
readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";
102119
readonly cause: unknown;
@@ -127,6 +144,100 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) =>
127144
),
128145
);
129146

147+
export const drizzleMigrationsTableExists = (sqlite: Database): boolean => {
148+
const row = sqlite
149+
.query<{ name: string }, [string]>(
150+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
151+
)
152+
.get("__drizzle_migrations");
153+
154+
return row !== null;
155+
};
156+
157+
export const readAppliedDrizzleMigrationHashes = (sqlite: Database): ReadonlyArray<string> => {
158+
if (!drizzleMigrationsTableExists(sqlite)) return [];
159+
160+
// Drizzle inserts one row per applied migration. `id` is the stable
161+
// application order; `created_at` comes from migration metadata and can tie.
162+
return sqlite
163+
.query<{ hash: string }, []>("SELECT hash FROM __drizzle_migrations ORDER BY id ASC")
164+
.all()
165+
.map((row) => row.hash);
166+
};
167+
168+
interface DrizzleJournal {
169+
readonly entries: ReadonlyArray<{
170+
readonly idx: number;
171+
readonly tag: string;
172+
}>;
173+
}
174+
175+
export const readBundledDrizzleMigrationHashes = (
176+
migrationsFolder: string,
177+
): ReadonlyArray<string> => {
178+
// Keep this in sync with drizzle-orm/src/migrator.ts: Drizzle hashes the raw
179+
// migration file contents before splitting on statement breakpoints.
180+
const journal = JSON.parse(
181+
fs.readFileSync(join(migrationsFolder, "meta", "_journal.json")).toString(),
182+
) as DrizzleJournal;
183+
184+
return [...journal.entries]
185+
.sort((left, right) => left.idx - right.idx)
186+
.map((entry) => {
187+
const query = fs.readFileSync(join(migrationsFolder, `${entry.tag}.sql`)).toString();
188+
return createHash("sha256").update(query).digest("hex");
189+
});
190+
};
191+
192+
const schemaTooNewMessage = (dbPath: string): string =>
193+
[
194+
`This Executor binary is older than the schema in ${process.env.EXECUTOR_DATA_DIR ?? dirname(dbPath)}.`,
195+
"The database was likely opened by a newer Executor build.",
196+
"Use a newer Executor binary or set EXECUTOR_DATA_DIR to a different data directory.",
197+
].join("\n");
198+
199+
const migrationHistoryMismatchMessage = (dbPath: string): string =>
200+
[
201+
`The migration history in ${process.env.EXECUTOR_DATA_DIR ?? dirname(dbPath)} does not match this Executor build.`,
202+
"The database may have been created by a different development branch, manually modified, or corrupted.",
203+
"Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.",
204+
].join("\n");
205+
206+
export const checkDrizzleMigrationCompatibility = (input: {
207+
readonly sqlite: Database;
208+
readonly dbPath: string;
209+
readonly migrationsFolder: string;
210+
}): void => {
211+
// Before running migrations, ensure the DB history is a prefix of the
212+
// migrations bundled with this binary. This catches newer or divergent schemas
213+
// before startup reaches arbitrary schema-dependent queries.
214+
if (!drizzleMigrationsTableExists(input.sqlite)) return;
215+
216+
const applied = readAppliedDrizzleMigrationHashes(input.sqlite);
217+
const bundled = readBundledDrizzleMigrationHashes(input.migrationsFolder);
218+
219+
if (applied.length > bundled.length) {
220+
throw new LocalDatabaseSchemaTooNew({
221+
message: schemaTooNewMessage(input.dbPath),
222+
dbPath: input.dbPath,
223+
appliedMigrationCount: applied.length,
224+
knownMigrationCount: bundled.length,
225+
});
226+
}
227+
228+
for (let index = 0; index < applied.length; index += 1) {
229+
if (applied[index] !== bundled[index]) {
230+
throw new LocalDatabaseMigrationHistoryMismatch({
231+
message: migrationHistoryMismatchMessage(input.dbPath),
232+
dbPath: input.dbPath,
233+
migrationIndex: index,
234+
appliedHash: applied[index],
235+
knownHash: bundled[index],
236+
});
237+
}
238+
}
239+
};
240+
130241
const createLocalExecutorLayer = () => {
131242
const { path: dbPath, legacySecrets } = resolveDbPath();
132243

@@ -136,6 +247,11 @@ const createLocalExecutorLayer = () => {
136247
Effect.sync(() => new Database(dbPath)),
137248
(conn) => Effect.sync(() => conn.close()),
138249
);
250+
checkDrizzleMigrationCompatibility({
251+
sqlite,
252+
dbPath,
253+
migrationsFolder: MIGRATIONS_FOLDER,
254+
});
139255
sqlite.exec("PRAGMA journal_mode = WAL");
140256

141257
const db = drizzle(sqlite, { schema: executorSchema });

0 commit comments

Comments
 (0)