Skip to content

Drizzle: no standalone repair for a broken encrypted ALTER COLUMN migration #710

Description

@tobyhede

Status — re-verified 2026-07-30 against main (2d7ef563)

Reopened 2026-07-30. This was closed 2026-07-22 as "Fixed in #728", but #728 did not implement it: its closingIssuesReferences is [693], not this issue, and its body carries a section headed "Not in scope (filed as #710**)"** which reads: "The complete fix is a standalone repair command (stash eql repair --drizzle, or a check in stash encrypt plan)… Filed as #710 rather than expanding this PR's surface."

The ask remains unmet on main (2d7ef563). packages/cli/src/cli/registry.ts declares only eql install (:307), eql migration (:325), eql upgrade (:364) and eql status (:369) — no repair. The sweep is not folded into stash encrypt plan: packages/cli/src/commands/encrypt/plan.ts is 61 lines that read .cipherstash/migrations.json and never touch a drizzle output directory. rewriteEncryptedAlterColumns has exactly two production call sites repo-wide — packages/cli/src/commands/eql/migration.ts:270 and packages/wizard/src/lib/rewrite-migrations.ts:1312 (reached from post-agent.ts's sweepMigrationDirs).

Everything below has been re-verified against 2d7ef563; the earlier revision of this issue was written against c8b1325a, since superseded by #838/#839/#840. Sections corrected in that pass are marked.

The sweep runs at the wrong moment

In the intended v3 flow the sweep sees an empty directory:

1. stash eql migration --drizzle   -> writes 0000_install-eql.sql, sweeps (nothing there yet)
2. edit the schema to use v3 columns
3. drizzle-kit generate            -> writes 0001 with the broken ALTER COLUMN
4. drizzle-kit migrate             -> fails

The broken migration is created at step 3, after the sweep has already run. The only way to get it repaired today is to run stash eql migration --drizzle a second time at step 4 — which works, and is how people actually find this, but it means generating a redundant install migration to trigger an unrelated repair.

Corrected 2026-07-30 — the v2 comparison is dead. An earlier revision noted "the same shape applies to the v2 path (stash eql install --drizzle, install.ts step 5); this is not new to v3." That flag no longer exists: packages/cli/src/commands/db/install.ts:41 returns "eql install --drizzle has been removed. Generate an EQL v3 Drizzle migration with stash eql migration --drizzle". This strengthens the case rather than weakening it — there is now exactly one command in the CLI that can run the sweep, and reaching it still requires generating a migration the user does not want.

Proposal

A standalone repair a user can run at step 4:

  • stash eql repair --drizzle (or similar), sweeping the migrations directory and reporting what it rewrote; or
  • fold the check into stash encrypt plan, which already inspects intent vs. observed state and is the command a user is likely to reach for next.

Either removes the need to regenerate an install migration to get the repair. The standalone command is the stronger option: it is the only one of the two that has a natural place to accept --database-url and consult applied state (see below).

Known hazard to handle in the design

Corrected 2026-07-30 (twice). The original text described a hash-based re-run mechanism that does not exist; that correction stands and has now been extended to the actual command in the repro. A second correction narrows the jsonb exception.

The sweep is unfiltered — it rewrites any matching file regardless of whether it has already been applied. It reads neither meta/_journal.json nor __drizzle_migrations; its only exclusion is options.skip, the single file eql migration just wrote. That is safe for essentially every match, because a matching statement is un-runnable by construction: EQL v3 columns are Postgres domains over jsonb, so there is no assignment cast from text/numeric to one, and the mangled "undefined"."eql_v3_*" form names a schema that does not exist. Either way the migration failed on apply and is unapplied.

The jsonb exception, narrowed. A jsonb column changed to a v3 domain is base-type-compatible, so the ALTER can succeed — but only when the column is empty, or already holds valid EQL envelopes. The domains carry a CHECK on the envelope shape (skills/stash-postgres/SKILL.md:8,43,50 — the failure reads value for domain eql_v3_… violates check constraint), so on a populated plaintext-jsonb table the ALTER fails that CHECK and the migration is unapplied like every other case. The applied-migration hazard is therefore real but confined to empty tables — which is exactly the case the in-place rewrite targets, so it is not a rare corner.

Why the original "it will re-run" claim was wrong. Drizzle's applied-check is timestamp-based, not hash-baseddrizzle-orm@0.45.2 pg-core/dialect.js:

62:  if (!lastDbMigration || Number(lastDbMigration.created_at) < migration.folderMillis) {
67:      sql`insert into … ("hash", "created_at") values(${migration.hash}, ${migration.folderMillis})`

The hash is written on insert at :67 and never read for the decision; folderMillis comes from the journal's when (migrator.js:22). The sweep does not touch meta/_journal.json, so nothing re-runs on the original database. Extended 2026-07-30: this was originally verified only against the ORM, while the repro flow is drizzle-kit migrate. drizzle-kit 0.31.10's bin.cjs delegates to drizzle-orm/<driver>/migrator for every Postgres driver (:78880 node-postgres, :78951 postgres-js, :79001 vercel-postgres, :79080 neon-serverless, :78829 pglite, :78767 aws-data-api), so the same timestamp logic governs the command users actually run.

The real residual hazard is cross-environment drift. Rewriting an already-applied migration leaves the .sql describing a shape the database never got from it. Dev, which applied the original, has payload eql_v3_json. A fresh staging or CI database replaying the rewritten file gets payload jsonb + payload_encrypted. The two environments silently diverge, and nothing in the migration history records that they should.

Partly mitigated on main, guidance-only. #839 added describeStagedReconciliation (packages/cli/src/commands/db/rewrite-migrations.ts:916-955), which names the post-sweep divergence precisely — database has both columns, schema.ts and meta/*_snapshot.json know neither — and walks the user through reconciling. That addresses the schema-vs-SQL divergence a sweep leaves behind. It does not address this one: nothing refuses to rewrite an already-applied migration, so the cross-environment drift is unmitigated. A standalone repair command is still the right place — it can consult the drizzle journal (and, given --database-url, __drizzle_migrations) and refuse to touch an applied migration, which the offline migration-first path cannot.

What is no longer in scope

Both follow-ups recorded in the comments below have been overtaken by #823, #836 and #839, and are not part of this issue's remaining work:

  1. Column metadata / dependent constraints not preserved — moot. The rewrite is add-only since Make the Drizzle EQL migration rewriter non-destructive and fail closed #823: renderSafeAlter (rewrite-migrations.ts:1213-1240) emits a single ALTER TABLE … ADD COLUMN "<col>_encrypted" and preserves the source column. Nothing is dropped or renamed, so there is nothing to carry over. The scoped task of re-emitting recoverable indexes/uniques and naming the unrecoverable NOT NULL/DEFAULT falls away with it, and the guidance half is now covered by describeStagedReconciliation.
  2. Matching is not SQL-context-aware — done. isInsideCommentOrString gates the rewrite pass and five of the six index scans; Drizzle migration rewriter: correctness follow-ups to #823 #836 closed the remaining dollar-quoted-DDL hole.

Remaining scope

A standalone repair entry point, plus applied-migration awareness: read meta/_journal.json offline, and __drizzle_migrations when --database-url is supplied, and refuse to rewrite a migration that has already been applied rather than silently creating cross-environment drift.

Notes

An ordering filter (only sweep entries after the install migration) was considered and rejected: whether the broken migration sorts before or after the install migration depends on when each was generated, so the filter would suppress the repair in exactly the recovery path above.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions