drizzle-team / drizzle-team/drizzle-orm

[BUG]: SQLite push table recreate silently drops UNIQUE from a column that also has a plain index

Open
#6,060 0 comments 0 reactions 1 assignee Claimed by @AleksandrSherman View on GitHub
bug drizzle/kit
Dominant language
TypeScript
Stars
35.8k
Forks
1.6k
Avg merge
2d 7h
Merged PRs (30d)
4

Description

### Report hasn't been filed before.

- [x] I have verified that the bug I'm about to report hasn't been filed before.

### What version of `drizzle-orm` are you using?

1.0.0-rc.4

### What version of `drizzle-kit` are you using?

1.0.0-rc.4

### Other packages

@libsql/client@0.17.4

### Describe the Bug

When `drizzle-kit push` (SQLite) recreates a table (`__new_` + `INSERT INTO ... SELECT` + `DROP` + `RENAME`), a column declared `.unique()` **loses its UNIQUE constraint if that same column also has a plain (non-unique) `index()`** declared on it. The emitted `CREATE TABLE __new_...` omits the inline `UNIQUE` for that column and no `CREATE UNIQUE INDEX` is emitted either — uniqueness is silently destroyed. The plan applies **without any prompt or warning**.

A sibling `.unique()` column *without* a plain index keeps its constraint, which isolates the trigger to the `.unique()` + `index()` combination.

**Reproduction**

1. Create the database out-of-band (simulates an existing production DB):

```sql
-- sqlite3 a.db
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
batch_id INTEGER NOT NULL UNIQUE,
label TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX orders_sku_idx ON orders(sku);
INSERT INTO orders (sku, batch_id, label) VALUES ('SKU-1', 100, 'first'), ('SKU-2', 200, 'second');
```

2. `schema.ts` (the missing `created_at` default is only there to trigger a table recreate — any recreate trigger reproduces it):

```ts
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const orders = sqliteTable(
'orders',
{
id: integer('id').primaryKey({ autoIncrement: true }),
sku: text('sku').notNull().unique(),
batchId: integer('batch_id').notNull().unique(),
label: text('label'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('orders_sku_idx').on(t.sku)]
);
```

3. `drizzle.config.ts`:

```ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './schema.ts',
out: './out',
dialect: 'sqlite',
dbCredentials: { url: 'file:a.db' },
});
```

4. `drizzle-kit push --verbose` — emitted SQL (applied with no prompt):

```sql
PRAGMA foreign_keys=OFF;
CREATE TABLE `__new_orders` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`sku` text NOT NULL, -- UNIQUE is GONE
`batch_id` integer NOT NULL UNIQUE, -- kept (no plain index on this one)
`label` text,
`created_at` integer NOT NULL
);
INSERT INTO `__new_orders`(`id`, `sku`, `batch_id`, `label`, `created_at`) SELECT `id`, `sku`, `batch_id`, `label`, `created_at` FROM `orders`;
DROP TABLE `orders`;
ALTER TABLE `__new_orders` RENAME TO `orders`;
PRAGMA foreign_keys=ON;
CREATE INDEX `orders_sku_idx` ON `orders` (`sku`);
```

5. Proof of silent integrity loss:

```sql
INSERT INTO orders (sku, batch_id, created_at) VALUES ('SKU-1', 999, 0);
-- succeeds; SELECT count(*) FROM orders WHERE sku='SKU-1' → 2
```

**Expected**: the recreated table keeps `sku` UNIQUE (inline or via a unique index), or at minimum push warns before removing a uniqueness guarantee.

**Actual**: uniqueness silently removed; duplicate rows accepted afterwards. On a real production database this destroys unique constraints across every table that pairs `.unique()` with a query index on the same column (a very common pattern: unique business key + covering index).

Possibly related (but distinct — those are re-add/duplicate-statement issues, not silent loss): #4152, #3574, #2888, #5955.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.