Skip to content

fix(cli,wizard): correctness follow-ups to the Drizzle EQL migration rewriter - #839

Merged
tobyhede merged 6 commits into
mainfrom
fix/issue-836
Jul 30, 2026
Merged

fix(cli,wizard): correctness follow-ups to the Drizzle EQL migration rewriter#839
tobyhede merged 6 commits into
mainfrom
fix/issue-836

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #836.

Three silent-wrongness defects in the Drizzle EQL migration rewriter, all of which exited 0 while leaving the user in a wrong state. Neither of the first two was data loss — the rewrite never emits DROP COLUMN or RENAME COLUMN — but neither was visible either.

Sequencing note

#836 says to land #837's skills/stash-drizzle/SKILL.md:64 correction first, because item 2 adds guidance to that same paragraph and editing in the other order silently reinstates the false "data-destroying / safe only on an empty table" claim. That correction already existed as an unpushed local commit (e3695929), so it is cherry-picked as the first commit here rather than waiting on #837. The remaining #837 items are untouched and still belong to that issue.

1. Dollar-quoted DDL no longer bypasses the already-encrypted guard

isInsideCommentOrString skips a dollar-quoted body whole. Correct for the rewrite pass — the body is PL/pgSQL and renderSafeAlter returns multiple lines — but wrong for the index pass: DDL inside DO $$ … END $$; is executed SQL, so an encrypted ADD COLUMN there means the column really holds ciphertext. It never entered encrypted, fell to "plaintext by residue", and the sweep staged an empty twin beside the real ciphertext.

The index now reads dollar-quoted bodies for the encrypted side only. That is the asymmetry the file already relies on: over-detecting encrypted costs a flagged statement, over-detecting declared is the fail-open direction. A DO $$ body is conditional PL/pgSQL, so a plaintext declaration inside one still does not count as declaring the column.

target-exists now also consults encrypted — the sets are not nested, and a twin added inside a dollar body was previously invisible to it, producing a duplicate ADD COLUMN that fails at migrate time.

Decision taken (the issue left this open): index the bodies, rather than fail closed on any table touched inside one. A blanket fail-closed would trip on drizzle-kit's own DO $$ … CREATE TYPE … EXCEPTION enum idiom and flag every ALTER in such a corpus — for a residual (dynamic EXECUTE SQL) that is the same corpus-invisible class as the psql/dashboard drift skills/stash-cli already documents. A test pins that idiom as still rewritable.

Three pre-existing expectations tightened, all toward fail-closed — including the one #823 codified backwards at rewrite-migrations.test.ts:977-998. Both rename corpora now report already-encrypted rather than target-exists: after those renames email is the ciphertext, and target-exists's "review the existing encrypted twin" pointed at a column the rename had consumed.

One implementation constraint is load-bearing and worth a reviewer's eye. dollarQuotedBodies tracks comment/string state itself instead of asking isInsideCommentOrString about each $. That predicate rescans from index 0 on every call, so the per-opener form is quadratic — and the corpus this sweeps is the drizzle output directory holding the ~2.6 MB EQL install migration the command has just written, which is itself thousands of small $$ PL/pgSQL bodies. Measured there: ~0.4 s single-pass vs ~8.5 s per-opener for the whole sweep. It reuses the same token helpers so tokenisation is unchanged (" before ', nested block comments by depth, unterminated tokens ending the walk), and a regression guard sweeps a 2.1 MB dollar-quote-heavy corpus with a 15 s bound — ~30x above linear so a slow runner stays comfortable, ~3x below quadratic. It measures ~70 ms as written and 41 s against the per-opener form.

2. A successful sweep now reports the artefact divergence it leaves

After a sweep the database has email text plus email_encrypted eql_v3_text_search, while schema.ts still declares email as the domain — that declaration is what made drizzle-kit emit the impossible SET DATA TYPE — and meta/*_snapshot.json records the same. Neither knows the twin exists.

drizzle-kit generate gives no signal: it diffs schema against snapshot, reads neither the .sql nor the database, and both inputs still agree. So every consequence was silent — reads hand plaintext to a fromDriver expecting an EQL envelope, writes push an envelope into a text column and succeed, and the twin is unreachable.

RewriteResult now carries staged: StagedColumn[], and describeStagedReconciliation turns it into guidance naming the table, both columns and the domain. Both callers print it; the wizard prints it before its migrate prompt. The partial-sweep path reports it too.

Decision taken: warn, not write the schema.ts edit. Editing it alone creates the duplicate-column failure, so a correct automatic fix would have to rewrite drizzle-kit's snapshot in lockstep, in a version-coupled internal format that is not ours. stash encrypt plan was rejected as too late — it runs later and may never run.

3. Wizard sweep reporting survives a non-Error throw

sweepMigrationDirs read its partial result off an unchecked cast, so throw null raised a TypeError inside the catch meant to report the failure — skipping the error result and abandoning the remaining directories. It now narrows. Rather than duplicate the CLI's predicate, PartialRewriteResult/isPartialRewriteResult move into the shared part of both copies, so the parity guard polices them — which it could not while the wizard's version sat inside #region wizard-only.

This was the only unresolved review thread on merged #823.

Review round 2 — fixes applied

Self-review found the reconciliation guidance was wrong in a way that undercut item 2's whole purpose, plus a real bug behind it. Fixed in f33f21b, TDD with regression tests.

The guidance walked the user into the error it warned about. It said to keep the source column "as its plaintext type" and run generate to advance the snapshot, warning separately against editing "only" the schema. Both halves were off: after a sweep schema.ts declares the source column as the domain (that declaration is what produced the invalid ALTER), so it must be set back to plaintext; and the duplicate ADD COLUMN isn't caused by partial editing — the snapshot has never seen the twin, so generate always emits an ADD COLUMN for it, and the swept migration already added that column. The old text produced column already exists either way.

The notice and both skills now give the sequence that reaches a working state: fix the schema, run generate, then delete the regenerated ADD COLUMN for the twin (keeping the snapshot advance, which is the point). IF NOT EXISTS noted as equivalent. skills/stash-drizzle also documents the cleaner path when the swept migration hasn't been applied yet — revert, remove the migration and its journal/snapshot entries, and use the canonical rollout.

staged could name a column that was never written. Entries are produced inside .replace(), which runs before writeFile, so a sweep throwing on a later file attached twins from a failed write — and the notice claims "the database now has" them. Now buffered per file and committed only after that file's write succeeds. stagedTargets (the in-run duplicate guard) is deliberately not rolled back. Surfaced by writing the test for the previously uncovered partial-sweep path: it reproduced as ['email', 'name'] where only email reached disk.

StagedColumn.file is now read — the per-column line names the migration the twin was staged in, rather than carrying a field nothing consumed.

Coverage for the three paths this PR added but never exercised, each verified to fail when the code it covers is removed:

  • describeStagedReconciliation must say "back to its plaintext type" and name a delete-the-ADD COLUMN step; must not say "keep the source column" or blame partial editing
  • eql migration --drizzle reports staged twins from a sweep that threw part way through
  • sweepMigrationDirs carries staged twins out of a failed directory — and only those that reached disk

Review round 3 — fixes applied

Three findings raised as minor/nit. Two addressed as described; one verdict overturned. 3e11af6a.

The rename-chain residue was reachable, so it is fixed rather than documented. The live rename pass ran before the dollar-quoted one, so a chain crossing dollar→live inside one file lost the type — ADD COLUMN "tmp" <domain> live, RENAME tmp TO mid inside DO $$, then live RENAME mid TO email — leaving email looking like plaintext and staging an empty twin beside its ciphertext.

It had been accepted on the grounds that drizzle-kit never emits RENAME COLUMN inside DO $$. That is true but the wrong reachability test: the corpus #811 actually reported is hand-written and does exactly this, a human staging a manual column swap — it is the reported two-file DO $$ rename corpus fixture in this very test file. What matters here is what people write by hand, not what the generator emits.

Both rename passes now iterate to a fixpoint, so pass order stops mattering and chains follow in either direction. Terminates (both sets only grow, bounded by distinct column keys) and converges one iteration past the longest chain. The perf guard is unmoved at ~70 ms. Tests cover both directions so a future reordering can't break one while the other stays green.

The wizard notice now gates on staged.length > 0, matching the CLI. Equivalent today — a rewritten file always yields ≥1 staged column, and both are recorded only after the write succeeds — so consistency rather than behaviour, and no test can distinguish them. Correct on the merits: the notice describes staged columns, so it should be driven by whether there are any.

The perf guard's documented margin was understated, which is what made it look marginal. The comment claimed ~30x, derived from a hand-waved "well under a second". Measured, the sweep over that corpus is 67-71 ms against a 15 s bound — ~200x. Corrected in the comment, with the flake question answered concretely: tripping it needs a machine ~200x slower at scanning a string than a developer laptop, where CI is 2-5x. Kept rather than restructured, with a note to raise the bound rather than delete the test if it ever fires, since it guards shipped-command latency. Happy to swap it for a deterministic static check that dollarQuotedBodies doesn't call the per-index predicate, if you'd rather have zero wall-clock assertions — that trades "catches any quadratic implementation" for "catches this one deterministically".

Verification

  • stash 917 passed / 60 files, at the default 5 s timeout
  • @cipherstash/wizard 364 passed, 5 skipped
  • rewriter copy parity guard: 4 passed
  • pnpm run build: 9/9
  • pnpm run code:check: 0 errors (178 warnings, as on main)
  • Every --flag the eql migration skill section names resolves against stash manifest --json
  • Bisectable: the intermediate commit passes both rewriter suites and the parity guard standing alone

@cipherstash/stack and @cipherstash/prisma-next tests fail locally for want of CS_* credentials — verified identical on pristine c8b1325a, and no code in either package is touched.

Changeset: stash patch, @cipherstash/wizard patch (skills ship in the stash tarball).

Summary by CodeRabbit

  • Bug Fixes

    • Improved encrypted-column migration rewriting inside PostgreSQL DO $$ ... END $$ blocks.
    • Prevented duplicate encrypted-column additions and unsafe rewrites when encrypted declarations already exist.
    • Improved handling of partial migration failures and nonstandard errors.
  • New Features

    • CLI and wizard now report staged encrypted-column changes and provide reconciliation steps for schema and snapshot differences.
  • Documentation

    • Added guidance for safely reconciling generated migrations and completing encrypted-column migrations.

`skills/stash-drizzle/SKILL.md` still told Drizzle users the sweep's repair is
"data-destroying", safe "only on an empty table", and that a populated table
must **not** apply the swept migration. That was true on remove-v2 and this
branch makes it false — the repair is add-only and preserves the source column.

It matters more than a stale sentence: SKILL_MAP.drizzle installs both
stash-drizzle and stash-cli into every Drizzle project, so the two skills
contradicted each other in the customer's repo, with the wrong one telling
their agent not to apply a migration that is now safe.

Per the AGENTS.md skill map, a Drizzle-integration change must check
skills/stash-drizzle as well as skills/stash-cli. Missed in the original PR and
carried through the rebase. (skills/ ships in the stash tarball; the existing
changeset already scopes stash as a patch.)

Also pins 'switch the application to the' in the CLI rewriter test, which the
wizard twin already asserted — the parity guard compares the two source files,
not the two test files, so the drift was invisible to CI.
@tobyhede
tobyhede requested a review from a team as a code owner July 30, 2026 00:40
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e11af6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Patch
@cipherstash/wizard Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/prisma-next Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The migration rewriters now detect encrypted DDL inside dollar-quoted bodies, stage encrypted twin metadata only after successful writes, and expose reconciliation guidance through the CLI and wizard. Tests and documentation cover fail-closed behavior, partial sweeps, error narrowing, and Drizzle schema reconciliation.

Changes

Encrypted migration rewriting

Layer / File(s) Summary
Dollar-quoted encrypted indexing
packages/cli/src/commands/db/rewrite-migrations.ts, packages/wizard/src/lib/rewrite-migrations.ts, packages/{cli,wizard}/src/**/rewrite-migrations.test.ts
Live dollar-quoted bodies are scanned for encrypted declarations while comments, strings, renames, and unterminated bodies retain the specified fail-closed behavior.
Staged twin state and partial results
packages/{cli,wizard}/src/**/rewrite-migrations.ts, packages/{cli,wizard}/src/**/rewrite-migrations.test.ts
Rewrite results include staged twin metadata, target-exists checks cover declared and encrypted targets, and staged entries are committed only after successful file writes.
CLI and wizard reconciliation reporting
packages/cli/src/commands/eql/migration.ts, packages/wizard/src/lib/post-agent.ts, packages/{cli,wizard}/src/**/migration.test.ts
Successful and partial sweeps report reconciliation guidance, aggregate staged entries, and safely handle non-object sweep errors.
Reconciliation documentation
.changeset/*, skills/stash-cli/SKILL.md, skills/stash-drizzle/SKILL.md
Documentation describes dollar-quoted fail-closed handling, staged twin divergence, and the Drizzle schema and snapshot reconciliation steps.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • #837 — Covers the wizard sweep error-handling gap and shared Drizzle guidance updated by this change.

Possibly related PRs

Suggested reviewers: coderdan, calvinbrewer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code and docs address all #836 requirements: dollar-quoted indexing, staged reconciliation guidance, and fail-closed wizard sweep handling.
Out of Scope Changes check ✅ Passed The changes stay focused on the rewriter, wizard, tests, and related skill docs without introducing unrelated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: correctness fixes to the Drizzle EQL migration rewriter across CLI and wizard.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-836

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

tobyhede added 3 commits July 30, 2026 10:45
…rd holds

Issue #836, item 1. `isInsideCommentOrString` skips a dollar-quoted body WHOLE.
That is correct for the rewrite pass — the body is PL/pgSQL and `renderSafeAlter`
returns multiple lines — but wrong for `indexColumnDeclarations`: DDL inside
`DO $$ … END $$;` is EXECUTED SQL, so an encrypted `ADD COLUMN` there means the
column really does hold ciphertext.

It never entered the `encrypted` set, so the column fell to "plaintext by
residue" and the sweep staged an empty `<col>_encrypted` twin beside the real
ciphertext — `rewritten` listing the file, `skipped` empty, exit code 0. This is
the mechanism #811 reported; #823 closed it by removing the blast radius
(add-only emission), not by closing the mechanism.

The index now reads dollar-quoted bodies via `dollarQuotedBodies`, for the
ENCRYPTED side only. That is the asymmetry the per-column `CREATE TABLE` loops
already rely on: over-detecting "encrypted" costs a flagged statement, while
over-detecting "declared" is what puts a column back on the fail-open path. A
`DO $$` body is conditional PL/pgSQL — an `IF … THEN` arm, an `EXCEPTION`
handler — so a plaintext declaration inside one is not proof the column exists
and is deliberately not recorded. An inert body (inside a `--` comment, or
quoted in an `INSERT … VALUES`) stays inert; the rewrite pass is untouched.

`target-exists` now also consults `encrypted`. The two sets are not nested: a
twin added inside a dollar body is `encrypted` without ever being `declared`,
and emitting a second ADD COLUMN for it fails with "column already exists".

`dollarQuotedBodies` tracks comment/string state itself rather than asking
`isInsideCommentOrString` about each `$`, and that is load-bearing, not a
stylistic choice: the predicate rescans from index 0 on every call, so the
per-opener form is quadratic. The corpus this sweeps is the drizzle output
directory holding the ~2.6 MB EQL install migration the command has just
written, and that file is itself thousands of small `$$` PL/pgSQL bodies —
measured over it, the whole sweep runs ~0.4 s as a single pass versus ~8.5 s
per-opener. It reuses the same token helpers (`dollarQuoteDelimiter`,
`endOfQuoted`, `isEscapeStringOpener`) so the tokenisation rules stay identical:
`"` consumed before `'`, nested block comments tracked by depth, and an
unterminated comment, literal or dollar body ending the walk. A regression guard
sweeps a 2.1 MB dollar-quote-heavy corpus with a 15 s bound — ~30x above the
linear time so a slow shared runner stays comfortable, ~3x below the quadratic
time; it measures ~70 ms as written and 41 s against the per-opener form.

The alternative in the issue — fail closed on any table touched inside a
dollar-quoted body — was rejected. It would trip on drizzle-kit's own
`DO $$ … CREATE TYPE … EXCEPTION` enum idiom and flag every ALTER in such a
corpus, for a residual (dynamic `EXECUTE` SQL) that is the same
corpus-invisible class as the psql/dashboard drift `skills/stash-cli` already
documents. A test pins that idiom as still rewritable.

Three pre-existing expectations tightened, all toward fail-closed:

- the `DO $$` ADD COLUMN corpus no longer rewrites at all. #823's own test
  codified the wrong outcome here (`rewrite-migrations.test.ts:977-998`).
- both rename corpora report `already-encrypted` rather than `target-exists`.
  After those renames `email` IS the ciphertext, and `target-exists`'s "review
  the existing encrypted twin" pointed at a column the rename had consumed.
- an unterminated `$$` now flags rather than rewrites. The file is unparseable,
  so Postgres ran none of it and nothing after the opener is proven.
Issue #836, item 2. `renderSafeAlter` is add-only, so after a sweep the database
has `email text` PLUS `email_encrypted eql_v3_text_search`, while `schema.ts`
still declares `email` as the encrypted domain — that declaration is what made
drizzle-kit emit the impossible `SET DATA TYPE` in the first place — and
`meta/*_snapshot.json` records the same. Neither knows the twin exists.

`drizzle-kit generate` gives no signal: it diffs `schema.ts` against the
snapshot, never reads `.sql`, and never introspects the database. Both inputs
still agree, so the diff is empty. Every consequence through the ORM is
therefore silent — reads of `users.email` hand plaintext to a
`customType.fromDriver` expecting an EQL envelope, writes push an envelope into
a `text` column and SUCCEED, and `email_encrypted` is unreachable because it is
in no Drizzle schema. It only fails loudly much later: correcting `schema.ts` by
hand makes `generate` diff against the stale snapshot and emit a duplicate ADD
COLUMN, which errors with "column already exists".

`RewriteResult` now carries `staged: StagedColumn[]` — one entry per rewritten
statement, so it is finer-grained than `rewritten`, which lists files — and
`describeStagedReconciliation` turns it into guidance naming the table, both
columns and the domain. `stash eql migration --drizzle` and the wizard's
post-agent step both print it, the wizard before its migrate prompt so the user
decides with it in hand. The partial-sweep path reports it too: those twins are
already on disk and already divergent.

Warn rather than exit non-zero — the swept SQL is valid and additive, and which
way to reconcile is the user's call.

Writing the `schema.ts` edit was rejected: editing it alone CREATES the
duplicate-column failure above, so a correct automatic fix would have to rewrite
drizzle-kit's snapshot in lockstep, in an internal format that is
version-coupled and not ours. Detecting it in `stash encrypt plan` was rejected
as too late — it runs later, and may never run.

Item 3, same subsystem. `sweepMigrationDirs` read its partial result off an
unchecked `err as Partial<RewriteSweepError>`. For a non-object throw
(`throw null`) that property read raises a TypeError INSIDE the catch, so the
per-directory error result is never pushed, the throw escapes
`sweepMigrationDirs` entirely, and the remaining directories go unswept — the
fail-closed reporting the catch exists to provide simply does not happen.

The CLI had been hardened against exactly this; the wizard had not, and it was
the only unresolved review thread on merged #823. Rather than duplicate the
predicate, `PartialRewriteResult`/`isPartialRewriteResult` move into the shared
part of both rewriter copies — so the parity guard now polices them, which it
could not do while the wizard's version sat inside `#region wizard-only` — and
`eql/migration.ts` imports instead of defining its own.

A test forces `throw null` through the writeFile spy and pins that the directory
is still reported and the sweep still continues; it fails with the exact
"Cannot read properties of null (reading 'rewritten')" against the old cast.
… $$ rule

Per AGENTS.md, a change to the CLI command surface or a user-facing workflow must
update the affected shipped skills in the same PR — `skills/` ships inside the
`stash` tarball and is copied into customer repos, so drift here becomes wrong
guidance in someone else's codebase.

`skills/stash-drizzle` gains the reconciliation step for #836 item 2: a table of
what the database, `schema.ts` and `meta/*_snapshot.json` each say after a sweep,
why `drizzle-kit generate` reports nothing (it diffs schema against snapshot and
reads neither the `.sql` nor the database — introspection is `push`), the three
silent ORM consequences, and the corrected `schema.ts` with both columns
declared. It also warns against the half-fix: changing only the source column's
type and re-generating against the stale snapshot emits a duplicate ADD COLUMN
that fails with `column already exists`.

This lands on the paragraph the preceding commit corrected for #837, which is why
that correction had to come first — writing this on top of the old
"data-destroying / safe only on an empty table" text would have silently
reinstated it.

`skills/stash-cli` gains the same reconciliation note, scoped to what the command
prints, plus the fail-closed rule's new detail: a declaration inside a
`DO $$ … END $$;` block counts toward "already encrypted", while a plaintext one
inside such a block does not count as declaring the column. Dynamic `EXECUTE`
SQL joins hand-psql and dashboard edits in the list of corpus-invisible drift.

Verified against the registry per AGENTS.md — every `--flag` the `eql migration`
section names resolves against `stash manifest --json` (`--eql-version` appears
only in the sentence saying it does not exist).

Changeset: `stash` patch, `@cipherstash/wizard` patch.
… work

Review of #839 found the remediation text walked the user into the very failure
it warned about, and that the notice could name a column that was never written.

**The guidance was wrong.** It said to declare the twin under its own name,
"keep the source column as its plaintext type", then run `drizzle-kit generate`
to bring the snapshot forward — and warned separately against hand-editing
"only" the schema. Both halves were off:

- After a sweep `schema.ts` declares the SOURCE column as the encrypted domain;
  that declaration is what produced the invalid ALTER. So it has to be set BACK
  to plaintext — "keep" describes a state the schema is not in.
- The duplicate `ADD COLUMN` is not caused by editing partially. The snapshot has
  never seen the twin, so `generate` ALWAYS emits an `ADD COLUMN` for it, and the
  swept migration already added that column — applying both fails with "column
  already exists". Following the old text produced exactly that error, whether or
  not the source column was reverted.

The notice and both skills now give the sequence that reaches a working state:
fix the schema (twin under its own name, source back to plaintext), run
`generate` to advance the snapshot, then DELETE the regenerated `ADD COLUMN` for
the twin from the migration it wrote — keeping the snapshot advance, which is the
point of the step. `IF NOT EXISTS` is noted as an equivalent. Anything else
`generate` emits, such as the now no-op `SET DATA TYPE` on the source column, can
stay. `skills/stash-drizzle` also documents the cleaner alternative when the
swept migration has not been applied yet: revert the schema, remove that
migration (`.sql`, `meta/*_snapshot.json`, and its `meta/_journal.json` entry) and
follow the canonical staged rollout, which generates the ADD COLUMN itself.

**`staged` could name a column that was never persisted.** The entries are
produced inside `.replace()`, which runs before `writeFile`. A sweep that threw
on a later file therefore attached twins from a file whose write had failed, and
the notice claims "the database now has" those columns. They are now buffered per
file and committed only after that file's write succeeds. `stagedTargets`, the
in-run duplicate guard, is deliberately not rolled back: if a write fails,
treating the target as taken keeps a later file from staging it again.

Found by writing the test for the previously uncovered partial-sweep reporting
path — it reproduced as `['email', 'name']` where only `email` reached disk.

Also surfaces `StagedColumn.file`, which was populated and documented but never
read: the per-column line now names the migration the twin was staged in, so the
user can go read the statement instead of searching the directory.

Coverage for the three paths this PR added but never exercised:

- `describeStagedReconciliation` must say "back to its plaintext type" and must
  name a delete-the-ADD-COLUMN step; it must not say "keep the source column" or
  blame partial editing. Prose assertions, but they pin the two claims that make
  the difference between guidance that works and guidance that fails.
- `eql migration --drizzle` reports staged twins from a sweep that threw part way
  through — fails if the `partial.staged` branch is removed.
- `sweepMigrationDirs` carries staged twins out of a directory whose sweep threw,
  and carries only the ones that reached disk — fails if the `partial.staged ??
  []` carry is dropped.

stash 915 passed (60 files, default timeouts), wizard 362 passed, parity guard 4
passed, build 9/9, code:check 0 errors.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed in an isolated worktree (at f33f21b), running the rewriter + parity test suites myself.

Approving. A careful, correctness-focused follow-up that closes three silent-wrong-state defects in the add-only Drizzle EQL migration rewriter — and every fix moves in the fail-closed direction.

Verified

  • Fix 1: the corpus index now reads dollar-quoted (DO $$ … $$) bodies for the encrypted side only — the safe asymmetry, since over-detecting "encrypted" merely flags a statement for manual review, while over-detecting "declared" is the fail-open path they deliberately avoid. Inert bodies (comment/string) stay inert; unterminated bodies over-read toward flagging.
  • Fix 2: surfaces the schema/snapshot/database three-way divergence with corrected guidance (revert source column + delete the regenerated ADD COLUMN), and only reports twins that actually reached disk (buffered per-file, committed after write).
  • Fix 3: narrows instead of casting so a non-Error throw can't TypeError inside the reporting catch.
  • Both rewriter copies got identical changes (parity guard passes), and the shared predicates were moved out of the wizard-only region so the guard polices them. Tests are genuine behavioral tests against real SQL. Ran: CLI rewrite-migrations (194) + eql migration (38), wizard rewrite-migrations (203) + post-agent (15), copies-in-sync parity (4); code:check exits 0. Changeset (stash + wizard patch) present; stash-cli + stash-drizzle skills updated — and the stash-drizzle change correctly drops the previously-wrong "data-destroying / safe only on an empty table" claim.

Non-blocking findings

  • rewrite-migrations.ts (indexEncryptedRenames, minor): a rename chain dollar-body→live within one file isn't followed (live rename pass runs before the dollar rename pass), so a column made encrypted via a rename inside DO $$ then renamed again by a live statement could stage an empty twin beside real ciphertext. Documented as accepted residue; unreachable in real drizzle-kit output (never emits RENAME COLUMN inside DO $$) and add-only, so no data loss. Acceptable.
  • rewrite-migrations.test.ts:567-591 (+ wizard mirror :1969) (minor): the perf guard asserts wall-clock elapsed < 15_000 — timing assertions are flake-prone on loaded runners. Mitigated by a ~30x margin over ~0.4s linear time and it catches a genuine shippable-latency quadratic regression, but it's a latent source of CI noise.
  • post-agent.ts:~2143 (nit): warns gated on didStage (rewritten>0) whereas the CLI gates on staged.length > 0. The invariant holds, so consistency-only; guarding on sweep.staged.length would match the CLI and be resilient if the invariant ever changes.

Caveat: didn't run a live end-to-end drizzle-kit generate/migrate cycle (reconciliation guidance verified via unit assertions on emitted text) or credentialed integration suites — the column already exists mechanism is well-reasoned regardless.

Review round 3. One accepted-residue verdict overturned, two addressed as raised.

**The rename-chain residue is reachable, so it is now fixed rather than
documented.** `indexColumnDeclarations` ran the live rename pass before the
dollar-quoted one, so a chain crossing dollar→live inside one file lost the
type: `ADD COLUMN "tmp" <domain>` live, `RENAME tmp TO mid` inside `DO $$`, then
a live `RENAME mid TO email` left `email` looking like plaintext and staged an
empty twin beside its ciphertext — the exact defect this branch exists to close.

It was accepted on the grounds that drizzle-kit never emits `RENAME COLUMN`
inside `DO $$`. True, and the wrong reachability test: the corpus #811 actually
reported is hand-written and does precisely this, a human staging a manual column
swap. `rewrite-migrations.test.ts`'s own "reported two-file DO $$ rename corpus"
fixture is that shape. Reachability here is about what people write by hand, not
what the generator emits.

Both rename passes now iterate to a fixpoint, so which one runs first stops
mattering and chains are followed in either direction. It terminates because both
sets only grow and are bounded by the distinct column keys in the corpus, and it
converges one iteration past the longest chain — a corpus with no renames pays a
second scan that finds nothing. The dollar-quote-heavy perf guard is unmoved at
~70 ms. Tests cover both directions, so a future reordering of the passes cannot
break one while the other stays green.

**The wizard's reconciliation notice is now gated on `staged`, matching the
CLI.** The two conditions are equivalent today — a rewritten file always yields
at least one staged column, and both are recorded together only after the write
succeeds — so this is consistency, not a behaviour change, and no test can
distinguish them. The notice describes the staged columns, so it should be driven
by whether there are any rather than by a file count that happens to track them.

**The perf guard's documented margin was understated, which is what made it look
marginal.** The comment claimed ~30x headroom, derived from a hand-waved "well
under a second". Measured, the whole sweep over that corpus is ~67-71 ms against
a 15 s bound: ~200x. Corrected, with the flake question answered concretely —
tripping it needs a runner ~200x slower at scanning a string than a developer
laptop, where CI is 2-5x — and a note to raise the bound rather than delete the
test if it ever does fire, since what it guards is shipped-command latency.

Also drops the now-stale "Residue, accepted" paragraph from
`indexEncryptedRenames`.

stash 917 passed (60 files, default timeouts), wizard 364 passed, parity guard 4
passed, build 9/9, code:check 0 errors.
@tobyhede
tobyhede merged commit 552cd97 into main Jul 30, 2026
10 checks passed
@tobyhede
tobyhede deleted the fix/issue-836 branch July 30, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drizzle migration rewriter: correctness follow-ups to #823

3 participants