Skip to content

refactor(stack): restore erased EQL v3 envelope + Result types#618

Merged
coderdan merged 3 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-type-robustness
Jul 12, 2026
Merged

refactor(stack): restore erased EQL v3 envelope + Result types#618
coderdan merged 3 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-type-robustness

Conversation

@coderdan

@coderdan coderdan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

PR2 of 4. #616 (the integration harness) has merged into the 1.0 candidate branch, and GitHub retargeted this PR onto it — base is now feat/eql-v3-text-search-schema. Ready for review.

The refactor the harness was built to make safe. #616 gave the v3 adapters real-crypto coverage; this leans on it to undo the type erasure they were shipping.

The problem

Reviewing #535 turned up that the canonical EQL v3 payload types are erased in both adapters. @cipherstash/eql (and the copy @cipherstash/protect-ffi vendors) defines a precise type per domain — IntegerOrd = { v, i, c, op }, IntegerOrdQuery = { v, i, op }, ~130 of them — and stack already re-exports the storage union as Encrypted. But the operand-encryption paths threw all of it away:

  • eql/v3/drizzle/operators.ts typed the client's encrypt/bulkEncrypt as returning unknown, collapsed the operation's Result to { data?: unknown; failure?: { message } }, and cast the bulk response to Array<{ data: unknown }>.
  • supabase/query-builder-v3.ts returned Promise<unknown[]> from encryptCollectedTerms, bulkEncryptGroup and encryptGroupPerTerm; the base query-builder.ts did the same.

So an operand's type, and whether encryption failed, were both invisible to the compiler. The eql_v3.query_* typing survived only as a runtime SQL cast.

What changed

The paths now carry the SDK's real types, threaded through a properly-typed operation surface that resolves Result<T, EncryptionError>:

  • Encrypted — the storage-envelope union, which already includes every v3 per-domain payload.
  • BulkEncryptedData{ id?, data: Encrypted | null }[].
  • EncryptedQueryResultEncrypted | EncryptedQuery | string | null.

No new dependency. @cipherstash/eql is only a devDependency of stack, so importing its types into src/ would emit .d.ts references consumers can't resolve. protect-ffi — a real dependency — vendors the same generated types, and stack already re-exports them. See the decision note in the thread for why per-column precision (IntegerOrd for this column) isn't threadable through the dynamic operator API, and why the domain-union Encrypted is the right granularity.

What the erasure was hiding

Un-erasing the Supabase path surfaced a genuine divergence between v2 and v3 that unknown[] had flattened: the base encryptCollectedTerms yields encryptQuery composite literals (objects); the v3 override yields JSON.stringify'd envelope strings. Both are EncryptedQueryResult — that union has a string arm precisely for the composite/envelope forms — so the divergence is now stated in the type rather than concealed by it.

Modelling the operation chain

ChainableOperation<T> had a subtle bug the unknown hid: it required withLockContext to return something also re-lockable, but the real EncryptOperationWithLockContext drops withLockContext (you can't lock-context twice). It's now two-tier — ChainableOperation<T>AuditableOperation<T> — which is what lets the real TypedEncryptionClient satisfy the structural surface with no cast. That was the M1 regression the .test-d.ts already guarded, and it still passes.

Verification

  • The un-erasure has teeth, pinned by a NEGATIVE type-test. operators.test-d.ts asserts (via @ts-expect-error) that a { encrypt } double returning unknown is REJECTED by createEncryptionOperatorsV3. A positive "correctly-typed double is accepted" check cannot catch a re-erasure — a correct value is assignable to unknown — so the negative assertion is the one with teeth; it fails the moment the client contract regresses. The real client is still accepted with no cast (M1).
  • Behaviour is unchanged bar one added guard, proven by the test(stack): EQL v3 integration harness + shared test kit, and two Supabase adapter fixes (PR1 of 4) #616 suites: Drizzle 619 and Supabase 665 real-crypto tests pass. The one addition: the Supabase v3 bulk path now rejects a null envelope from bulkEncrypt (the restored Encrypted | null type makes that arm reachable; an unguarded null would be JSON.stringify'd to the literal "null" and sent as a filter operand). Unit-tested.
  • src/ typechecks with zero errors; the unit suite passes. The changeset is a minor: tightening the public createEncryptionOperatorsV3 client contract from unknown to a typed operation surface is a compile-time breaking change for loosely-typed downstream clients.

Post-review fixes (round 2)

A /code-review pass over this PR found the un-erasure was only partly realised, and closed the gaps:

  • The restored type stopped one hop short on the base builder. encryptCollectedTerms was retyped to EncryptedQueryResult[], but its value was stored into EncryptedFilterState.encryptedValues, still typed unknown[], so the use site (encryptedValues[i]) widened straight back to unknown. That field is now EncryptedQueryResult[], so the restored type actually reaches the consumer.
  • The regression guard was toothless. The .test-d.ts only asserted a correctly-typed double is accepted — which keeps passing even if the contract re-erases to unknown. Added the negative assertion described in Verification.
  • The null arm the restored type newly names was unguarded in the Supabase bulk path — fixed with a guard + unit test (see Verification).
  • Changeset re-levelled patchminor (breaking type-surface change) and its "no runtime change" line corrected.

Still to come

Two more adapter type-robustness tasks and the follow-ups queued in the plan doc (removing the LIVE_* unit-suite skips; porting the remaining live suites onto the harness).

The refactor the integration harness (#616) was built to make safe. Both v3
adapters typed their operand-encryption paths as `unknown` and dropped the
`Result` wrapper, so the payload type and the failure channel were invisible.

- eql/v3/drizzle/operators.ts: `encrypt`/`bulkEncrypt` were typed to return
  `unknown`; the operation `Result` collapsed to `{ data?: unknown; failure? }`;
  the bulk response was cast `as Array<{ data: unknown }>`. Now the client
  surface returns a typed `ChainableOperation<Encrypted>` /
  `ChainableOperation<BulkEncryptedData>` whose `then` resolves
  `Result<T, EncryptionError>`, with the casts gone.

- supabase/query-builder-v3.ts (and the base query-builder.ts):
  `encryptCollectedTerms` / `bulkEncryptGroup` / `encryptGroupPerTerm` returned
  `Promise<unknown[]>`. They now return `EncryptedQueryResult[]` /
  `Array<Encrypted | null>` / `Encrypted[]`.

Types come from protect-ffi via stack's existing re-exports (`Encrypted`,
`BulkEncryptedData`, `EncryptedQueryResult`) — NOT from @cipherstash/eql, which
is only a devDependency; importing it into src would emit unresolvable .d.ts
references. See the PR thread for why per-column precision isn't threadable
through the dynamic operator API and the domain union is the right granularity.

Un-erasing surfaced two things the `unknown` hid:

- The Supabase base/v3 divergence: the v2 path yields `encryptQuery` composite
  literals, the v3 path yields `JSON.stringify`'d envelope strings. Both are
  `EncryptedQueryResult` (its `string` arm), now stated in the type.

- A latent bug in `ChainableOperation`: it required `withLockContext` to return
  something re-lockable, but the real `EncryptOperationWithLockContext` isn't.
  Now two-tier (`ChainableOperation` -> `AuditableOperation`), which is what
  lets the real client satisfy the surface with no cast — the M1 regression the
  .test-d.ts guards. Strengthened that test: a `{ encrypt }` returning `unknown`
  no longer typechecks.

Type-only: no runtime logic changed (same JSON bytes, same length check). Src
typechecks with 0 errors; type tests pass; 703 adapter unit tests + 1561 total
unit tests pass. Real-crypto integration is covered by #616's suites on CI —
local re-run was blocked by an OrbStack daemon outage, not by this change.
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 311dfc6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba1642bb-0ebc-4f5d-8c49-10e8b0ed11e0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eql-v3-type-robustness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores the canonical EQL v3 envelope unions and Result<T, EncryptionError> typing through the v3 Drizzle and Supabase adapter operand-encryption paths, reversing prior unknown/type-erasure that hid both payload shape and failure handling from the compiler.

Changes:

  • Retypes Supabase query-term encryption to return EncryptedQueryResult[] (v2 and v3 builders), making the operand encoding and nullability explicit at the type level.
  • Retypes Drizzle v3 operator operand encryption to carry Encrypted / BulkEncryptedData through a properly modelled thenable operation chain (ChainableOperation<T>AuditableOperation<T>), aligning with the real SDK surface.
  • Updates the Drizzle .test-d.ts regression guard and adds a patch changeset documenting the restoration.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/stack/src/supabase/query-builder.ts Returns typed EncryptedQueryResult[] from v2 batch term encryption (encryptQuery(terms)), instead of unknown[].
packages/stack/src/supabase/query-builder-v3.ts Returns typed EncryptedQueryResult[] and narrows bulk/per-term encryption helpers to EQL v3 envelope types.
packages/stack/src/eql/v3/drizzle/operators.ts Replaces unknown operand encryption + ad-hoc result shape with typed Result<T, EncryptionError> and typed envelope payloads.
packages/stack/tests/drizzle-v3/operators.test-d.ts Updates the structural-double test to match the now-typed operation surface (Result<Encrypted, EncryptionError>).
.changeset/eql-v3-adapter-type-robustness.md Patch changeset describing the adapter type un-erasure and its guarantees.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Base automatically changed from feat/eql-v3-integration-tests to feat/eql-v3-text-search-schema July 12, 2026 10:57
@coderdan
coderdan marked this pull request as ready for review July 12, 2026 10:57
@coderdan
coderdan requested a review from a team as a code owner July 12, 2026 10:57
coderdan added 2 commits July 12, 2026 21:19
- operators.test-d.ts: add the negative assertion the changeset claimed but
  never made — a `{ encrypt }` double returning `unknown` must be REJECTED by
  createEncryptionOperatorsV3. The prior positive `toBeCallableWith(double)`
  could not catch a re-erasure (a correct value is assignable to `unknown`), so
  the "pinned" guarantee was toothless. The `@ts-expect-error` now goes unused
  and fails the moment the client contract regresses to `unknown`.
- query-builder.ts: type `EncryptedFilterState.encryptedValues` as
  `EncryptedQueryResult[]`, not `unknown[]`. `encryptCollectedTerms` was restored
  to return that type but the value widened straight back to `unknown` at this
  field, so the use site (`encryptedValues[i]`) still saw `unknown` — the
  restoration never reached the consumer for the base builder. Now it does.
- query-builder-v3.ts: guard the `null` arm in `bulkEncryptGroup`. The restored
  `Encrypted | null` type makes null reachable; an unguarded null envelope would
  be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand
  (matching the wrong rows). Treat it as a contract violation.
- changeset: `patch` → `minor`. Tightening the public `createEncryptionOperatorsV3`
  client contract is a compile-time breaking change for loosely-typed downstream
  clients; corrected the "no runtime change" line to note the new null guard.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Drives `bulkEncrypt` to return a length-matched response with a null envelope
at position 0 and asserts the query fails 500 with "null envelope at position 0"
rather than sending the literal "null" as the `in` operand. Sibling to the
existing length-mismatch guard test.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan coderdan changed the title refactor(stack): restore erased EQL v3 envelope + Result types (PR2 of 4) refactor(stack): restore erased EQL v3 envelope + Result types Jul 12, 2026
@coderdan
coderdan merged commit e3c62d8 into feat/eql-v3-text-search-schema Jul 12, 2026
10 checks passed
@coderdan
coderdan deleted the feat/eql-v3-type-robustness branch July 12, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants