Skip to content

Make the Drizzle EQL migration rewriter non-destructive and fail closed - #823

Merged
tobyhede merged 7 commits into
mainfrom
fix/issue-813
Jul 29, 2026
Merged

Make the Drizzle EQL migration rewriter non-destructive and fail closed#823
tobyhede merged 7 commits into
mainfrom
fix/issue-813

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Verification

  • pnpm --filter stash test (954 passed)
  • pnpm --filter @cipherstash/wizard test (335 passed, 5 skipped)
  • pnpm --filter stash build
  • pnpm --filter @cipherstash/wizard build
  • pnpm run code:check

Fixes #813
Fixes #811

Summary by CodeRabbit

  • New Features

    • Migration rewrites now add staged encrypted columns while preserving original source columns.
    • Added clearer guidance for completing staged encryption workflows.
  • Bug Fixes

    • Unsafe or unverifiable migration changes now fail closed with a non-zero exit.
    • Existing encrypted targets are detected and skipped safely.
    • Improved handling of complex SQL, including dollar-quoted statements.
  • Documentation

    • Updated CLI guidance to explain staged rollout steps and migration review requirements.

@tobyhede
tobyhede requested a review from a team as a code owner July 28, 2026 23:55
@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 143a99d

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 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The Drizzle migration rewriters now add staged _encrypted columns while preserving source columns. CLI and wizard flows report partial results and abort on unverified rewrites, duplicate targets, or unsafe SQL. Tests cover parser edge cases, partial writes, and command integration.

Changes

Drizzle migration rewriter

Layer / File(s) Summary
Staged rewrite and safety guards
packages/cli/src/commands/db/rewrite-migrations.ts, packages/wizard/src/lib/rewrite-migrations.ts
Rewrites emit only staged encrypted-column additions, track duplicate targets, classify skip reasons, preserve partial results, and harden dollar-quote scanning.
CLI sweep and abort handling
packages/cli/src/commands/eql/migration.ts, packages/cli/src/commands/eql/__tests__/migration.test.ts
The CLI reports staged rewrites and exits non-zero for unsafe or incomplete sweeps, with stricter argument, preview, and spawn handling.
Wizard sweep and confirmation handling
packages/wizard/src/lib/post-agent.ts, packages/wizard/src/__tests__/post-agent.test.ts
The wizard blocks confirmation when skipped or unverified results exist and updates prompts for staged encrypted columns.
Rewriter and command regression coverage
packages/cli/src/__tests__/rewrite-migrations.test.ts, packages/wizard/src/__tests__/rewrite-migrations.test.ts
Tests cover add-only SQL, target-exists skips, dollar-quoted DDL, chained alterations, and partial write reporting.
Parity checks and documentation
.changeset/staged-add-only-rewriter.md, scripts/__tests__/rewriter-copies-in-sync.test.mjs, skills/stash-cli/SKILL.md
Documentation and synchronization checks describe the staged, fail-closed migration flow.

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

Possibly related issues

Possibly related PRs

Suggested reviewers: calvinbrewer, coderdan, freshtonic

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main non-destructive, fail-closed rewrite change.
Linked Issues check ✅ Passed The changes match the safety, parity, partial-write, and dollar-quote requirements in #813 and #811.
Out of Scope Changes check ✅ Passed The changes stay focused on the rewriter, parity tests, docs, and change-set; no unrelated scope stands out.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
✨ 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-813

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/cli/src/commands/db/rewrite-migrations.ts (1)

831-854: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the "public" rationale to both rewriters or centralize the assumption.

packages/wizard/src/lib/rewrite-migrations.ts documents that "public"."<domain>" is an install-schema assumption, but packages/cli/src/commands/db/rewrite-migrations.ts emits the same unconditional qualifier with only a short comment. Keep the contract in one shared rationale that both copies inherit; otherwise this kind of fix is easily applied to one tool and not the other.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/db/rewrite-migrations.ts` around lines 831 - 854,
Centralize the rationale for the unconditional "public" domain qualifier used by
renderSafeAlter and the corresponding wizard rewriter, or add the same
install-schema explanation to both implementations. Ensure both generated ALTER
statements retain the "public" qualifier while inheriting the shared/documented
contract rather than relying on only the CLI’s brief comment.
packages/cli/src/commands/db/install.ts (1)

672-675: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the partial-result carrier before reading it.

A caught value may be null/undefined; this assertion does not change runtime behavior, so partial.rewritten can throw and mask the intended CliExit(1) path. Narrow error to a non-null object before reading optional fields. As per coding guidelines, source code must avoid type-erasing assertions unless deliberately suppressed with a reason.

Proposed fix
-    const partial = error as Partial<{
-      rewritten: string[]
-      skipped: SkippedAlter[]
-    }>
+    const partial =
+      error !== null && typeof error === 'object'
+        ? (error as Partial<{
+            rewritten: string[]
+            skipped: SkippedAlter[]
+          }>)
+        : {}

[recommendation: use a small type guard to avoid retaining the assertion]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/db/install.ts` around lines 672 - 675, Update the
error handling around the partial-result carrier to safely narrow the caught
value to a non-null object before accessing rewritten or skipped. Replace the
type assertion with a small type guard, preserving the intended CliExit(1) path
for null, undefined, and non-object errors.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/__tests__/rewrite-migrations.test.ts`:
- Around line 1611-1638: Replace the UID-dependent read-only-file setup in the
partial-progress test for rewriteEncryptedAlterColumns with a failure mechanism
that works when running as root, preferably creating 0002_name.sql as a
directory so the rewrite produces EISDIR; apply the identical change in
packages/cli/src/__tests__/rewrite-migrations.test.ts lines 1611-1638 and
packages/wizard/src/__tests__/rewrite-migrations.test.ts lines 1551-1578,
including matching cleanup.

In `@packages/cli/src/commands/eql/migration.ts`:
- Around line 279-284: Update the sweepIncomplete abort path in the migration
function to emit the same conditional standalone outro, `if (!embedded)
p.outro('Migration aborted.')`, before throwing `new CliExit(1)`, matching the
other non-zero abort paths while preserving the existing error log.

In `@packages/wizard/src/lib/post-agent.ts`:
- Around line 95-104: Remove the aggregate warning blocks guarded by skipped and
unverified in the post-agent flow, leaving rewriteEncryptedMigrations as the
sole source of these per-directory warnings and their accompanying details.

---

Nitpick comments:
In `@packages/cli/src/commands/db/install.ts`:
- Around line 672-675: Update the error handling around the partial-result
carrier to safely narrow the caught value to a non-null object before accessing
rewritten or skipped. Replace the type assertion with a small type guard,
preserving the intended CliExit(1) path for null, undefined, and non-object
errors.

In `@packages/cli/src/commands/db/rewrite-migrations.ts`:
- Around line 831-854: Centralize the rationale for the unconditional "public"
domain qualifier used by renderSafeAlter and the corresponding wizard rewriter,
or add the same install-schema explanation to both implementations. Ensure both
generated ALTER statements retain the "public" qualifier while inheriting the
shared/documented contract rather than relying on only the CLI’s brief comment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 80a16ecb-b08f-4e52-981e-ebd45a0fec9e

📥 Commits

Reviewing files that changed from the base of the PR and between 17393b9 and 69e8e6a.

📒 Files selected for processing (14)
  • .changeset/staged-add-only-rewriter.md
  • packages/cli/src/__tests__/rewrite-migrations.test.ts
  • packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts
  • packages/cli/src/commands/db/install.ts
  • packages/cli/src/commands/db/rewrite-migrations.ts
  • packages/cli/src/commands/db/supabase-migration.ts
  • packages/cli/src/commands/eql/__tests__/migration.test.ts
  • packages/cli/src/commands/eql/migration.ts
  • packages/wizard/src/__tests__/post-agent.test.ts
  • packages/wizard/src/__tests__/rewrite-migrations.test.ts
  • packages/wizard/src/lib/post-agent.ts
  • packages/wizard/src/lib/rewrite-migrations.ts
  • scripts/__tests__/rewriter-copies-in-sync.test.mjs
  • skills/stash-cli/SKILL.md

Comment thread packages/cli/src/__tests__/rewrite-migrations.test.ts
Comment thread packages/cli/src/commands/eql/migration.ts
Comment thread packages/wizard/src/lib/post-agent.ts Outdated

@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.

Approve

Reviewed the full diff at e3ee5e6a (4 commits on remove-v2), reading the rewriter's complete lexer myself and running two focused audits — copy parity + caller fail-closed, and a non-destructive-output check. No blocking defects; all 7 CI checks green. This is a careful, well-tested conversion of the Drizzle EQL rewriter from destructive (ADD+DROP+RENAME) to additive-and-fail-closed.

What I verified

Additive-only is actually guaranteed. renderSafeAlter emits exactly one statement shape — ALTER TABLE … ADD COLUMN "<col>_encrypted" "public"."<domain>"; — preceded only by comment lines. DROP COLUMN survives solely in JSDoc explaining why a commented-out fragment must not be mistaken for live SQL; RENAME_COLUMN_RE is read-only, feeding column identity into the encrypted/declared fail-closed sets (never emitted). The source column is preserved in every branch.

Every caller fails closed. install.ts and eql/migration.ts set sweepIncomplete on a non-empty skipped (success path) and in the catch (surfacing partial rewritten/skipped), then throw new CliExit(1). The wizard's post-agent.ts throws on skipped || unverified, and — nicely — keys directory failure on error !== undefined (presence, not truthiness) so an empty-message sweep error still counts as unverified. The old wizard "warn and default the prompt to NO" path is replaced by a hard throw, and the now-safe additive rewrite lets the migrate prompt default to true legitimately.

The #811 dollar-quote regression is fixed at the lexer and covered by tests. dollarQuoteDelimiter refuses to open a $…$ body when the preceding char is an identifier char (matching Postgres's separator rule), so price$usd$cents / price$$cents no longer open phantom bodies that hide later live SQL; $$…$$ and $tag$…$tag$ bodies are skipped whole. The over-termination reasoning from #772-finding-1 (an early-closed literal is what flips a commented-out ALTER to "live") is applied correctly across the ", ', E'', and $$ branches, with " tokenized before '. Tests exercise odd-apostrophe dollar bodies, tagged bodies, both identifier-$ shapes, and a "still rewrites a live ALTER after a dollar body" case that proves the skip doesn't over-reach.

Copy discipline holds. The two rewriter copies share byte-identical core logic; they diverge only in imports, prose comments, the emitted tool-name header, and an explicit #region wizard-only block (the multi-directory sweep the wizard alone needs). rewriter-copies-in-sync.test.mjs strips comments/imports/blanks, canonicalizes the header, excludes the region, and asserts exact equality — with guard tests against both over-normalization and logic sneaking under the region marker. Real logic drift would fail it.

Fail-closed reasons are precise and well-explained. already-encrypted / source-unknown / target-exists / unrecognised-form each get distinct, actionable describeSkipReason guidance, and the corpus index deliberately over-detects "encrypted" (costs a flagged statement) rather than risk under-detecting (costs irrecoverable ciphertext) — the asymmetry is documented and correct. Changeset (stash + @cipherstash/wizard patch) and the shipped stash-cli skill are both updated accurately: the destructive "ADD+DROP+RENAME / safe only on empty table" and "DROP COLUMN would destroy" language is gone, replaced with the staged add-only + exits-non-zero description.

Non-blocking

  • Unrelated import churn. install.ts, eql/migration.ts, and supabase-migration.ts switch @/… → relative paths, but 16 other packages/cli/src files keep @/. It's behavior-neutral (green CI) but unrelated to this PR and now inconsistent — worth reverting to keep the security-focused diff tight, or applying repo-wide if there's a bundling reason.
  • Merge ordering with #825. This PR touches install.ts and makes a trivial import change to supabase-migration.ts, both of which sibling PR #825 (also on remove-v2) rewrites/deletes — supabase-migration.ts is deleted there. Whichever lands second will need conflict resolution; the rewriter-caller logic in eql/migration.ts survives #825 (which keeps stash eql migration --drizzle), so nothing is lost, just a heads-up.
  • The author's documented "accepted residue" (nested-block-comment double-report as unrecognised-form; the DECLARED_COLUMN_RE keyword-list edge cases like SIMILAR/REFERENCES <table>) are all in the over-flag direction — a manual-review statement, never a fail-open — and strictly narrower than the gaps they replace. Correct calls; no action needed.

Excellent, rigorous work — the lexer comments alone document the exact failure mode each guard prevents. LGTM.

tobyhede added 5 commits July 29, 2026 15:44
…base

Rebasing onto remove-v2 drops `eql install --drizzle` and
`generate-drizzle-migration.test.ts` with it, so the fail-closed work that
lived on that path moves to the surviving one:

- port the `isPartialRewriteResult` narrowing from the deleted
  `generateDrizzleMigration` into `eql migration --drizzle`, replacing the
  unchecked cast on the thrown sweep result
- port the non-object sweep-failure coverage (`null` / `undefined` / a bare
  string) into `commands/eql/__tests__/migration.test.ts`
- say "switch the application to the encrypted column by name, then drop
  plaintext" wherever the rewriter, the wizard prompt, and the shipped skill
  used to say "cutover" — `stash encrypt cutover` no longer exists
tobyhede added 2 commits July 29, 2026 16:31
Both found by audit, neither caught by CI:

- `isPartialRewriteResult` used an annotation where the branch's original
  used a cast, producing TS2322 ("Type 'object' is not assignable to type
  'Record<string, unknown>'"). No runtime effect — packages/cli has no
  typecheck job — but it breaks `tsc -p packages/cli` and shows in editors.
  Restored the branch's own form.

- The emitted migration header lost the word "dual-write", which upstream
  had deliberately added to it. Dual-write is the precondition
  `--confirm-dual-writes-deployed` exists to enforce, and this text ships
  into customer migration files. Restored in both rewriter copies; parity
  test still byte-identical.
…oval

`remove-v2` deleted `db/__tests__/generate-drizzle-migration.test.ts` along
with `eql install --drizzle`. Seven of its blocks tested behaviour that still
exists line-for-line in `generateDrizzleEqlMigration`, and two survivors here
asserted less than the deleted originals did. Re-homed against
`eql migration --drizzle`:

- `--name` rejection for command substitution, backticks, a path separator,
  and `''` — the input where "empty" and "absent" diverge, since `''` is not
  nullish and so slips past `?? DEFAULT_MIGRATION_NAME` into the regex
- name validation precedes the dry-run preview (ordering invariant)
- `--out` appears in the dry-run preview
- `--out` defaults to an absolute `drizzle/`; because `--out` is always
  appended to drizzle-kit's argv, a flagless run overrides the project's
  drizzle.config.ts `out`, and no test exercised that arm
- the second and third arms of the spawn-failure chain (ENOENT, and a
  non-zero status with no stderr and no spawn error)
- the remediation hint when drizzle-kit writes somewhere we don't look

Strengthened: the argv assertion is `toEqual` on the full array plus the
command, so dropping the `exec` prefix (regressing to download-and-run, which
resolves a different drizzle.config.ts) now fails; `detectPackageManager` is
pinned so that assertion is not tautological. The sibling-rewrite test regains
the command-level add-only invariant and the "Rewrote N" success line.
Base automatically changed from remove-v2 to main July 29, 2026 07:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/wizard/src/lib/rewrite-migrations.ts`:
- Around line 944-952: Update the catch block in sweepMigrationDirs to read
partial rewritten/skipped results only when err is an Error instance, matching
the existing isPartialRewriteResult narrowing behavior; for non-object throws
such as null or undefined, report empty arrays while preserving the extracted
error message and per-directory result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cdae8e5c-47a3-4082-8542-369fc0d2cd1e

📥 Commits

Reviewing files that changed from the base of the PR and between 69e8e6a and 143a99d.

📒 Files selected for processing (11)
  • .changeset/staged-add-only-rewriter.md
  • packages/cli/src/__tests__/rewrite-migrations.test.ts
  • packages/cli/src/commands/db/rewrite-migrations.ts
  • packages/cli/src/commands/eql/__tests__/migration.test.ts
  • packages/cli/src/commands/eql/migration.ts
  • packages/wizard/src/__tests__/post-agent.test.ts
  • packages/wizard/src/__tests__/rewrite-migrations.test.ts
  • packages/wizard/src/lib/post-agent.ts
  • packages/wizard/src/lib/rewrite-migrations.ts
  • scripts/__tests__/rewriter-copies-in-sync.test.mjs
  • skills/stash-cli/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • .changeset/staged-add-only-rewriter.md
  • packages/wizard/src/lib/post-agent.ts
  • packages/wizard/src/tests/post-agent.test.ts

Comment on lines 944 to +952
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
results.push({ dir, rewritten: [], skipped: [], error: message })
const partial = err as Partial<RewriteSweepError>
results.push({
dir,
rewritten: partial.rewritten ?? [],
skipped: partial.skipped ?? [],
error: message,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the partial-result read against non-object throws.

err as Partial<RewriteSweepError> is an unchecked cast: for throw null / throw undefined, partial.rewritten raises a TypeError inside the catch and escapes sweepMigrationDirs, defeating the per-directory fail-closed reporting. The CLI caller narrows explicitly (isPartialRewriteResult in packages/cli/src/commands/eql/migration.ts); mirror that here. The rewriter only attaches partials to Error instances, so an instanceof check is sufficient.

🛡️ Proposed fix
     } catch (err) {
       const message = err instanceof Error ? err.message : String(err)
-      const partial = err as Partial<RewriteSweepError>
+      const partial: Partial<RewriteSweepError> =
+        err instanceof Error ? (err as RewriteSweepError) : {}
       results.push({
         dir,
         rewritten: partial.rewritten ?? [],
         skipped: partial.skipped ?? [],
         error: message,
       })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
results.push({ dir, rewritten: [], skipped: [], error: message })
const partial = err as Partial<RewriteSweepError>
results.push({
dir,
rewritten: partial.rewritten ?? [],
skipped: partial.skipped ?? [],
error: message,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const partial: Partial<RewriteSweepError> =
err instanceof Error ? (err as RewriteSweepError) : {}
results.push({
dir,
rewritten: partial.rewritten ?? [],
skipped: partial.skipped ?? [],
error: message,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/wizard/src/lib/rewrite-migrations.ts` around lines 944 - 952, Update
the catch block in sweepMigrationDirs to read partial rewritten/skipped results
only when err is an Error instance, matching the existing isPartialRewriteResult
narrowing behavior; for non-object throws such as null or undefined, report
empty arrays while preserving the extracted error message and per-directory
result.

@tobyhede
tobyhede merged commit a3987e0 into main Jul 29, 2026
10 checks passed
@tobyhede
tobyhede deleted the fix/issue-813 branch July 29, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants