[BUG]: `drizzle-kit generate` silently drops a newly created unique index when another table’s column type changes
Report hasn't been filed before.
- I have verified that the bug I'm about to report hasn't been filed before.
What version of drizzle-orm are you using?
0.45.2
What version of drizzle-kit are you using?
0.31.10
Other packages
[email protected], [email protected]
Describe the Bug
NB: bug discovered and verified by me, then verified by gpt-6-astra. text of this description is generated by astra, and verified by me carefully except the last section where it supposes the root-cause for the bug
describe the bug
Adding an indexed table and changing an existing table's column from integer to real in the same drizzle-kit generate produces a migration that:
- creates the new table and its unique index;
- drops that index while changing the unrelated existing table's column;
- never recreates it.
migrate succeeds, but duplicate values forbidden by the declared unique index can then be inserted. The generated snapshot still contains the index, so another generate reports no schema changes.
This appears related to #5564 and PR #5703, but the failure here is with generated migration files and silently missing uniqueness, rather than push failing with no such index. Please advise whether this should be tracked there instead.
minimal reproduction
Start in an empty directory and pin the versions below:
npm init -y
npm install --save-exact [email protected] @libsql/[email protected]
npm install --save-dev --save-exact [email protected] Create drizzle.config.ts:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'turso',
schema: './schema.ts',
out: './drizzle',
}); Create schema.ts:
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const player = sqliteTable('player', {
id: text('id').primaryKey(),
rd: integer('rd').notNull().default(350),
});Generate the initial migration:
node node_modules/drizzle-kit/bin.cjs generate --name=initial Replace only schema.ts with:
import { real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const player = sqliteTable('player', {
id: text('id').primaryKey(),
rd: real('rd').notNull().default(350),
});
export const ratingEvent = sqliteTable('rating_event', {
id: text('id').primaryKey(),
playerId: text('player_id').notNull().references(() => player.id),
}, (table) => [uniqueIndex('rating_event_player_unique').on(table.playerId)]);Generate the change migration:
node node_modules/drizzle-kit/bin.cjs generate --name=change The generated drizzle/0001_change.sql, without manual modifications, is:
CREATE TABLE `rating_event` (
`id` text PRIMARY KEY NOT NULL,
`player_id` text NOT NULL,
FOREIGN KEY (`player_id`) REFERENCES `player`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `rating_event_player_unique` ON `rating_event` (`player_id`);--> statement-breakpoint
DROP INDEX "rating_event_player_unique";--> statement-breakpoint
ALTER TABLE `player` ALTER COLUMN "rd" TO "rd" real NOT NULL DEFAULT 350; Create verify.mjs:
import { readFileSync } from 'node:fs';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
const client = createClient({ url: 'file::memory:' });
try {
await migrate(drizzle(client), { migrationsFolder: './drizzle' });
const snapshot = JSON.parse(readFileSync('./drizzle/meta/0001_snapshot.json', 'utf8'));
console.log('snapshot indexes:', Object.keys(snapshot.tables.rating_event.indexes));
const indexes = await client.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'rating_event_player_unique'");
console.log('actual named indexes:', indexes.rows.map(row => row.name));
await client.execute("INSERT INTO player (id) VALUES ('p1')");
await client.execute("INSERT INTO rating_event (id, player_id) VALUES ('e1', 'p1')");
let duplicateAccepted = false;
try {
await client.execute("INSERT INTO rating_event (id, player_id) VALUES ('e2', 'p1')");
duplicateAccepted = true;
} catch (error) {
console.log('duplicate rejected:', error.code);
}
console.log('duplicate accepted:', duplicateAccepted);
console.log('foreign-key violations:', (await client.execute('PRAGMA foreign_key_check')).rows.length);
} finally {
client.close();
}Run:
node verify.mjs
node node_modules/drizzle-kit/bin.cjs generate --name=check-driftObserved output:
snapshot indexes: [ 'rating_event_player_unique' ]
actual named indexes: []
duplicate accepted: true
foreign-key violations: 0The subsequent generation reports:
No schema changes, nothing to migrateexpected behaviour
- After applying generated migrations, the unique index declared in the target schema and snapshot exists in the database.
- The second event for
p1is rejected by that unique index. - A column alteration on
playermust not leaverating_eventwithout its index.
root-cause evidence
In LibSQLModifyColumn.convert, indexes are collected from every table in the target schema, not just the table being modified:
for (const table of Object.values(json2.tables)) {
for (const index of Object.values(table.indexes)) {
const unsquashed = SQLiteSquasher.unsquashIdx(index);
sqlStatements.push(`DROP INDEX "${unsquashed.name}";`);
indexes.push({ ...unsquashed, tableName: table.name });
}
}The converter later emits recreations. However, the turso diff path deduplicates SQL strings across the entire plan:
const uniqueSqlStatements: string[] = [];
sqlStatements.forEach((ss) => {
if (!uniqueSqlStatements.includes(ss)) {
uniqueSqlStatements.push(ss);
}
}); Therefore the second CREATE UNIQUE INDEX is discarded as a duplicate of the first, even though a DROP INDEX occurs between them. SQL statements with identical text are not redundant when earlier statements have changed database state.
Adding IF EXISTS to the drop alone cannot restore an omitted recreation. Scoping the converter to the modified table, as proposed in #5703, addresses the unrelated-table sweep in this example; broader statement deduplication should also be reviewed for valid create/drop/create lifecycles.
Source: drizzle-team/drizzle-orm