drizzle-team / drizzle-team/drizzle-orm
[BUG]: push — composite PK column order lost on introspection causes permanent phantom drift, and the statement it emits is two commands in one string
- Dominant language
- TypeScript
- Stars
- 35.8k
- Forks
- 1.6k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 4
Description
### What version of `drizzle-orm` are you using?
0.45.2
### What version of `drizzle-kit` are you using?
0.31.10
### Describe the Bug
A table with a composite primary key produces **permanent phantom drift** on `drizzle-kit push` — every push wants to drop and re-add the same primary key, forever, with no schema change on either side. On PGlite the statement it emits then fails outright, because it is two commands in one string.
Two defects, one behind the other.
**1. Composite primary key column ORDER is lost on introspection.**
`pgPushIntrospect` reads constraint columns from `information_schema.constraint_column_usage`:
```sql
SELECT c.column_name, c.data_type, constraint_type, constraint_name, constraint_schema
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage AS ccu USING (constraint_schema, constraint_name)
JOIN information_schema.columns AS c ON c.table_schema = tc.constraint_schema
AND tc.table_name = c.table_name AND ccu.column_name = c.column_name
WHERE tc.table_name = '…' and constraint_schema = '…';
```
…and then builds the PK from the row order it happens to get:
```js
const cprimaryKey = tableConstraints.filter((r) => r.constraint_type === "PRIMARY KEY")
if (cprimaryKey.length > 1) {
primaryKeys[name] = { name, columns: cprimaryKey.map((c) => c.column_name) }
}
```
`constraint_column_usage` carries **no position within the constraint** (unlike `key_column_usage.ordinal_position`), and the query has no `ORDER BY`. So the order is whatever the join yields.
Reproduction, against a database whose PK really is `(service, day)`:
```ts
export const apiUsage = pgTable("api_usage", {
service: text("service").notNull(),
day: text("day").notNull(),
count: integer("count").notNull().default(0),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => [primaryKey({ columns: [t.service, t.day] })])
```
```console
$ psql -c "SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname='api_usage_service_day_pk'"
PRIMARY KEY (service, day)
```
but drizzle-kit's own introspection query returns the PK rows as `day`, then `service`. Snapshot column order `["day","service"]` vs schema `["service","day"]` → `alter_composite_pk` on every push. **No edit to the schema can settle it**, because neither side is wrong about the database; the order was simply never read.
(Note `key_column_usage` *does* expose `ordinal_position`, and the MySQL/SQLite introspection in the same file selects it and orders by it. Only the Postgres path uses `constraint_column_usage`.)
**2. The statement it emits is two commands in one string, which `push` cannot execute.**
`PgAlterTableAlterCompositePrimaryKeyConvertor.convert` returns the DROP and the ADD joined by the **file** delimiter:
```js
return `ALTER TABLE ${t} DROP CONSTRAINT "${statement.oldConstraintName}";
${BREAKPOINT}ALTER TABLE ${t} ADD CONSTRAINT "${statement.newConstraintName}" PRIMARY KEY("${newColumns.join('","')}");`
```
`BREAKPOINT` is `"--> statement-breakpoint\n"` — the marker `generate` writes into a `.sql` file for the migrator to split on. `push` does not split; it hands the string straight to the driver:
```js
for (const dStmnt of statementsToExecute) await db.query(dStmnt)
```
The extended query protocol takes exactly one command, so on PGlite this is:
```console
$ npx drizzle-kit push --force
[✓] Pulling schema from database...
error: cannot insert multiple commands into a prepared statement
code: '42601',
routine: 'exec_parse_message',
query: 'ALTER TABLE "api_usage" DROP CONSTRAINT "api_usage_service_day_pk";\n' +
'--> statement-breakpoint\n' +
'ALTER TABLE "api_usage" ADD CONSTRAINT "api_usage_service_day_pk" PRIMARY KEY("service","day");',
```
Against a driver that uses the simple query protocol the two commands do execute, so there the symptom is the milder one: a pointless drop-and-re-add of the primary key on every single push, in perpetuity.
Because `pgPush` swallows the error and exits 0 (filed separately), the failure also takes every later statement with it silently — for us, five `CHECK` constraints that sort after this statement were never applied while the command reported success.
### Expected behavior
- Composite primary key column order is read in constraint order (e.g. `key_column_usage` with `ORDER BY ordinal_position`, or `pg_constraint.conkey`), so a table whose PK matches its schema produces no statements.
- `alter_composite_pk` yields **two** statements, not one string containing a file-level delimiter, so `push` can execute what it generates.
### Environment & setup
Node 26.4.0, `@electric-sql/pglite` 0.5.4, `driver: "pglite"`. Reproduces on every push against a database with any composite primary key whose introspected column order differs from the declared order.
Contributor guide
Research direction
Start with the PostgreSQL path in pgPushIntrospect and the PgAlterTableAlterCompositePrimaryKeyConvertor.convert entry point, then trace how the push execution loop passes generated statements to the driver. Verify composite primary-key columns retain constraint order and that alter_composite_pk produces separately executable statements without the file-level breakpoint; confirm matching schemas produce no drift.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nodejs, postgresql, typescript
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100