Skip to content
Open
47 changes: 46 additions & 1 deletion .changeset/rewriter-never-drops-ciphertext.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ either kind makes the rest of the file inert rather than live.

The sweep also refuses to rewrite a column the migration corpus already gives an
encrypted type, so changing a column's encrypted domain no longer drops a column
full of ciphertext. Skipped statements report why they were left alone.
full of ciphertext. Skipped statements report why they were left alone. This
recognises the encrypted forms drizzle-kit emits, a domain installed into a
non-`public` schema, and an array of a domain — so a corpus that shows a column
as encrypted in any of those shapes is flagged, not rewritten.

The sweep is now fail-closed about the columns it does not recognise at all.
Previously a column missing from the corpus index was assumed to be plaintext
Expand All @@ -49,3 +52,45 @@ treated as empty, and the wizard's `Run the migration now?` prompt defaults to N
whenever the sweep rewrote anything, flagged anything, or could not check a
directory at all — naming the directories that went unchecked, and making no
claim about data destruction for a directory nothing is known about.

Four further ways the sweep could still reach a ciphertext column are closed:

- **Chained conversions.** The corpus index read `CREATE TABLE`, `ADD COLUMN`
and `RENAME`, but never the sweep's own target. A directory where an earlier
migration already ran `SET DATA TYPE eql_v2_encrypted` — generated by a stack
version predating this sweep — left the column looking plaintext, so a later
domain change dropped its ciphertext. Conversions are now recorded as the
sweep walks the corpus in order, so the first conversion still applies and
only the ones after it are flagged.
- **Schema qualification.** `"users"` and `"public"."users"` are the same table
— Postgres resolves the unqualified name through `search_path` — but they were
indexed under different keys. drizzle-kit emits unqualified while hand-written
SQL and this sweep's own output are qualified, so a corpus mixing the two hid
an encrypted column from the guard. A non-`public` schema stays distinct.
- **Quote parity.** A `$$ … $$` body containing an odd number of apostrophes, or
an `E'a\'b'` literal, ended a string literal earlier than Postgres does. Every
token after it was then misread — including a commented-out `ALTER`, which
read as live and was rewritten into a live `DROP COLUMN`. Dollar-quoted bodies
are skipped whole and `E''` backslash escapes are honoured.
- **Truncated `CREATE TABLE` bodies.** The body was matched up to the first
`);`, which can sit inside a `--` comment or a string `DEFAULT`. Columns
declared after that point vanished from the index entirely. The closing paren
is now located by skipping candidates that are inside a comment or literal.

The two copies of this rewriter — one in `stash`, one in `@cipherstash/wizard` —
are now compared by a repo test, so a fix can no longer land in one and silently
miss the other.

**The wizard now sweeps only drizzle-kit output directories.** It cannot
discover a project's configured `out`, so it tries `drizzle/`, `migrations/` and
`src/db/migrations/` — but the last two are generic names that Knex,
node-pg-migrate, Flyway and hand-rolled psql also use. A project whose drizzle
`out` is `drizzle/` and which also keeps a hand-maintained `migrations/` had
that second directory rewritten into ADD+DROP+RENAME, in a directory the wizard
was never pointed at. The fail-closed rule is no defence there: a real migration
history declares its own columns, so the rewrite proceeds. A candidate is now
swept only if it carries the `meta/_journal.json` drizzle-kit maintains. A
directory that holds `.sql` files but no journal is reported rather than passed
over in silence, so a genuine drizzle output whose `meta/` went missing is
visible instead of looking clean. `stash eql migration` and `stash eql install`
are unaffected — both already take a single explicit `--out`.
Loading