feat(stack-supabase): EQL v3 encrypted-JSON querying (containment + selector predicates)#653
feat(stack-supabase): EQL v3 encrypted-JSON querying (containment + selector predicates)#653coderdan wants to merge 2 commits into
Conversation
…electors) Closes #650. The v3 Supabase adapter gains encrypted-JSON querying on types.Json columns: - contains(col, subDocument) — exact encrypted ste_vec containment. A live PostgREST probe established the wire form: PostgREST casts the `cs.` filter operand to the column's own domain, so a storage-encrypted needle reaches eql_v3."@>"(eql_v3_json, eql_v3_json) → ste_vec_contains. The operand is a full storage envelope, the same INTERIM shape (and GET-query-string caveat) as every other v3 filter operand. - selectorEq/selectorNe(col, path, value) — JSONPath equality at a dot-notation path, compiled to containment of the reconstructed needle ({user:{role:'admin'}}); ne compiles to not.cs and INCLUDES rows lacking the path, mirroring the Drizzle selector's documented semantics (verified live). Paths/leaves share the Drizzle adapter's validation. - Selector ORDERING is not expressible over PostgREST: arrow paths get to_jsonb-wrapped (domain stripped) and bare-column comparison is blocked by design. Filed cipherstash/encrypt-query-language#407 proposing a needle-comparison overload; until then the methods don't exist and the skill points at Drizzle's ops.selector() for ordering. Core: QueryTypesForColumn gains the searchableJson arm (types.Json no longer resolves to never, so the typed key sets stop erasing JSON columns), and the JSONPath selector-path helpers move from stack-drizzle to @cipherstash/stack/adapter-kit (drizzle re-exports them unchanged) so both adapters share one validation surface. Adapter plumbing: V3SearchableJsonKeys key set + typed contains overload + selector methods on both builder interfaces; the capability resolver re-types the cs-collected term to searchableJson on JSON columns (free-text and JSON capabilities are mutually exclusive by construction); matches() steers to contains()/selectorEq() on JSON columns; not(col,'contains',…) is allowed on JSON (honest negated exact containment) and stays rejected on text. Tests: 17 unit (operand routing, guards, path validation), 6 type-level (key-set narrowing, operand shapes), 11 live integration with real crypto against real PostgREST as anon (containment, selector eq/ne incl. absent-path inclusion, raw cs, capability rejections). Full suites: stack 816, drizzle 368 unit + 16 live JSON, supabase 468 unit + 677 integration — all green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
🦋 Changeset detectedLatest commit: b257953 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
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 |
📝 WalkthroughWalkthroughSupabase v3 now supports encrypted JSON containment and JSONPath selector equality/inequality. Shared selector validation is exposed through ChangesEncrypted JSON querying
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
…uerying Ten findings from the high-effort review of #653, all fixed: 1. Guard bypass on non-method spellings: the freeTextSearch→searchableJson re-typing now enforces the operand boundary AT THE RESOLVER — raw .filter(col,'cs',string), .or() string/structured conditions, and not(col,'contains',scalar) get the actionable sub-document steer instead of silently storage-encrypting a JSON scalar; not(col,'matches',…) on a JSON column gets the same steer matches() gives. 2. Type-surface regression: scalar predicates (eq/gt/in/…) are compile-excluded again for types.Json columns (V3FilterableKeys now excludes columns with no scalar capability); dedicated filter('cs')/not('contains') overloads keep the raw spellings typed. Pinned with @ts-expect-error tests. 3. Empty/non-plain needles: contains('payload', {}) and [] are rejected ("matches every row" — Drizzle parity), and Date/Map/RegExp instances get the sub-document steer instead of serializing to scalars or {}. 4. Array-leaf semantics: the live probe showed finder-claimed ANY-element matching is WRONG with real protect-ffi — a scalar needle does NOT match an array at the path (elements are encoded under their own selectors). Docs corrected to the observed truth and pinned by live fixtures (scalar needle misses the array row; the full array through contains() hits it). 5. SQL-NULL documents: selectorNe now compiles to a structured OR (payload.is.null, payload.not.cs.<needle>) so NULL-document rows are INCLUDED, matching Drizzle's ne for both absence cases — verified live. 6. Integration test: removed the phantom two-arg from(TABLE, schema) (v3 from() takes one argument) and the identity casts; tsc over integration/ is clean. 7. Exposure honesty: the skill now states the JSON-needle ciphertext scales per-node (Drizzle containment ships none) and cites the new tracking issue #654; empty-needle rejection documented. 8. Changeset gains the required 'stash': patch for the skills change. 9. stash-encryption skill now names the Supabase route beside Drizzle's for both containment and selectors (with the ordering caveat). 10. Live decrypt round-trip added (select payload → toEqual original doc), plus live not-contains, and the unit not-contains asserts the encrypted envelope. Also from below-the-fold: the INTENTIONAL FORK JSDoc re-attached to EncryptionOperatorError (the re-export had split them); parseSelectorSegments rejects whitespace at segment boundaries (was silently addressing 'user '); unsupportedLeafReason handles null explicitly (was steering null to contains()); the as-unknown-as double-cast replaced with a plain widening; dead V3ContainsValue removed; core's matrix test-d pins QueryTypesForColumn<Json> = 'searchableJson'; stale "three scalar kinds" comment fixed. Suites: supabase 476 unit + 31 type + 681 live integration (15 in the JSON suite incl. NULL-doc and array-leaf fixtures), stack 62 type tests, drizzle 368 — all green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@skills/stash-encryption/SKILL.md`:
- Around line 688-697: Remove the references to the sibling “stash-supabase” and
“stash-drizzle” skills in the JSON containment and JSONPath selector
documentation. Make this section self-contained by retaining or adding the
necessary integration details directly in SKILL.md, without directing users to
other skill files.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 66894212-682c-43f9-b3be-d5566c41cbad
📒 Files selected for processing (12)
.changeset/supabase-v3-json-querying.mdpackages/stack-drizzle/src/v3/operators.tspackages/stack-supabase/__tests__/supabase-v3-builder.test.tspackages/stack-supabase/__tests__/supabase-v3-json.test.tspackages/stack-supabase/__tests__/supabase-v3.test-d.tspackages/stack-supabase/integration/json.integration.test.tspackages/stack-supabase/src/query-builder-v3.tspackages/stack-supabase/src/types.tspackages/stack/__tests__/v3-matrix/matrix.test-d.tspackages/stack/src/eql/v3/selector-path.tsskills/stash-encryption/SKILL.mdskills/stash-supabase/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (9)
- packages/stack-supabase/tests/supabase-v3.test-d.ts
- packages/stack-supabase/tests/supabase-v3-builder.test.ts
- .changeset/supabase-v3-json-querying.md
- packages/stack/src/eql/v3/selector-path.ts
- packages/stack-supabase/integration/json.integration.test.ts
- skills/stash-supabase/SKILL.md
- packages/stack-supabase/tests/supabase-v3-json.test.ts
- packages/stack-supabase/src/query-builder-v3.ts
- packages/stack-drizzle/src/v3/operators.ts
| Through the Drizzle v3 integration this is `ops.contains(col, subObject)` (on Supabase: `contains(col, subObject)` — see the `stash-supabase` skill) — see | ||
| the `stash-drizzle` skill. | ||
|
|
||
| **JSONPath selector-with-constraint** is the second query pattern: it compares | ||
| the encrypted value at a JSONPath, e.g. `metadata->'age' > 21`. Through the | ||
| Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`. | ||
| Its unique power over containment is **ordering at a path** | ||
| Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`; | ||
| on Supabase it is `selectorEq(col, path, value)` / `selectorNe(col, path, value)` | ||
| — equality/inequality only, since selector ORDERING over PostgREST needs an | ||
| EQL-bundle change (cipherstash/encrypt-query-language#407); see the | ||
| `stash-supabase` skill. Drizzle's unique power over containment is **ordering at a path** |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove references to sibling skill files.
The documentation instructs users to refer to the stash-supabase and stash-drizzle skills. As per path instructions, each skill file must be self-sufficient and should not rely on sibling files for content that must ship to users, because only SKILL.md is inlined.
📝 Proposed fix
-Through the Drizzle v3 integration this is `ops.contains(col, subObject)` (on Supabase: `contains(col, subObject)` — see the `stash-supabase` skill) — see
-the `stash-drizzle` skill.
+Through the Drizzle v3 integration this is `ops.contains(col, subObject)`. On Supabase, this is `contains(col, subObject)`.
**JSONPath selector-with-constraint** is the second query pattern: it compares
the encrypted value at a JSONPath, e.g. `metadata->'age' > 21`. Through the
Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`;
on Supabase it is `selectorEq(col, path, value)` / `selectorNe(col, path, value)`
— equality/inequality only, since selector ORDERING over PostgREST needs an
-EQL-bundle change (cipherstash/encrypt-query-language#407); see the
-`stash-supabase` skill. Drizzle's unique power over containment is **ordering at a path**
+EQL-bundle change (cipherstash/encrypt-query-language#407). Drizzle's unique power over containment is **ordering at a path**📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Through the Drizzle v3 integration this is `ops.contains(col, subObject)` (on Supabase: `contains(col, subObject)` — see the `stash-supabase` skill) — see | |
| the `stash-drizzle` skill. | |
| **JSONPath selector-with-constraint** is the second query pattern: it compares | |
| the encrypted value at a JSONPath, e.g. `metadata->'age' > 21`. Through the | |
| Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`. | |
| Its unique power over containment is **ordering at a path** | |
| Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`; | |
| on Supabase it is `selectorEq(col, path, value)` / `selectorNe(col, path, value)` | |
| — equality/inequality only, since selector ORDERING over PostgREST needs an | |
| EQL-bundle change (cipherstash/encrypt-query-language#407); see the | |
| `stash-supabase` skill. Drizzle's unique power over containment is **ordering at a path** | |
| Through the Drizzle v3 integration this is `ops.contains(col, subObject)`. On Supabase, this is `contains(col, subObject)`. | |
| **JSONPath selector-with-constraint** is the second query pattern: it compares | |
| the encrypted value at a JSONPath, e.g. `metadata->'age' > 21`. Through the | |
| Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`; | |
| on Supabase it is `selectorEq(col, path, value)` / `selectorNe(col, path, value)` | |
| — equality/inequality only, since selector ORDERING over PostgREST needs an | |
| EQL-bundle change (cipherstash/encrypt-query-language#407). Drizzle's unique power over containment is **ordering at a path** |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/stash-encryption/SKILL.md` around lines 688 - 697, Remove the
references to the sibling “stash-supabase” and “stash-drizzle” skills in the
JSON containment and JSONPath selector documentation. Make this section
self-contained by retaining or adding the necessary integration details directly
in SKILL.md, without directing users to other skill files.
Source: Coding guidelines
There was a problem hiding this comment.
Pull request overview
Adds EQL v3 encrypted-JSON querying support to the Supabase v3 adapter (@cipherstash/stack-supabase) for types.Json (public.eql_v3_json) columns, including exact encrypted containment and JSONPath selector equality/inequality, and shares selector-path parsing/validation logic across first-party adapters via @cipherstash/stack/adapter-kit.
Changes:
- Adds a shared selector-path helper module (parse/validate path, reconstruct selector needle document, scalar-leaf gating) and re-exports it from
@cipherstash/stack/adapter-kit. - Extends core v3 column typing to include
searchableJson(QueryTypesForColumn) and pins the behavior in core type-level tests. - Implements Supabase v3 encrypted JSON querying (
contains,selectorEq,selectorNe, rawcs/not.csrouting) with guards + adds unit/type/live integration coverage; updates shipped skills + adds changeset.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/stash-supabase/SKILL.md | Documents encrypted JSON querying on Supabase v3 (containment + selector eq/ne) and updates prior guidance about contains(). |
| skills/stash-encryption/SKILL.md | Cross-skill documentation update to mention the Supabase selector eq/ne surface and ordering limitation. |
| packages/stack/src/eql/v3/selector-path.ts | New shared JSON selector path parsing/validation + selector needle reconstruction + leaf gating. |
| packages/stack/src/eql/v3/columns.ts | Extends QueryTypesForColumn to include the searchableJson capability arm. |
| packages/stack/src/adapter-kit.ts | Re-exports selector-path helpers from adapter-kit for adapter reuse. |
| packages/stack/tests/v3-matrix/matrix.test-d.ts | Pins types.Json → QueryTypesForColumn = 'searchableJson' in core type suite. |
| packages/stack-supabase/src/types.ts | Adds JSON-query key sets and method overload types (V3SearchableJsonKeys, selector leaf types, JSON contains values). |
| packages/stack-supabase/src/query-builder-v3.ts | Implements encrypted JSON containment/selector filters over PostgREST cs/not.cs, plus runtime operand/path/leaf guards and capability routing. |
| packages/stack-supabase/src/helpers.ts | Updates operator→queryType mapping commentary for contains/matches behavior with JSON columns. |
| packages/stack-supabase/integration/json.integration.test.ts | New live PostgREST integration tests for encrypted JSON containment + selector eq/ne semantics (incl. absent-path + SQL-NULL behavior). |
| packages/stack-supabase/tests/supabase-v3.test-d.ts | Adds type-level coverage for JSON query key sets and selector/contains overload correctness. |
| packages/stack-supabase/tests/supabase-v3-json.test.ts | Adds unit tests around operand routing, guards, and mock-encryption behavior for JSON querying. |
| packages/stack-supabase/tests/supabase-v3-builder.test.ts | Updates a guard test to reflect the newly supported searchableJson query type. |
| packages/stack-supabase/tests/helpers/supabase-mock.ts | Adjusts the mock envelope tagging to support null-prototype selector needle objects. |
| packages/stack-drizzle/src/v3/operators.ts | Removes duplicated selector-path helpers and re-uses the shared adapter-kit implementation (with compatibility re-exports). |
| .changeset/supabase-v3-json-querying.md | Adds release notes and version bumps for @cipherstash/stack-supabase, @cipherstash/stack, and stash (skills ship). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * The scalar leaf value the selector methods compare at a JSONPath. Objects and | ||
| * arrays are rejected (that shape is `contains()`); see the adapters' shared | ||
| * `unsupportedLeafReason`. | ||
| */ | ||
| export type SelectorLeafValue = string | number | bigint | boolean | Date |
| type Instance = Awaited<ReturnType<typeof encryptedSupabaseV3>> | ||
| let instance: Instance | ||
|
|
||
| type Row = { row_key: string; payload: JsonDocument; test_run_id: string } |
| throw new Error( | ||
| `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value === 'object' ? (value as object).constructor?.name || 'a non-plain object' : typeof value}.`, | ||
| ) |
Closes #650. Adds encrypted-JSON querying for
types.Jsoncolumns to the v3 Supabase adapter — the PostgREST counterpart of the Drizzle #621 containment and #623 selector work.The wire-form investigation (the hard part per #650)
A live probe against PostgREST v12.2.12 + eql 3.0.0 (with
log_statement=allto read the emitted SQL) settled what is reachable:?doc=cs.<storage envelope>eql_v3."@>"(eql_v3_json, eql_v3_json)→ste_vec_contains{user:{role:'admin'}}not.cs.nesemantics for freeto_jsonb(col)(observed:WHERE to_jsonb("t"."doc")->$1 > $2), stripping the domain; bare-column comparison is blocked by design (P0001)Ordering needs an EQL-bundle overload — filed as cipherstash/encrypt-query-language#407 (needle-comparison semantics for the currently-blocked
(eql_v3_json, eql_v3_json)comparisons). Until then the skill steers ordering-at-a-path to Drizzle'sops.selector().Surface
V3SearchableJsonKeys; atypes.Jsoncolumn no longer erases tonever(coreQueryTypesForColumngains thesearchableJsonarm).matches()steers tocontains()/selectorEq()on JSON columns; scalar ops rejected by capability; paths/leaves validated with the same rules as Drizzle (shared helpers moved to@cipherstash/stack/adapter-kit; drizzle re-exports unchanged).Testing
anon— containment (multi-leaf, absent-value), selector eq (nested paths, string leaves, absent path), selector ne absent-path inclusion, rawcs, capability rejections.stash-supabasegains an "Encrypted JSON querying" section (incl. the ordering limitation and ciphertext-in-WHERE disclosure); the stalecontains()-always-throws bullet corrected.@cipherstash/stack-supabaseminor +@cipherstash/stackminor.https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Summary by CodeRabbit
New Features
types.Jsoncolumns, including exact encrypted sub-document containment viacontains().selectorEq()andselectorNe(), with nested dot-notation paths.not(... )for JSON negation.Bug Fixes
types.Jsoncolumns are correctly queryable.Tests
Documentation