Skip to content

fix(wizard,cli): report a part-way-failed ALTER COLUMN sweep as destructive (#786) - #812

Open
tobyhede wants to merge 5 commits into
remove-v2from
fix/786-partial-sweep-destructive
Open

fix(wizard,cli): report a part-way-failed ALTER COLUMN sweep as destructive (#786)#812
tobyhede wants to merge 5 commits into
remove-v2from
fix/786-partial-sweep-destructive

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #786

The Drizzle migration rewriter writes one file at a time. When it threw after rewriting a file — ENOSPC, a read-only file, an editor or drizzle-kit holding a lock — the accumulated rewritten array died with the stack frame. sweepMigrationDirs then hard-coded rewritten: [] on the error result, and rewriteEncryptedMigrations continued past the whole reporting block, so the wizard printed

the sweep could not check 1 directory (drizzle/); review those migrations before migrating

over a prompt that never mentioned data loss — while a live DROP COLUMN sat on disk. This was the last fail-open path left by the A-2 sweep (#783, #785), in the subsystem where it costs the most.

What changed

  • rewriteEncryptedAlterColumns rejects with a new PartialRewriteError carrying rewritten and skipped whenever it fails part way through a directory it has already changed. The underlying message is preserved verbatim and the original is kept as cause. The whole per-file loop is wrapped, not just the writeFile call — a throw from the post-write near-miss scan would otherwise still lose the file written in that same iteration.
  • sweepMigrationDirs propagates those arrays onto the error result instead of emptying them.
  • packages/wizard/src/lib/post-agent.ts no longer continues past the report for a directory that errored: the rewritten files are listed with the existing data-destroying warning, the "did not fully complete" warning still fires, and the prompt takes the destructive arm (initialValue: false, "DESTROYS data"). Both facts are true; destruction wins the wording because it is the one a user cannot afford to miss.
  • stash eql migration --drizzle and stash eql install --drizzle now name any files the sweep rewrote before it failed, instead of pointing at the directory and leaving the user to guess. The shared reporting blocks were hoisted out of the try so success and partial failure use one path; message wording, ordering, and sweepIncomplete semantics are unchanged.

Design note

PartialRewriteError is thrown only when there is partial work to report. A sweep that fails before changing or flagging anything rejects with the original error object, code and identity intact — so the "threw on the first file" arm is provably unchanged rather than merely tested-unchanged, and the fail-open-risk surface stays limited to the case that actually gained information.

The alternative (returning a result carrying error) would have been a breaking signature change for both CLI callers and would have removed the throw contract sweepMigrationDirs relies on. Cost of the chosen shape: PartialRewriteError is new exported surface in both mirrored rewriter copies.

Tests

Both pre-existing failure tests use the EISDIR-on-read trick, which throws before any write — which is why this was invisible. New tests use a delegating node:fs/promises writeFile spy that fails the Nth call, deterministic where chmod passes silently as root.

Coverage: the rewriter (both mirrored copies), the directory sweep, the wizard report driven end to end through a real failing write, and both CLI callers. No existing test was weakened or deleted — post-agent.test.ts's empty-error-message test and the two EISDIR tests pass unmodified.

pnpm --filter @cipherstash/wizard test      15 passed | 1 skipped (16 files), 340 passed | 5 skipped
pnpm --filter stash test                    62 passed (62 files), 952 passed
scripts/__tests__/rewriter-copies-in-sync   4 passed
pnpm run test:scripts                       11 passed (11 files), 131 passed
pnpm run code:check                         exit 0 (warnings all pre-existing)

The rewriter change lands identically in packages/wizard/src/lib/rewrite-migrations.ts and packages/cli/src/commands/db/rewrite-migrations.ts; scripts/__tests__/rewriter-copies-in-sync.test.mjs is green.

Changeset: @cipherstash/wizard patch + stash patch. skills/stash-cli/SKILL.md documents what a part-way failure now reports.

Base

Branched off remove-v2, not main — it targets code that only exists there.

Summary by CodeRabbit

  • Bug Fixes

    • Improved migration sweep reporting when failures occur after some files are rewritten.
    • Reports rewritten, attempted, and skipped migrations instead of losing partial results.
    • Preserves clearer warnings for data-destructive changes and incomplete sweeps.
    • Refined migration prompts based on whether files were rewritten, flagged, or left unverified.
  • Documentation

    • Added guidance to review migration directories after an interrupted sweep before running migrations.

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

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 90ca56b

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

This PR includes changesets to release 11 packages
Name Type
@cipherstash/wizard Patch
stash Patch
@cipherstash/e2e Patch
@cipherstash/basic-example 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

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 79c9442b-835e-40d6-bbfc-9f5844b50f62

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/786-partial-sweep-destructive

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: 2

🤖 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 @.changeset/sweep-partial-rewrite-destructive.md:
- Around line 35-42: Update the second paragraph to describe failures occurring
before recording any rewrite or skipped statement, rather than before attempting
any write or reporting zero counts. Preserve the distinction that a failure
after skipped statements retains flagged-statement guidance and does not use the
softer no-known-data wording.

In `@skills/stash-cli/SKILL.md`:
- Around line 395-396: Update the sweep failure documentation to state that the
reported file list includes both successfully rewritten and attempted files,
including the file whose write failed. Instruct users to inspect every listed
file because any may be truncated or partially replaced, while preserving the
warning that the directory requires review before running drizzle-kit migrate.
🪄 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: 5ce03ded-1e9f-4627-8da0-7ac371b734ab

📥 Commits

Reviewing files that changed from the base of the PR and between 69a1156 and f1674df.

📒 Files selected for processing (12)
  • .changeset/sweep-partial-rewrite-destructive.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/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
  • skills/stash-cli/SKILL.md

Comment on lines +35 to +42
If a wizard sweep flags a raw ALTER and then fails before rewriting anything,
the prompt now preserves both facts: it keeps the flagged-statement guidance
and names the migration directory that the sweep could not finish checking.

A sweep that fails before attempting any write is unchanged: it rejects with
the original error, reports zeros, and keeps the softer "nothing is known about
this directory" wording, because claiming data destruction there would be a
guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Qualify the “reports zeros” failure case.

This contradicts the preceding paragraph: a sweep can record skipped statements, then fail before attempting any rewrite. In that case rewritten is zero but skipped is not, and the prompt retains the flagged-statement guidance. Change “before attempting any write” / “reports zeros” to “before recording any rewrite or skipped statement.”

Suggested wording
-A sweep that fails before attempting any write is unchanged: it rejects with
-the original error, reports zeros, and keeps the softer "nothing is known about
-this directory" wording, because claiming data destruction there would be a
-guess.
+A sweep that fails before recording any rewrite or skipped statement is
+unchanged: it rejects with the original error, reports zero rewrite/skip
+results, and keeps the softer "nothing is known about this directory" wording,
+because claiming data destruction there would be a guess.
📝 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
If a wizard sweep flags a raw ALTER and then fails before rewriting anything,
the prompt now preserves both facts: it keeps the flagged-statement guidance
and names the migration directory that the sweep could not finish checking.
A sweep that fails before attempting any write is unchanged: it rejects with
the original error, reports zeros, and keeps the softer "nothing is known about
this directory" wording, because claiming data destruction there would be a
guess.
If a wizard sweep flags a raw ALTER and then fails before rewriting anything,
the prompt now preserves both facts: it keeps the flagged-statement guidance
and names the migration directory that the sweep could not finish checking.
A sweep that fails before recording any rewrite or skipped statement is
unchanged: it rejects with the original error, reports zero rewrite/skip
results, and keeps the softer "nothing is known about this directory" wording,
because claiming data destruction there would be a guess.
🤖 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 @.changeset/sweep-partial-rewrite-destructive.md around lines 35 - 42, Update
the second paragraph to describe failures occurring before recording any rewrite
or skipped statement, rather than before attempting any write or reporting zero
counts. Preserve the distinction that a failure after skipped statements retains
flagged-statement guidance and does not use the softer no-known-data wording.

Comment thread skills/stash-cli/SKILL.md Outdated
Comment on lines +395 to +396
If the sweep itself fails part way through — an unwritable file, a full disk, a lock held by an editor or `drizzle-kit` — it still lists the files it rewrote before the failure, *and* warns that it did not fully complete. Both matter: the listed files already contain the `DROP COLUMN`, and the rest of the directory went unchecked. Treat that directory as needing review before you run `drizzle-kit migrate`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document attempted files as potentially partially rewritten.

The error contract includes the file whose write failed because it may already be truncated or partially replaced. Therefore, not every listed path is guaranteed to have completed the rewrite or already contain DROP COLUMN. Tell users that the list includes rewritten/attempted files and that every listed file must be inspected.

Suggested wording
-If the sweep itself fails part way through — an unwritable file, a full disk, a lock held by an editor or `drizzle-kit` — it still lists the files it rewrote before the failure, *and* warns that it did not fully complete. Both matter: the listed files already contain the `DROP COLUMN`, and the rest of the directory went unchecked. Treat that directory as needing review before you run `drizzle-kit migrate`.
+If the sweep itself fails part way through — an unwritable file, a full disk, a lock held by an editor or `drizzle-kit` — it still lists files it rewrote or attempted before the failure, *and* warns that it did not fully complete. An attempted write may already have partially modified its destination, so inspect every listed file and the rest of the directory before you run `drizzle-kit migrate`.
📝 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
If the sweep itself fails part way through — an unwritable file, a full disk, a lock held by an editor or `drizzle-kit` — it still lists the files it rewrote before the failure, *and* warns that it did not fully complete. Both matter: the listed files already contain the `DROP COLUMN`, and the rest of the directory went unchecked. Treat that directory as needing review before you run `drizzle-kit migrate`.
If the sweep itself fails part way through — an unwritable file, a full disk, a lock held by an editor or `drizzle-kit` — it still lists files it rewrote or attempted before the failure, *and* warns that it did not fully complete. An attempted write may already have partially modified its destination, so inspect every listed file and the rest of the directory before you run `drizzle-kit migrate`.
🧰 Tools
🪛 SkillSpector (2.3.11)

[warning] 35: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 36: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 43: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 44: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 45: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 51: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 52: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 64: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 65: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 73: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 91: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 179: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 181: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 182: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 348: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 367: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 625: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 312: [MP2] Context Window Stuffing: Skill attempts to fill the context window with filler content, displacing legitimate instructions and safety constraints. This can degrade agent performance or bypass safety boundaries.

Remediation: Implement context-window management that detects and rejects padding or stuffing attempts. Prioritize system instructions over user-injected content.

(Memory Poisoning (MP2))


[warning] 12: [RA2] Session Persistence: Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Remediation: Remove any persistence mechanisms (cron jobs, startup scripts, state files). Skills should not maintain state across sessions without explicit user consent.

(Rogue Agent (RA2))

🤖 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 `@skills/stash-cli/SKILL.md` around lines 395 - 396, Update the sweep failure
documentation to state that the reported file list includes both successfully
rewritten and attempted files, including the file whose write failed. Instruct
users to inspect every listed file because any may be truncated or partially
replaced, while preserving the warning that the directory requires review before
running drizzle-kit migrate.

@coderdan

Copy link
Copy Markdown
Contributor

Review complete: no findings. The patch preserves partial rewrite state and reports it consistently across CLI and wizard paths. Targeted tests and builds pass without regressions.

tobyhede added 5 commits July 29, 2026 09:21
…uctive (#786)

The Drizzle migration rewriter writes one file at a time. When it threw
*after* rewriting a file — ENOSPC, a read-only file, a lock held by an
editor or drizzle-kit — the accumulated `rewritten` array died with the
stack frame. `sweepMigrationDirs` then hard-coded `rewritten: []` on the
error result, and `rewriteEncryptedMigrations` `continue`d past the whole
reporting block, so the wizard printed "the sweep could not check 1
directory (drizzle/)" over a prompt that never mentioned data loss —
while a live DROP COLUMN sat on disk. The last fail-open path in the A-2
sweep, in exactly the subsystem where it costs the most.

`rewriteEncryptedAlterColumns` now rejects with a `PartialRewriteError`
carrying `rewritten` and `skipped` whenever it fails part way through a
directory it has already changed, preserving the underlying message
verbatim and keeping the original as `cause`. A failure with no work
behind it still rejects with the original error untouched, so the
"nothing is known about this directory" arm is byte-for-byte unchanged.

`sweepMigrationDirs` propagates those arrays onto the error result, and
the wizard's report no longer skips a directory that errored: the
rewritten files are listed with the existing data-destroying warning,
the "did not fully complete" warning still fires, and the prompt takes
the destructive arm (initialValue: false, "DESTROYS data"). Both facts
are true; the destructive one wins because it is the one the user cannot
afford to miss. `stash eql migration --drizzle` and `stash eql install
--drizzle` likewise name any files the sweep rewrote before it failed,
instead of pointing at the directory and leaving the user to guess.

The rewriter change lands identically in both mirrored copies
(scripts/__tests__/rewriter-copies-in-sync.test.mjs is green).

Tests use a delegating `node:fs/promises` writeFile spy that fails the
Nth call — deterministic, unlike chmod, which passes silently as root.
Coverage spans the rewriter (both copies), the directory sweep, the
wizard report driven end to end through a real failing write, and both
CLI callers. Skill updated: skills/stash-cli documents what a part-way
failure now reports.
…shes mid-walk

`lint-no-hardcoded-runners.mjs` walks the packages tree with a recursive
`readdir`. The repo's own script self-tests create and delete probe package
directories in that same tree while other suites run, and vitest ran those
suites in parallel — so the walk could enumerate a probe directory and find it
gone before recursing into it.

Unhandled, that rejects with ENOENT and Node exits 1: the same code the linter
uses for "found a hardcoded npx". CI then failed naming nothing, on a tree the
identical script had just passed one step earlier, and a re-run went green. The
script already draws this distinction for a missing target — "Exit 2 means the
linter could not run" — but only at the top level, not one frame down.

Observed on three branches before this one (30316404472, 30317589094), which is
why it reads as a flake rather than a defect.

Two changes:

- `walk` treats ENOENT as "nothing here to lint" and skips. A directory that
  no longer exists cannot contain a violation, and crashing with the violation
  exit code is the actual bug.
- `scripts/vitest.config.mjs` sets `fileParallelism: false`. Several of these
  suites mutate the real repo tree, so cross-file parallelism is unsound here
  regardless of this particular pair. Eleven small files; it costs a second.

Verified by reproducing the race locally — churning a probe directory while
running the linter failed 6/30 before, 0/40 after.

One wrinkle worth recording: the first draft of these comments named the probe
directories with their `packages/` prefix, which the sibling
`lint-no-dead-package-paths` linter correctly flagged as references to
directories that do not exist. The names are now written bare.
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 sweep: partial-write-then-throw is reported as unverified, not destructive

2 participants