Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dedeb64
docs(plan): EQL v3 integration test suite, then type robustness
coderdan Jul 10, 2026
e8d4587
fix(stack): encrypt raw filter in-lists element-wise; restore alias Date
coderdan Jul 10, 2026
ce390d8
test(test-kit): extract the v3 domain catalog into a shared harness p…
coderdan Jul 10, 2026
e9d72ac
test(integration): harness — docker variants, CLI install, loud env gate
coderdan Jul 10, 2026
c67d017
docs(test-kit): pin down why ORE ordering is silently wrong, not absent
coderdan Jul 10, 2026
bd63c8c
test(integration): supabase v3 driver + adapter, integer family on re…
coderdan Jul 10, 2026
476c1e3
test(integration): all nine supabase families + aliased Date reconstr…
coderdan Jul 10, 2026
48618a4
test(integration): cover the include_original pathological case (#615)
coderdan Jul 10, 2026
8f9df8b
test(integration): port the wire suite, drop its gate, wire up CI
coderdan Jul 10, 2026
9b65ae8
feat(stack): order EQL v3 encrypted columns by their OPE term on Supa…
coderdan Jul 10, 2026
cd43b39
docs(test-kit): correct the ORE deferral reason — unusable, not silen…
coderdan Jul 10, 2026
6043f9b
fix(stack): order by `col->op`, and pin what makes it correct
coderdan Jul 10, 2026
a3152fb
test(integration): port the Drizzle v3 suite onto the shared harness
coderdan Jul 10, 2026
6c03e73
fix(test-kit): reject a partially configured CipherStash environment
coderdan Jul 10, 2026
cd981e8
test(integration): show test logs only for failing tests
coderdan Jul 10, 2026
c78b60f
ci(integration): run Drizzle on both databases; Node 24; drop-before-…
coderdan Jul 10, 2026
bd25e0e
test(integration): no skipped tests, and fail the run if any appear
coderdan Jul 10, 2026
f67f448
docs(plan): queue the skip-removal and live-suite reorg follow-ups
coderdan Jul 10, 2026
02750c6
test(stack): address review findings on the v3 integration harness
coderdan Jul 11, 2026
11fe4ec
fix(stack): address code-review findings on the v3 integration harnes…
coderdan Jul 12, 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
18 changes: 18 additions & 0 deletions .changeset/supabase-in-list-operands.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ rendered as `not.in.(…)`. Passing a PostgREST list literal (`'(a,b)'`) for an
encrypted column now throws instead of silently matching nothing — pass an
array.

**`filter(col, 'in', […])` encrypted the whole list as a single ciphertext.**
The raw `.filter()` path reached `in` with none of the element-splitting the
`in()`, `not(…, 'in', …)` and `.or()` paths perform, so the entire list operand
was encrypted as one equality term. The two wire formats then failed
differently, which is why this went unnoticed: **v2**'s `("json")` composite
literal is already parenthesized, so PostgREST parsed it as a one-element list
and answered `200 []` — a filter that silently matched nothing. **v3**'s bare
`{…}` envelope is not, so PostgREST rejected the request outright with
`PGRST100 (failed to parse filter)`.

Each element is now encrypted separately and the operand rendered as a quoted
PostgREST list literal. As on the `not` path, passing a list literal
(`'(a,b)'`) for an encrypted column now throws instead — pass an array.

Plaintext columns are unaffected, including the pre-existing quirk that
postgrest-js renders `.filter(col, 'in', [array])` as an unparenthesized
`in.a,b` that PostgREST rejects; pass a list literal there, or use `.in()`.

**`is(col, null)` is now allowed on every column**, including storage-only
encrypted ones (`types.Boolean`, `types.Integer`, …). `is` is never encrypted
and a NULL plaintext is stored as a SQL NULL, so `IS NULL` is not merely legal
Expand Down
47 changes: 47 additions & 0 deletions .changeset/supabase-v3-order-by-ope-term.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@cipherstash/stack': minor
---

**`order()` now works on EQL v3 encrypted ordering columns in the Supabase
adapter.** It was rejected outright on every encrypted column.

A bare `ORDER BY col` on an EQL v3 domain really is wrong — the bundle declares
no btree operator class on any domain, so the sort falls through to jsonb's
default `jsonb_cmp` and compares the envelope's keys in storage order, starting
at the random ciphertext `c`. Measured over ten rows it returns
`r00,r04,r08,r01,…` where the plaintext order is `r00..r09`. No error, a stable
and plausible-looking meaningless order.

But the correct sort key is reachable without a function call. `eql_v3.ord_term`
returns the domain's `op` term, and OPE is order-preserving, so ordering by the
term reproduces the plaintext order. PostgREST cannot emit
`ORDER BY eql_v3.ord_term(col)`, but it can emit a jsonb path. The builder now
emits `order=col->op` for an encrypted ordering column, verified against a live
PostgREST for `integer_ord` and `text_search` in both directions.

The guard is now on the ordering FLAVOUR, not on encryption:

- **`ope` present → supported.** Every plain `*_ord` domain, plus `text_ord` and
`text_search`.
- **`ore` present → rejected.** The `ob` term is an array of ORE blocks whose
comparison needs the superuser-only operator class, which no jsonb path can
reach. (Such a column cannot hold data on managed Postgres anyway: its domain
CHECK raises `ore_domain_unavailable`.) ORE columns are now excluded from
`order()` at COMPILE time too, not only at runtime — `.order(oreColumn)` is a
type error, matching the rejection.
- **neither → rejected.** Storage-only, equality-only and match-only columns
carry no ordering term.

The path is `col->op` (jsonb), not `col->>op` (text). Neither avoids the
database collation — Postgres compares jsonb strings with `varstr_cmp` under the
default collation, exactly as it does text. What makes the ordering
collation-independent is the term's encoding: lowercase hex, fixed-width for
numeric and date domains, and per-character (16 hex chars each) for text, so
lexicographic order reproduces plaintext order including the prefix case
(`ada` < `adam`). `ope-term.integration.test.ts` pins that shape.

`V3OrderableKeys` widens to admit OPE-backed ordering columns (`*_ord`,
`text_ord`, `text_search`) while still excluding ORE (`*_ord_ore`) columns, so
`order()` typechecks exactly where it works. `is(col, true)` is unaffected — it
stays plaintext-only, and now has its own `V3PlaintextKeys` rather than
borrowing the orderable set.
47 changes: 47 additions & 0 deletions .github/actions/integration-setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Integration test setup
description: >-
Checkout, pnpm, Node, dependencies, and a built `stash` CLI — the common
preamble for every integration workflow. The CLI build is required, not
incidental: the integration harness installs EQL v3 by shelling out to
`stash eql install --eql-version 3`, so an installer regression fails the job
rather than hiding behind a test-only SQL apply.

inputs:
node-version:
description: Node major to run the suites on.
required: false
# The newest major `tests.yml` exercises, and what the repo develops on.
# `engines` is `>=22`; the unit job still covers 22 in its matrix.
default: '24'

runs:
using: composite
steps:
- name: Checkout Repo
uses: actions/checkout@v6

- uses: pnpm/action-setup@v6.0.8
name: Install pnpm
with:
run_install: false

- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: 'pnpm'

# node-pty's install hook falls back to `node-gyp rebuild` when no
# linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships node-gyp
# on PATH, so install it explicitly.
- name: Install node-gyp
shell: bash
run: npm install -g node-gyp

- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile

- name: Build the stash CLI (used to install EQL v3)
shell: bash
run: pnpm exec turbo run build --filter stash
130 changes: 130 additions & 0 deletions .github/workflows/integration-drizzle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Integration — Drizzle (EQL v3)

# Real ZeroKMS ciphertext against a real Postgres, on BOTH database variants.
#
# The Drizzle adapter talks straight to the database, so it does not need
# PostgREST — but it does need to work on managed Postgres, where the `postgres`
# role is not a superuser, the EQL install takes its self-skipping path, and the
# ORE domains cannot hold data. The Supabase compose file brings up PostgREST
# too; this job simply ignores it, and leaves `PGRST_URL` unset so a Supabase
# suite scoped here would throw rather than silently skip.
#
# Separate from `tests.yml` on purpose: these suites need CipherStash credentials
# and a database, and they THROW rather than skip when unconfigured. Keeping them
# out of the unit job is what lets `pnpm test` stay runnable with neither.

on:
push:
branches: [main]
paths:
- 'packages/stack/src/eql/v3/**'
# Source layers the adapter's encoding/round-trip rests on: a break here
# (not just under src/eql/v3) can produce wrong rows, so trigger the live
# suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- 'packages/schema/**'
- 'packages/stack/integration/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
- 'local/docker-compose.supabase.yml'
- 'local/supabase-init.sql'
- '.github/workflows/integration-drizzle.yml'
- '.github/actions/integration-setup/**'
pull_request:
branches: ['**']
# Repeated verbatim: GitHub Actions does not support YAML anchors/aliases.
paths:
- 'packages/stack/src/eql/v3/**'
# Source layers the adapter's encoding/round-trip rests on: a break here
# (not just under src/eql/v3) can produce wrong rows, so trigger the live
# suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- 'packages/schema/**'
- 'packages/stack/integration/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
- 'local/docker-compose.supabase.yml'
- 'local/supabase-init.sql'
- '.github/workflows/integration-drizzle.yml'
- '.github/actions/integration-setup/**'

jobs:
integration:
name: Drizzle v3 integration (db=${{ matrix.db }})
runs-on: blacksmith-4vcpu-ubuntu-2404
# Fork PRs have no secrets. Skip cleanly rather than fail on something the
# contributor cannot fix — `tests.yml` still gives them a green signal.
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}

strategy:
fail-fast: false
# Drizzle talks straight to Postgres, so it runs against BOTH databases.
# The Supabase variant is not a formality: its `postgres` role is not a
# superuser, so the EQL install takes its self-skipping path and the ORE
# domains become unusable. A suite that passes on a superuser database can
# still fail there.
matrix:
include:
- db: postgres
database-url: postgres://cipherstash:password@localhost:55432/cipherstash
pgrest-url: ''
- db: supabase
database-url: postgres://postgres:password@localhost:55433/postgres
# The supabase compose file starts PostgREST anyway. Drizzle does not
# use it, but supplying the URL lets the harness assert the `anon`
# path on the database it is actually running against.
pgrest-url: http://localhost:55430

env:
CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }}
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
# Job-level env, not a `.env` file: `dotenv/config` does not override an
# already-set `process.env`, so these win and no secret is written to disk.
DATABASE_URL: ${{ matrix.database-url }}
PGRST_URL: ${{ matrix.pgrest-url }}
# EXPLICIT, never inferred. The variant decides whether EQL is installed
# with `--supabase` (and therefore whether the role grants are applied).
# Inferring it from `PGRST_URL` reported `postgres` for this job's Supabase
# cell and silently skipped the grants.
CS_IT_DB_VARIANT: ${{ matrix.db }}
# Scoped by directory, never by named file, so a renamed suite cannot
# silently drop from CI. `integration/shared/` holds the adapter-agnostic
# suites (harness, bloom, ope-term); the Supabase adapter suites are not
# run here — they have their own job.
CS_IT_SUITE: >-
integration/shared/**/*.integration.test.ts,
integration/drizzle-v3/**/*.integration.test.ts

steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/integration-setup

# Fast pre-flight: fail in seconds if a secret was rotated or cleared,
# before the docker pull. The in-test `requireIntegrationEnv` is the
# correctness guarantee; this is the cheap one.
- name: Require CipherStash secrets
uses: ./.github/actions/require-cs-secrets
with:
workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }}
client-id: ${{ secrets.CS_CLIENT_ID }}
client-key: ${{ secrets.CS_CLIENT_KEY }}
client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }}

- name: Start ${{ matrix.db }}
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml up -d --wait

# `globalSetup` installs EQL v3 by shelling out to the real
# `stash eql install --eql-version 3`, so an installer regression fails
# here rather than hiding behind a test-only SQL apply.
- name: Drizzle v3 integration suites
run: pnpm --filter @cipherstash/stack run test:integration

- name: Stop ${{ matrix.db }}
if: always()
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v
114 changes: 114 additions & 0 deletions .github/workflows/integration-supabase.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Integration — Supabase (EQL v3)

# Real ZeroKMS ciphertext, a real PostgREST, and `supabase/postgres` — the only
# job that proves the Supabase v3 adapter's queries return the right rows. The
# unit suites drive a mock that records strings; they cannot.
#
# Separate from `tests.yml` on purpose: these suites need CipherStash credentials
# and a database, and they THROW rather than skip when unconfigured. Keeping them
# out of the unit job is what lets `pnpm test` stay runnable with neither.

on:
push:
branches: [main]
paths:
- 'packages/stack/src/supabase/**'
- 'packages/stack/src/eql/v3/**'
# Source layers the adapter's encoding/round-trip rests on: a break here
# (not just under src/supabase) can produce wrong wire output or rows, so
# trigger the live suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- 'packages/schema/**'
- 'packages/stack/integration/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.supabase.yml'
- 'local/supabase-init.sql'
- '.github/workflows/integration-supabase.yml'
- '.github/actions/integration-setup/**'
pull_request:
branches: ['**']
paths:
- 'packages/stack/src/supabase/**'
- 'packages/stack/src/eql/v3/**'
# Source layers the adapter's encoding/round-trip rests on: a break here
# (not just under src/supabase) can produce wrong wire output or rows, so
# trigger the live suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- 'packages/schema/**'
- 'packages/stack/integration/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.supabase.yml'
- 'local/supabase-init.sql'
- '.github/workflows/integration-supabase.yml'
- '.github/actions/integration-setup/**'

jobs:
integration:
name: Supabase v3 integration (db=${{ matrix.db }})
runs-on: blacksmith-4vcpu-ubuntu-2404
# Fork PRs have no secrets. Skip cleanly rather than fail loudly on something
# the contributor cannot fix — `tests.yml` still gives them a green signal.
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}

strategy:
# A one-element matrix, not a cross-product: the Supabase adapter only ever
# runs against the Supabase variant. Kept as a matrix so adding a second
# database is a one-line change.
matrix:
db: [supabase]

env:
CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }}
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
# Job-level env, not a `.env` file: `dotenv/config` does not override an
# already-set `process.env`, so these win and no secret is written to disk.
#
# `postgres` here is deliberately NOT a superuser on this image — that is
# what makes the EQL install, the grants, and the ORE opclass skip behave
# as they do on a real Supabase project.
DATABASE_URL: postgres://postgres:password@localhost:55433/postgres
PGRST_URL: http://localhost:55430
# EXPLICIT, never inferred from `PGRST_URL` — see `dbVariant()`.
CS_IT_DB_VARIANT: ${{ matrix.db }}
# Scoped by directory, never by named file, so a renamed suite cannot
# silently drop from CI. `integration/shared/` holds the adapter-agnostic
# suites (harness, bloom, and the ope-term tripwire for THIS job's
# `col->op` ordering path); the Drizzle suites talk to plain Postgres and
# get their own job.
CS_IT_SUITE: >-
integration/shared/**/*.integration.test.ts,
integration/supabase/**/*.integration.test.ts

steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/integration-setup

# Fast pre-flight: fail in seconds if a secret was rotated or cleared,
# before paying for the ~2 GB supabase/postgres pull. The in-test
# `requireIntegrationEnv` is the correctness guarantee; this is the cheap one.
- name: Require CipherStash secrets
uses: ./.github/actions/require-cs-secrets
with:
workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }}
client-id: ${{ secrets.CS_CLIENT_ID }}
client-key: ${{ secrets.CS_CLIENT_KEY }}
client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }}

- name: Start ${{ matrix.db }}
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml up -d --wait

# `globalSetup` installs EQL v3 by shelling out to the real
# `stash eql install --eql-version 3 --supabase --direct`, so an installer
# regression fails here rather than hiding behind a test-only SQL apply.
- name: Supabase v3 integration suites
run: pnpm --filter @cipherstash/stack run test:integration

- name: Stop ${{ matrix.db }}
if: always()
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v
Loading
Loading