Skip to content

Commit 1ba5a14

Browse files
committed
fix(migrate): make SQLite CREATE TABLE/INDEX statements idempotent before running
Schema drift (tables created outside the migration system) previously caused a fatal startup crash when drizzle tried to re-create an already-existing table. Now all CREATE TABLE and CREATE INDEX statements are rewritten with IF NOT EXISTS before being passed to dialect.migrate, so the error never occurs. Migration hashes are computed from the original file content and are unaffected by this transformation, so migration tracking remains correct. Also adds warn/silly logging to the repair fallback path so future failures surface a clear reason instead of silently returning false.
1 parent 39823bf commit 1ba5a14

1 file changed

Lines changed: 27 additions & 4 deletions

File tree

packages/backend/src/db/migrate.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,12 @@ function attemptSQLiteAlreadyExistsRepair(
115115

116116
// Reach the underlying Bun SQLite Database through the drizzle session
117117
const sqlite = db?.session?.client;
118-
if (!sqlite?.run) return false;
118+
if (!sqlite?.run) {
119+
logger.warn(
120+
'SQLite repair skipped: could not access underlying Database client via db.session.client'
121+
);
122+
return false;
123+
}
119124

120125
sqlite.run(`
121126
CREATE TABLE IF NOT EXISTS ${DRIZZLE_MIGRATIONS_TABLE} (
@@ -133,7 +138,12 @@ function attemptSQLiteAlreadyExistsRepair(
133138
const refersToObject = statements.some((s) =>
134139
s.toLowerCase().replace(/\s+/g, ' ').includes(`\`${objectName}\``)
135140
);
136-
if (!refersToObject) continue;
141+
if (!refersToObject) {
142+
logger.silly(
143+
`SQLite repair: migration ${entry.tag} does not reference ${objectName}, skipping`
144+
);
145+
continue;
146+
}
137147

138148
const alreadyApplied = sqlite
139149
.query(`SELECT id FROM ${DRIZZLE_MIGRATIONS_TABLE} WHERE hash = ?`)
@@ -249,14 +259,27 @@ export async function runMigrations() {
249259
await Bun.file(path.join(DEV_MIGRATIONS_DIR.sqlite, 'meta', '_journal.json')).text()
250260
) as Journal);
251261
const migrations = await buildMigrations(journal, DEV_MIGRATIONS_DIR.sqlite);
262+
// Make all CREATE TABLE/INDEX statements idempotent before running so that
263+
// schema drift (tables created outside the migration system) never causes a
264+
// fatal startup failure. The hash field is computed from the original file
265+
// content and is not affected by this transformation, so migration tracking
266+
// remains correct.
267+
const idempotentMigrations = migrations.map((m) => ({
268+
...m,
269+
sql: m.sql.map(toIdempotentSQLiteStatement),
270+
}));
252271
try {
253-
(db as any).dialect.migrate(migrations, (db as any).session, { migrationsFolder: '' });
272+
(db as any).dialect.migrate(idempotentMigrations, (db as any).session, {
273+
migrationsFolder: '',
274+
});
254275
} catch (error: any) {
255276
if (isSQLiteAlreadyExistsError(error)) {
256277
const repaired = attemptSQLiteAlreadyExistsRepair(db, migrations, journal, error);
257278
if (repaired) {
258279
logger.warn('Retrying SQLite migrations after already-exists repair');
259-
(db as any).dialect.migrate(migrations, (db as any).session, { migrationsFolder: '' });
280+
(db as any).dialect.migrate(idempotentMigrations, (db as any).session, {
281+
migrationsFolder: '',
282+
});
260283
} else {
261284
throw error;
262285
}

0 commit comments

Comments
 (0)