Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
7eb47ed
fix(wizard,cli): never rewrite a commented-out or already-encrypted A…
tobyhede Jul 24, 2026
7e0092f
fix(stack)!: make Encryption's overloads agree with what it returns
tobyhede Jul 24, 2026
ae5d1a4
fix(stack-supabase): export the single-row builder, drop dead v2 remn…
tobyhede Jul 24, 2026
40c5ea0
docs(skills,meta): stop describing EQL v2 as the current generation
tobyhede Jul 24, 2026
c8c1512
chore(ci,changesets): fix bench triggers, test-kit alias, overstated …
tobyhede Jul 24, 2026
2d3bbfb
fix(wizard): default the migrate prompt to No when a sweep could not run
tobyhede Jul 24, 2026
3c33c12
fix(stack-supabase): accept interface row types, not just type aliases
tobyhede Jul 24, 2026
8b3f0b1
docs(skills): scope the v2 rollout lifecycle, and fix the drop column
tobyhede Jul 24, 2026
de48f0a
docs: stop claiming the tooling detects a column's EQL v2 generation
tobyhede Jul 24, 2026
d765ee5
docs(changesets): fix claims that are false in the release they ship in
tobyhede Jul 24, 2026
05b7bf0
fix(wizard,cli): treat quoted identifiers and string literals as inert
tobyhede Jul 24, 2026
383d3c2
fix(wizard,cli): an unterminated quoted identifier must fail safe too
tobyhede Jul 24, 2026
919a9c7
fix(stack): only forward an EQL v3 table to the client on DynamoDB de…
tobyhede Jul 24, 2026
4c17678
docs(stack-supabase): drop a comment about a removed code path
tobyhede Jul 24, 2026
b186fd2
docs: drop three more comments that explain the present via deleted code
tobyhede Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/drizzle-encrypted-indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
'@cipherstash/stack-drizzle': minor
---

New `encryptedIndexes` helper on the `/v3` entry: spread
New `encryptedIndexes` helper on the package root: spread
`...encryptedIndexes(t)` in `pgTable`'s third-argument callback and it derives
the recommended functional indexes for every encrypted column in the table —
named `<table>_<column>_<capability>`, tracked by `drizzle-kit generate` like
Expand Down
27 changes: 27 additions & 0 deletions .changeset/dynamodb-v2-read-table-forwarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@cipherstash/stack': patch
---

Fix `encryptedDynamoDB`'s EQL v2 read path, which failed against a v3-configured
client — the case the v2 read support exists for.

`decryptModel` and `bulkDecryptModels` forwarded the table to the encryption
client unconditionally. The second argument is only meaningful to the typed EQL
v3 client, which resolves it against its own reconstructor map; a legacy v2
table is not in that map, so the call failed with:

```
[eql/v3]: decryptModel received a table this client was not initialized with
```

That contradicted the adapter's documented contract — writes are EQL v3 only,
but decrypt keeps accepting a v2 table so previously stored items stay readable.
In practice the failure hit exactly the customers the compatibility promise was
written for: upgraded to a v3 schema, with v2 items still in the table.

The table is now forwarded only when it is an EQL v3 table. A v2 table takes the
table-less form, and the client derives the table from the payloads themselves.

Both paths are covered by a credential-free test asserting what the adapter
forwards, so a regression no longer depends on a live-credential integration run
to surface.
28 changes: 19 additions & 9 deletions .changeset/eql-v3-sole-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@

Docs: EQL v3 is now the sole documented approach. The `stash-encryption`,
`stash-drizzle`, and `stash-supabase` skills and the `@cipherstash/stack`
README teach only the v3 typed surface (`EncryptionV3`, `types.*` concrete
domains, `@cipherstash/stack-drizzle/v3`, `encryptedSupabaseV3`); EQL v2
shrinks to one short Legacy section per document. Two explicit exceptions are
called out: DynamoDB still requires the v2 schema surface (#657), and the
encrypt rollout tooling (`stash encrypt backfill`/`cutover`,
`@cipherstash/migrate`) currently targets v2 columns (#648) — its guidance is
kept under a version callout. Also corrects the legacy `@cipherstash/drizzle`
README's pointer to the removed `@cipherstash/stack/drizzle` subpath (now the
separate `@cipherstash/stack-drizzle` package).
README teach only the v3 typed surface (`Encryption`, the `types.*` concrete
domains, the `@cipherstash/stack-drizzle` package root, `encryptedSupabase`);
EQL v2 shrinks to one short Legacy section per document. Two places keep more
than a Legacy section, because EQL v2 is still reachable there:

- **DynamoDB reads.** `encryptedDynamoDB` writes EQL v3 only, but `decryptModel`
/ `bulkDecryptModels` still accept an EQL v2 table so previously stored v2
items stay readable, so the `stash-dynamodb` skill keeps the v2 schema shape
documented for that read path (#657).
- **The encrypt rollout lifecycle.** `stash encrypt *` and `@cipherstash/migrate`
detect a column's generation from its Postgres domain type, with EQL v3 as the
recognised and default generation; a legacy `eql_v2_encrypted` column does not
classify and falls through to the v2 lifecycle, which ends in
`stash encrypt cutover` rather than the v3 `stash encrypt drop`. That
difference is kept under a version callout (#648).

Also corrects the legacy `@cipherstash/drizzle` README's pointer to the removed
`@cipherstash/stack/drizzle` subpath (now the separate `@cipherstash/stack-drizzle`
package).
4 changes: 4 additions & 0 deletions .changeset/init-placeholder-eql-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ the concrete-domain `types.*` factories (`types.TextSearch`, `types.IntegerOrd`,
`types.Text`, `types.Json`, …), and the `@cipherstash/stack-drizzle/v3` entry
(`extractEncryptionSchemaV3`) for Drizzle. The `encryptionClient` export shape
and the empty-schema "no schemas yet" error path are unchanged.

**Superseded later in this release** by the `@cipherstash/stack-drizzle` EQL v2
removal: the scaffolds now emit `Encryption` from `@cipherstash/stack/v3` and
`extractEncryptionSchema` from the collapsed `@cipherstash/stack-drizzle` root.
31 changes: 31 additions & 0 deletions .changeset/one-sided-eql-detection-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'stash': patch
'@cipherstash/migrate': patch
'@cipherstash/stack': patch
---

Correct shipped documentation that claimed the tooling detects a column's EQL
**v2** generation. It does not, and has not since `classifyEqlDomain` dropped v2:
detection is one-sided — a `public.eql_v3_*` Postgres domain classifies as **v3**,
and anything else (a plaintext column, or a legacy `eql_v2_encrypted` one)
classifies as *unknown* and falls through to the **v2** lifecycle. The v2 path is
reached by fallback, not by detection, and a v2 column records no `eqlVersion` in
`.cipherstash/migrations.json`, so `stash encrypt status` reports no version for
it.

- `skills/stash-supabase/SKILL.md` said the CLI "still auto-detects a v2 column"
(twice, once inside the "Stay on v2 for now" bullet — exactly the case it got
wrong) and that `stash encrypt drop` picks its target from a version the CLI
"auto-detects". All three now describe the one-sided rule, matching the correct
wording already in the same file's EQL version note. This skill is copied into
customer repos by `stash init`, so the wrong version of it was being installed
as guidance.
- `packages/migrate/README.md` documented `detectColumnEqlVersion(client, table,
column)` as returning `2`, `3`, or `null`. It cannot return `2` — the return
type is now stated as `3` or `null`, with what a `null` means for the caller.
The lifecycle intro no longer presents the v2 ladder as a detection result.
- `packages/stack/README.md`'s Supabase example imported and called
`encryptedSupabaseV3`, the `@deprecated` alias, contradicting the same file's
package table and v3-only note. It now uses `encryptedSupabase`.

Documentation only — no behaviour change.
32 changes: 32 additions & 0 deletions .changeset/prisma-next-v3-client-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@cipherstash/prisma-next': major
---

**Breaking:** `CipherstashFromStackV3Options.encryptionConfig` — the config
passed through to the encryption client by `cipherstashFromStack` — is narrowed
from `ClientConfig` to `V3ClientConfig` (`ClientConfig` without the legacy
`eqlVersion: 2` escape hatch). Forcing EQL v2 no longer type-checks:

```ts
const cipherstash = await cipherstashFromStack({
contractJson,
encryptionConfig: { eqlVersion: 2 },
// ^^^^^^^^^^ error TS2322: Type '2' is not assignable to type '3'.
})
```

The option never did what it looked like it did. This package is EQL v3 only,
and `eqlVersion: 2` selects `Encryption`'s nominal (untyped) overload at
runtime — not the `TypedEncryptionClient` that `cipherstashFromStack` returns as
`encryptionClient`. The field disagreed with the client you got back.

**Migration:** drop the `eqlVersion` field. Every other `ClientConfig` option
(`workspaceCrn`, `clientId`, `clientKey`, `accessKey`, `authStrategy`, logging,
…) is unchanged and still accepted.

To read legacy EQL v2 rows, decrypt through `@cipherstash/stack` rather than
asking this adapter for a v2 client: the decrypt path is generation-agnostic and
reads both v2 and v3 payloads. Use the returned `encryptionClient` — `decrypt(…)`
for a single value, or the no-table `decryptModel(row)` / `bulkDecryptModels(rows)`
form for whole models, which is the supported path for models written before the
v3 upgrade.
31 changes: 31 additions & 0 deletions .changeset/rewriter-never-drops-ciphertext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@cipherstash/wizard': patch
'stash': patch
---

Fix a data-loss bug in the Drizzle migration rewriter: a **commented-out**
`ALTER … SET DATA TYPE` was rewritten into executable SQL. The matcher was
comment-blind and the replacement is multi-line, so the author's `-- ` survived
on the first line only — the `DROP COLUMN` on the next line emitted live and
dropped a populated column.

A statement is now left exactly as written whenever it is inert — inside a `--`
line comment, inside a `/* … */` block, or inside a single-quoted string
literal, where an `ALTER` is data rather than SQL. (Rewriting one splices
`--> statement-breakpoint` markers *inside* the literal, so splitting the file
the way drizzle's migrator does yields a bare, live `DROP COLUMN` as a chunk of
its own.) Quoting is tokenised properly in the process: a `--` inside a string
no longer opens a comment, an apostrophe inside a quoted identifier such as
`"o'brien_data"` no longer opens a phantom string literal, a doubled `''` or
`""` reads as an escape rather than a delimiter, and an unterminated quote of
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.

An unreadable migration directory (`EACCES`) is reported rather than silently
treated as empty, and the wizard's `Run the migration now?` prompt defaults to No
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.
27 changes: 27 additions & 0 deletions .changeset/skills-v3-lifecycle-honesty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'stash': patch
---

Correct the EQL v2/v3 rollout lifecycle in the bundled `stash-encryption`,
`stash-supabase` and `stash-drizzle` agent skills. Each described the **v2**
lifecycle as the unqualified default even though v3 is the default generation,
so an agent following the prose would run steps that do not apply — and, in one
case, expect the wrong column to be dropped.

- `stash encrypt drop` was documented as removing `<col>_plaintext`. That is the
**v2** target. On a v3 column there is no `<col>_plaintext`: the command drops
the **original `<col>`**, guarded by a `DO` block that takes `ACCESS EXCLUSIVE`
and re-counts unencrypted rows at apply time, raising instead of dropping if
any remain. Each step in the cutover table is now marked v2-only or v3, and the
drop preconditions (`cut-over` for v2, `backfilled` for v3) are stated.
- "The pending row will be promoted to active by `stash encrypt cutover`" was
false for v3, where cutover short-circuits before touching any configuration.
`stash db activate` is the only promotion path there.
- The CipherStash Proxy call-outs told every reader to run `stash db push`.
`db push`/`db activate` manage `eql_v2_configuration`, which EQL v3 does not
ship — on a v3-only database `db push` reports "Nothing to do." and exits 0,
and `db activate` errors. The call-outs are now scoped to the EQL v2 + Proxy
path.

Skills ship inside the `stash` tarball and are copied into user projects at
`stash init`, so this guidance was being installed into customer repos.
23 changes: 19 additions & 4 deletions .changeset/stack-audit-on-decrypt.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,25 @@ already passing EQL v3 tables to plain `Encryption`, you now receive the typed
client rather than the nominal one — its `decryptModel` / `bulkDecryptModels`
return type changes, and the two-argument form reconstructs `Date` columns from
`cast_as` instead of leaving them as ISO strings. Code that read those columns as
strings needs updating. As part of this collapse
`EncryptionV3` no longer independently pins the wire format — like `Encryption`,
it now honours an explicit `config.eqlVersion` (the retained migration escape
hatch). The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2
strings needs updating.

The v3 overload takes a non-empty tuple of tables and a `V3ClientConfig` —
`ClientConfig` without the deprecated `eqlVersion` escape hatch. So
`Encryption({ schemas: [] })` no longer type-checks (it used to compile and then
throw), and `config: { eqlVersion: 2 }` selects the nominal overload, which is
the client you actually get back. Callers passing a plain `AnyV3Table[]` rather
than an array literal must narrow it to `readonly [AnyV3Table, ...AnyV3Table[]]`.
`Awaited<ReturnType<typeof Encryption>>` names the nominal client whatever you
pass, because `ReturnType` reads the last overload; use the exported
`EncryptionClientFor<S>` to name the client for a schema tuple.

`decryptModel` / `bulkDecryptModels` on the typed client also accept a call with
no table, matching the runtime, which has always allowed it — that is the path
for reading models written before the upgrade, above all legacy EQL v2 ones,
whose table cannot be a member of a v3 schema tuple. Prefer the two-argument
form whenever the table is registered.

The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2
builders remain available (now marked `@deprecated`) for reading and migrating
legacy v2 data; the client authors EQL v3 only. Their full removal is deferred to
a later PR.
5 changes: 3 additions & 2 deletions .changeset/stack-dynamodb-v2-write-removal.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
---

**Breaking (DynamoDB adapter):** `encryptedDynamoDB(...).encryptModel` and
`bulkEncryptModels` no longer accept an EQL v2 table — write is EQL v3 only. The
v2 write type overloads have been removed, narrowing encrypt to `AnyV3Table`.
`bulkEncryptModels` no longer accept an EQL v2 table. The v2 write type overloads
have been removed, narrowing encrypt to `AnyV3Table`. The narrowing is
type-level — treat the type as the contract, not a runtime guard.

**Decrypt still reads existing v2 items.** `decryptModel` / `bulkDecryptModels`
continue to accept an EQL v2 table (`encryptedColumn` / `encryptedField` from
Expand Down
30 changes: 30 additions & 0 deletions .changeset/supabase-interface-row-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@cipherstash/stack-supabase': minor
---

Row-type generics now accept an `interface`, not just a `type` alias.

`from<Row>()`, `returns<U>()` and `single().returns<U>()` constrained their row
parameter to `Record<string, unknown>`. An `interface` has no implicit index
signature, so the most ordinary way to declare a row type failed to compile:

```typescript
interface User { id: string; email: string }

// before: TS2344 — Index signature for type 'string' is missing in type 'User'
// after: fine
const { data } = await supabase.from<User>('users').select('id, email')
```

A `type User = { … }` alias worked, which is why the existing type tests never
caught it. The constraint is now `object`, which still rejects `string`/`number`
row types. upstream `postgrest-js` constrains `returns` to nothing at all, so
this brings the adapter in line with the API it mirrors rather than being
stricter than it.

Also corrects the `EncryptedSingleQueryBuilder` documentation, which claimed
that "everything that only re-types or re-configures the pending request is
carried over" after `single()`/`maybeSingle()`. `overrideTypes` and `setHeader`
are not carried over — they have no adapter equivalent, and since
`single()`/`maybeSingle()` return the same builder instance rather than a
passthrough, calling them would fail at runtime, not just fail to typecheck.
14 changes: 10 additions & 4 deletions .changeset/supabase-single-row-typing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ awaits to `EncryptedSupabaseResponse<T>` (`data: T | null`). That covers the
zero-row case for `maybeSingle()` and the error case for both, so no separate
null modelling was needed.

Filters and transforms are not chainable after `single()`/`maybeSingle()`,
Filters and transforms are no longer chainable after `single()`/`maybeSingle()`,
matching supabase-js — applying one afterwards would change the query the
single-row promise was made about. `returns<U>()` preserves the awaited shape,
so `.single().returns<U>()` still awaits one row.
single-row promise was made about. `.single().eq(...)`, `.single().limit(...)`
and friends were previously accepted and are now compile errors. What only
re-types or re-configures the pending request is carried over: `returns<U>()`
(preserving the awaited shape, so `.single().returns<U>()` awaits one row),
`abortSignal()`, `throwOnError()`, `withLockContext()` and `audit()`.
`EncryptedSingleQueryBuilder<T>` is exported so a stored builder can be
annotated.

**Migration:** delete the cast. Code that worked around the old typing with
`data as unknown as Row` (or read `data![0]`) should now use `data` directly;
the cast still compiles but is no longer needed, and `data![0]` becomes a type
error.
error. Move any filter or transform chained after `single()`/`maybeSingle()` to
before it.
8 changes: 8 additions & 0 deletions .github/workflows/tests-bench.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
paths:
- 'packages/bench/**'
- 'packages/stack/src/eql/v3/**'
# The bench drives the typed v3 CLIENT, which lives in `encryption/`, not
# `eql/v3/` (that is the authoring DSL). Without this the job stayed green
# by never running for the changes most likely to break it.
- 'packages/stack/src/encryption/**'
- 'packages/stack-drizzle/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
Expand All @@ -26,6 +30,10 @@ on:
paths:
- 'packages/bench/**'
- 'packages/stack/src/eql/v3/**'
# The bench drives the typed v3 CLIENT, which lives in `encryption/`, not
# `eql/v3/` (that is the authoring DSL). Without this the job stayed green
# by never running for the changes most likely to break it.
- 'packages/stack/src/encryption/**'
- 'packages/stack-drizzle/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
Expand Down
9 changes: 6 additions & 3 deletions docs/reference/supabase-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ One entry point, EQL v3 only:
|---|---|---|
| `encryptedSupabase` | `@cipherstash/stack/eql/v3` (EQL v3) | native `public.eql_v3_*` domains |

Rows already written as EQL v2 still decrypt through `@cipherstash/stack`; what
is gone is the ability to author new v2 columns here.

`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias. The old
EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient,
supabaseClient })` — has been removed; the name now binds to the v3 factory
Expand Down Expand Up @@ -134,11 +137,11 @@ The domains use SQL-standard type names (`integer`, `smallint`, `real`,
### Install EQL

```bash
# v2 (default)
# v3 (the default)
stash eql install --supabase

# v3
stash eql install --eql-version 3 --supabase
# v2 (legacy installs only)
stash eql install --eql-version 2 --supabase
```

For **v2**, `--supabase` selects the opclass-stripped bundle (operator
Expand Down
Loading