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
4d4b43e
feat(prisma-next): derive v3 domain catalog + codec ids from stack DO…
calvinbrewer Jul 14, 2026
8e6ef84
feat(prisma-next): v3 codec-id closed union, traits, invariants, drif…
calvinbrewer Jul 14, 2026
54c9a06
feat(prisma-next): v3 per-domain authoring constructors + *V2 aliases
calvinbrewer Jul 14, 2026
089e247
feat(prisma-next): v3 plain-JSONB cell codecs + EncryptedNumber envelope
calvinbrewer Jul 14, 2026
051efe5
feat(prisma-next): v3 bulk-encrypt middleware (plain-JSONB payloads)
calvinbrewer Jul 14, 2026
92ce461
feat(prisma-next): v3 query operators — eql_v3.* lowering + capabilit…
calvinbrewer Jul 14, 2026
4f68833
feat(prisma-next): v3 schema derivation, SDK adapter, runtime descrip…
calvinbrewer Jul 14, 2026
d16cb00
feat(prisma-next): EQL v3 bundle baseline migration from @cipherstash…
calvinbrewer Jul 14, 2026
d566c8e
test(prisma-next): property-based suites for v3 wire, catalog, and ga…
calvinbrewer Jul 14, 2026
fc51f84
test(prisma-next): v3 live-PG integration + bundling isolation pins
calvinbrewer Jul 14, 2026
04b23b5
docs(prisma-next): changeset for EQL v3 per-domain authoring surface
calvinbrewer Jul 14, 2026
b5cdb65
fix(prisma-next): wire the v3 ORM surface end-to-end
calvinbrewer Jul 14, 2026
d31ebb4
feat(prisma-example): convert the example app to EQL v3
calvinbrewer Jul 14, 2026
93c8044
refactor(prisma-next): consume @cipherstash/stack/adapter-kit, drop t…
calvinbrewer Jul 16, 2026
a5660d7
feat(prisma-next): EQL-derived v3 operator vocabulary with a guarded …
calvinbrewer Jul 16, 2026
76a4669
fix(prisma-next): PR #655 review hardening across the v3 seams
calvinbrewer Jul 16, 2026
c2f4719
feat(prisma-example): adopt the eql* operator surface, regenerate the…
calvinbrewer Jul 16, 2026
f86d02c
test(prisma-next): test-kit family integration suite + CI job
calvinbrewer Jul 16, 2026
8db7196
fix(prisma-next): hermetic typecheck against stack source, gated in C…
calvinbrewer Jul 17, 2026
bfa77d6
fix(prisma-next): make the JSON encryptQuery cast suppressions effective
coderdan Jul 18, 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
23 changes: 23 additions & 0 deletions .changeset/eql-v3-prisma-next.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@cipherstash/prisma-next': minor
---

**Breaking:** EQL v3 columns are now authored through **concrete per-domain constructors** — the constructor you choose *is* the capability set. The legacy boolean-option surface (`EncryptedString({ equality, freeTextSearch, orderAndRange })`) is not carried into v3.

- New per-domain constructors, one per exposed `public.eql_v3_*` domain:
- Text: `EncryptedText` (storage), `EncryptedTextEq`, `EncryptedTextOrd` (eq + order/range), `EncryptedTextMatch` (free-text), `EncryptedTextSearch` (eq + free-text + order/range).
- Scalars (Integer, Smallint, BigInt, Numeric, Real, Double, Date, Timestamp): `Encrypted<Fam>` (storage), `Encrypted<Fam>Eq`, `Encrypted<Fam>Ord`.
- `EncryptedBoolean` — storage-only (`public.eql_v3_boolean`); there is no boolean equality constructor.
- `EncryptedJson` — searchable encrypted JSONB (`public.eql_v3_json`, `ste_vec`), queried with `eqlJsonContains` (`@>` containment). Selector querying (comparing the value at a JSONPath) is tracked in #677.
- **Impossible capability combinations have no constructor** (e.g. text equality + free-text without order/range) — they are unrepresentable, not runtime errors.
- **BigInt is a first-class v3 family** (`EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd`, JS `bigint` plaintext, backed by `public.eql_v3_bigint*`).
- Use the `*V2` constructors (`EncryptedStringV2`, `EncryptedDoubleV2`, `EncryptedBigIntV2`, `EncryptedDateV2`, `EncryptedBooleanV2`, `EncryptedJsonV2`) to keep EQL v2 columns. A client is v2 or v3 — the two runtime descriptors are never co-registered.
- New `@cipherstash/prisma-next/v3` entry point: `cipherstashFromStackV3({ contractJson })` builds the v3 runtime descriptor, bulk-encrypt middleware, and a stack `EncryptionV3` client from the emitted contract.
- Query operators use an **EQL-derived vocabulary** (`eqlEq`, `eqlNeq`, `eqlIn`, `eqlNotIn`, `eqlGt`, `eqlGte`, `eqlLt`, `eqlLte`, `eqlBetween`, `eqlNotBetween`, `eqlJsonContains`; ordering via `eqlAsc` / `eqlDesc`), lowering to the same-named `eql_v3.*` functions with operands cast to the domain's query type (`$n::eql_v3.query_<domain>`); ordering uses `eql_v3.ord_term` / `eql_v3.ord_term_ore` by the column's ordering flavour. The domains are `public.eql_v3_*`; the operator functions live in the `eql_v3` schema. (The v2 surface keeps its `cipherstash*` names.)
- Free-text search is **`eqlMatch`** — fuzzy bloom token matching (`eql_v3.contains`), deliberately NOT named after SQL `ILIKE`: matching is case-insensitive, order/multiplicity-insensitive, and one-sided (may false-positive). Two guards run before encryption: SQL wildcards are normalised (leading/trailing `%` stripped; interior `%` or any `_` rejected), and needles the column's match index cannot answer (empty / below the tokenizer length) are rejected via the shared `matchNeedleError` guard. There is **no negated match operator** — negating a may-false-positive bloom test would silently drop matching rows.
- A new baseline migration `20260601T0100_install_eql_v3_bundle` (invariant `cipherstash:install-eql-v3-bundle-v1`) installs the `public.eql_v3_*` domains and `eql_v3.*` functions from the pinned `@cipherstash/eql` release. Regenerate contracts and run migrations after changing constructors.
- **The v3 ORM surface is fully wired end-to-end** (proven by converting `examples/prisma`):
- The generated `contract.d.ts` type surface covers every v3 codec id: `CodecTypes` gains all 40 `cipherstash/eql-v3/*@1` entries (envelope outputs — `number`-castAs domains decode to the new `EncryptedNumber` — plus trait-accurate operator visibility), and `QueryOperationTypes` gains the `eql*` operator set, surfaced on v3 columns via type-level `cipherstash:v3-*` marker traits (the v2 `cipherstash*` methods never appear on v3 columns, and vice-versa). Storage-only domains (including `EncryptedBoolean`) surface no operator methods at the type level, matching the runtime gate.
- The v3 runtime descriptor now presents the **pack id** (`cipherstash`) with v3's own version, so `postgres<Contract>({ extensions })` accepts contracts emitted by the cipherstash extension pack instead of failing with `RUNTIME.MISSING_EXTENSION_PACK`.
- Every v3 codec id registers a control-plane `expandNativeType` hook that strips the `public.` qualifier — `prisma-next migration plan` now renders `CREATE TABLE` columns as bare domain names (`eql_v3_bigint_ord`), matching what introspection reports, with **no `add_search_config` ops** (v3 domains carry their own index metadata). No `onFieldEvent` is registered for v3.
- The v3 bundle baseline migration op is reclassified `data` (it is a contract-shape-neutral self-edge; the aggregate integrity checker rejects self-edges without a data-class op), unblocking `prisma-next migration plan` / `migrate` in consuming apps.
5 changes: 5 additions & 0 deletions .changeset/prisma-example-eql-v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/prisma-next-example': minor
---

Convert the example app to EQL v3. Every column is now a concrete `public.eql_v3_*` domain authored with the per-domain constructors (`EncryptedTextSearch`, `EncryptedDoubleOrd`, `EncryptedBigIntOrd`, `EncryptedDateOrd`, `EncryptedBoolean`, `EncryptedJson`), wired through `cipherstashFromStackV3({ contractJson })`. The e2e harness runs the full v3 surface against live Postgres + ZeroKMS with no skips: the `eql*` operator vocabulary (equality/range plus `eqlMatch` free-text token search), `eqlAsc`/`eqlDesc` order-term sorting, encrypted JSON containment (`eqlJsonContains` — the v2 `cipherstashJsonb*` helpers do not exist in v3), lossless `bigint` beyond `Number.MAX_SAFE_INTEGER`, and the storage-only `eql_v3_boolean` refusal (`EncryptionOperatorError`) pinned as a feature. Migrations regenerate from the v3 contract: the initial app migration creates the `users` table against the v3 domains with zero `add_search_config` ops, and the cipherstash space carries both bundle baselines (v2 + v3).
129 changes: 129 additions & 0 deletions .github/workflows/integration-prisma-next.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Integration — prisma-next (EQL v3)

# Real ZeroKMS ciphertext against a real Postgres, on BOTH database variants —
# the prisma-next leg of the shared test-kit family driver (the same catalog,
# oracle, and single-vs-bulk crossover as the Drizzle and Supabase jobs).
#
# The prisma-next 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.
#
# 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/**'
- 'packages/prisma-next/**'
# 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/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
- 'local/docker-compose.supabase.yml'
- 'local/supabase-init.sql'
- '.github/workflows/integration-prisma-next.yml'
- '.github/actions/integration-setup/**'
pull_request:
branches: ['**']
# Repeated verbatim: GitHub Actions does not support YAML anchors/aliases.
paths:
- 'packages/stack/src/eql/v3/**'
- 'packages/prisma-next/**'
# 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/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
- 'local/docker-compose.supabase.yml'
- 'local/supabase-init.sql'
- '.github/workflows/integration-prisma-next.yml'
- '.github/actions/integration-setup/**'

jobs:
integration:
name: prisma-next v3 integration (db=${{ matrix.db }})
runs-on: blacksmith-4vcpu-ubuntu-2404
# Serialize every job that brings up the SAME docker-compose stack — same
# rationale and keys as the Drizzle/Supabase workflows, so the jobs queue
# on a shared runner rather than collide on the fixed host ports.
concurrency:
group: integration-live-db-${{ matrix.db }}
cancel-in-progress: false
# 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
# prisma-next 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. 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
pgrest-url: http://localhost:55430

env:
CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }}
CS_CLIENT_ID: ${{ vars.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 — same rationale as the Drizzle workflow.
CS_IT_DB_VARIANT: ${{ matrix.db }}

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: ${{ vars.CS_WORKSPACE_CRN }}
client-id: ${{ vars.CS_CLIENT_ID }}
client-key: ${{ secrets.CS_CLIENT_KEY }}
client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }}

# Belt-and-braces for the concurrency guard: a run that was hard-killed
# can skip its `down` and leak a container holding the host port onto a
# REUSED runner. `|| true` so a clean runner is not an error.
- name: Clear any leaked containers from a prior run
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v --remove-orphans || true

- 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: prisma-next v3 family suites
run: pnpm --filter @cipherstash/prisma-next run test:integration

- name: Stop ${{ matrix.db }}
if: always()
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v
9 changes: 9 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ jobs:
- name: Type tests (stack-supabase)
run: pnpm --filter @cipherstash/stack-supabase run test:types

# prisma-next's operator-capability gating is proven by `.test-d.ts`
# `@ts-expect-error` assertions (an unsupported operator on a column
# must be a compile error). Those only fire when tsc processes them,
# so the package `typecheck` must run — and be required — here (#684).
# Its tsconfig resolves stack subpaths to SOURCE, so no build step is
# needed first.
- name: Typecheck (prisma-next — enforces v3 operator-capability gating)
run: pnpm --filter @cipherstash/prisma-next run typecheck

- name: Lint — no hardcoded package-manager runners
run: pnpm run lint:runners

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ If these variables are missing, tests that require live encryption will fail or
- `packages/cli`: The `stash` CLI — auth, init, encryption schema, and database setup (`stash eql install`). Has its own `AGENTS.md`.
- `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`)
- `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state
- `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres
- `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. EQL v2 (`*V2` constructors, `cipherstashFromStack`) and EQL v3 (per-domain constructors, `./v3` entry with `cipherstashFromStackV3`)
- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`.
- `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`.
- `packages/schema`: Schema builder utilities and types (`encryptedTable`, `encryptedColumn`, `encryptedField`)
Expand Down
Loading
Loading