Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions .changeset/remove-eql-v2-supabase-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@cipherstash/stack-supabase': major
---

Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical
unsuffixed names (part of the EQL v2 removal, #707).

- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory**
(formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains as a
type-identical `@deprecated` alias, so existing imports keep working.
- **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })`
wrapper is removed** — with it the two-argument `from(tableName, schema)` form
and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and
the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone;
the unsuffixed type names now denote the v3 surface.
- **The `*V3` type exports are de-suffixed** to their canonical names —
`EncryptedSupabaseV3Options` → `EncryptedSupabaseOptions`,
`EncryptedSupabaseV3Instance` → `EncryptedSupabaseInstance`,
`TypedEncryptedSupabaseV3Instance` → `TypedEncryptedSupabaseInstance`,
`EncryptedQueryBuilderV3` → `EncryptedQueryBuilder`,
`EncryptedQueryBuilderV3Untyped` → `EncryptedQueryBuilderUntyped`,
`V3FilterableKeys` → `FilterableKeys`, `V3OrderableKeys` → `OrderableKeys`, and
the rest of the `*V3` key-helper types. Each keeps a type-identical
`@deprecated` `*V3` alias.

**Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed
— no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is
generation-agnostic, so EQL v2 payloads still decrypt through the core client
(`decrypt` / `decryptModel`). This adapter, however, is now EQL v3 only and will
not auto-read an `eql_v2_encrypted` column: to read legacy v2 data during
migration, decrypt fetched rows with `@cipherstash/stack` directly, or run a
v2-configured setup alongside the v3 one and route per column. Mixed-generation
handling is a customer-side concern (install both and handle it explicitly), not
adapter auto-detection.

Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base
`EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or
wire encoding changed.

**Migration:** rename `encryptedSupabaseV3` → `encryptedSupabase` (or keep using
the alias). If you still use the v2 `encryptedSupabase({ encryptionClient,
supabaseClient }).from(table, schema)` wrapper, migrate the table to an
`eql_v3_*` column domain and switch to the introspecting factory —
`await encryptedSupabase(supabaseUrl, supabaseKey)` — see the `stash-supabase`
skill and https://cipherstash.com/docs.
11 changes: 11 additions & 0 deletions .changeset/remove-eql-v2-supabase-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'stash': patch
---

Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707):
`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with
`encryptedSupabaseV3` kept as a `@deprecated` alias), and the legacy v2
`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has
been removed. The skill's examples, exported-type list, and migration/cutover
guidance are corrected accordingly. Skills ship inside the `stash` tarball, so
the stale v2 guidance would otherwise land in a user's project.
32 changes: 32 additions & 0 deletions .changeset/supabase-single-row-typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@cipherstash/stack-supabase': major
---

`single()` and `maybeSingle()` now type `data` as the ROW, not an array.

Both have always returned one object at runtime, but the builder kept
advertising the array shape it was created with, so `data` was typed `T[] | null`
while holding a single row. Every caller had to launder it:

```typescript
const { data } = await supabase.from('users').select('id, email').single()
// before: data is `User[] | null` — wrong; a cast was the only way through
const user = data as unknown as User
// after: data is `User | null`
data?.email
```

`single()`/`maybeSingle()` now return `EncryptedSingleQueryBuilder<T>`, which
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()`,
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.

**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.
17 changes: 13 additions & 4 deletions .github/workflows/fta-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,19 @@ jobs:
# blocking gate. No `continue-on-error`.
# One step per package so a failure names the offending package. Each caps
# at its current worst file (a ratchet, not an aspiration): stack src/eql/v3
# at 69, and the split adapter packages at their monolith maxima — drizzle
# 89 (operators.ts), supabase 91 (query-builder.ts). Lower a cap whenever a
# file is refactored below the next threshold; the v2 query-builder/operators
# monoliths are the debt to whittle down toward stack's 69.
# at 69, drizzle 89 (operators.ts, still a monolith), and supabase 71
# (query-encrypt.ts at 70.12, after the query-builder monolith was split
# across column-map / query-encrypt / query-mutation / query-dbspace /
# query-filters / query-results). Lower a cap whenever a file is refactored
# below the next threshold; drizzle's operators.ts is the remaining debt to
# whittle down toward stack's 69.
#
# NB supabase's top three now cluster tightly (query-encrypt 70.12,
# query-builder 70.05, helpers 69.13), so 71 is a ~0.9 margin. A cap set
# flush against its max is fragile — the 91 this replaced was 0.12 above
# its max and a single refactor blew straight through it. If a formatting
# reflow trips this step without a real complexity increase, re-measure
# (`npx fta src --format table`) before assuming the code got worse.
- name: Analyze stack (eql/v3) complexity
run: pnpm --filter @cipherstash/stack run analyze:complexity

Expand Down
19 changes: 12 additions & 7 deletions packages/stack-supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ Depends on `@cipherstash/stack`; install both:
npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js
```

## EQL v3 (recommended)
## EQL v3

`encryptedSupabaseV3` introspects the database at connect time (native
`encryptedSupabase` introspects the database at connect time (native
`public.eql_v3_*` column domains) — no schema argument, `select('*')` support,
equality/range filters, and encrypted `order()` on OPE columns.

Expand All @@ -22,18 +22,23 @@ Use the Drizzle or Prisma Next adapter, or a carefully scoped direct SQL/RPC
path.

```ts
import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase'
import { encryptedSupabase } from '@cipherstash/stack-supabase'

const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey)
const es = await encryptedSupabase(supabaseUrl, supabaseKey)
await es.from('users').select('id, email').eq('email', 'a@b.com')
```

`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of
`encryptedSupabase`, so existing imports keep working.

Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an
optional peer and the factory cannot run in a Worker or the browser.

## EQL v2 (legacy)
## EQL v2 (removed)

`encryptedSupabase` wraps a supabase-js client with a v2 schema; still shipped for
existing v2 deployments.
The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient,
supabaseClient }).from(tableName, schema)` — has been removed; this package now
authors and queries EQL v3 only. Migrate existing v2 columns to an `eql_v3_*`
domain, or pin the last release that shipped the v2 wrapper.

See the `stash-supabase` agent skill and https://cipherstash.com/docs for the full guide.
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import type { EncryptionClient } from '@cipherstash/stack/encryption'
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { EncryptionErrorTypes } from '@cipherstash/stack/errors'
import {
encryptedColumn,
encryptedTable as encryptedTableV2,
} from '@cipherstash/stack/schema'
import { describe, expect, it } from 'vitest'
import { EncryptedQueryBuilderImpl } from '../src/query-builder'
import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3'
import {
createMockEncryptionClient,
createMockSupabase,
Expand All @@ -18,18 +13,14 @@ import {
/**
* Regression coverage for #626: the query builder's catch block used to hardcode
* `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError`
* field was dead. The v2 tests pin that a genuine encryption failure now threads its
* `EncryptionError` through the shared base `execute()` catch, while a plain
* (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own
* `encryptionFailure` path, which synthesizes an `EncryptionError` for its two
* query-term contract-violation cases (length mismatch, null envelope) that have
* no operation failure to wrap.
* field was dead. The `execute()` tests pin that a genuine encryption failure now
* threads its `EncryptionError` through the shared `execute()` catch, while a plain
* (non-encryption) throw leaves it unset. The `encryptionFailure` tests cover the
* dialect's own `encryptionFailure` path, which synthesizes an `EncryptionError`
* for its two query-term contract-violation cases (length mismatch, null envelope)
* that have no operation failure to wrap.
*/

const usersV2 = encryptedTableV2('users', {
email: encryptedColumn('email').freeTextSearch().equality(),
})

const usersV3 = encryptedTable('users', {
email: types.TextEq('email'),
})
Expand All @@ -48,7 +39,7 @@ function failingOperation(failure: { type: string; message: string }) {
}

describe('EncryptedSupabaseError.encryptionError (#626)', () => {
it('v2: threads the EncryptionError through on an encryption failure', async () => {
it('threads the EncryptionError through on an encryption failure', async () => {
const failure = {
type: EncryptionErrorTypes.EncryptionError,
message: 'zerokms unreachable',
Expand All @@ -62,7 +53,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => {
const { client: supabase } = createMockSupabase()
const builder = new EncryptedQueryBuilderImpl(
'users',
usersV2,
usersV3,
encryptionClient as unknown as EncryptionClient,
supabase,
)
Expand All @@ -77,7 +68,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => {
)
})

it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => {
it('leaves encryptionError unset on a plain (non-encryption) error', async () => {
const encryptionClient = createMockEncryptionClient()
const { client: supabase } = createMockSupabase()
// Make the underlying supabase call throw a non-encryption error: an insert
Expand All @@ -88,7 +79,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => {

const builder = new EncryptedQueryBuilderImpl(
'users',
usersV2,
usersV3,
encryptionClient,
supabase,
)
Expand All @@ -105,16 +96,16 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => {
// wrap for its contract-violation cases, so it synthesizes an EncryptionError.
// Drive it directly: a two-element `in` list whose bulkEncrypt returns one
// term trips the length-mismatch check. (The base `execute()` threading is
// already covered by the v2 test above; overriding `encryptModel` here would
// only re-run that shared path, not this v3-specific branch.)
// already covered by the `execute()` tests above; overriding `encryptModel`
// here would only re-run that shared path, not this v3-specific branch.)
const encryptionClient = createMockEncryptionClient() as unknown as {
bulkEncrypt: (...args: unknown[]) => unknown
}
encryptionClient.bulkEncrypt = () =>
operation([{ data: fakeEnvelope('ada', 'email') }])

const { client: supabase } = createMockSupabase()
const { error, status } = await new EncryptedQueryBuilderV3Impl(
const { error, status } = await new EncryptedQueryBuilderImpl(
'users',
usersV3,
encryptionClient as unknown as EncryptionClient,
Expand Down Expand Up @@ -144,7 +135,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => {
operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }])

const { client: supabase } = createMockSupabase()
const { error, status } = await new EncryptedQueryBuilderV3Impl(
const { error, status } = await new EncryptedQueryBuilderImpl(
'users',
usersV3,
encryptionClient as unknown as EncryptionClient,
Expand Down
Loading