Skip to content

Commit 60fd960

Browse files
committed
feat(stack)!: replace like/ilike with contains on the v3 supabase surface
EQL v3 free-text search is token containment over a bloom filter, not SQL wildcard matching: `%` is tokenized like any other character, so a `like` pattern is a category error. The v3 Drizzle integration already omits `like`/`ilike` in favour of `contains`; the supabase adapter now matches. This is a surface rename, not a re-encoding. The bundle declares CREATE OPERATOR @> (FUNCTION = eql_v3.contains, LEFTARG = public.text_search, RIGHTARG = jsonb) so PostgREST's `cs` already resolved through `@>` into `eql_v3.contains` → `match_term(a) @> match_term(b)` → smallint[] bloom containment. It was never the built-in `jsonb @> jsonb`. The emitted operator and the full-envelope operand are unchanged; no ciphertext moves. The live PostgREST suite proves the resolution: a 3-character needle matches while a 7-character one does not, and neither shares the stored `c`. `contains` is narrowed at compile time to freeTextSearch-capable domains (text_match, text_search). The shared builder interface is now parameterised by its own return type, so `like` is absent at every chain depth rather than only on the first call; the runtime still throws for untyped JS callers, and passes `like` through on a genuine plaintext column. Three silent-failure bugs fixed alongside. order() on ANY encrypted v3 column now throws, including the ORE-capable ones — the non-obvious half. The `*_ord` domains are `DOMAIN … AS jsonb` and the bundle declares no btree operator class on any domain (it lints against one), so `ORDER BY col` resolved through jsonb's default `jsonb_cmp` and sorted by the envelope's byte structure, first key `bf`. Stable, plausible-looking, and meaningless, with no error. Confirmed against the live database: integer_ord has zero operator classes and a jsonb base type. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which PostgREST's `order=` cannot express. The old guard rejected exactly the columns where the wrongness was obvious and admitted the ones where it was silent. gte/lte remain correct: the comparison operators ARE declared on the ord domains; only sorting resolves through the missing operator class. or() now understands PostgREST's `column.not.<op>.<value>` negation. The parser split on the first two dots, read `not` as the operator, and so `or('nickname.not.in.(ada,grace)')` on an encrypted column encrypted the literal string `in.(ada,grace)` as one plaintext — a filter that silently matched nothing. A malformed `col.not.<value>` is passed through for PostgREST to reject rather than dropped, which would quietly widen the result set. Raw filter() derives its query type from the operator on v3 instead of always encrypting an equality term, so `.filter('bio','cs',…)` on a text_match column (equality: false, freeTextSearch: true) answers the query it was given rather than being rejected. Unknown operators throw. The v2 base still returns 'equality' verbatim: there `queryType` selects the encryptQuery narrowing, so correcting it moves v2 ciphertext. Tracked separately. Every new test was mutation-checked against the implementation it protects. The include_original substring defect is untouched and still pinned by a test asserting the broken behaviour: the operand is a storage envelope whose bloom carries the whole needle as an extra token, so a substring only matches when it equals the stored value or is exactly the tokenizer's 3-character window. Shared with v3 Drizzle's contains and tracked upstream in EQL. BREAKING CHANGE: `like`/`ilike` are removed from the EQL v3 supabase builder — use `contains`. `order()` on an encrypted v3 column now throws; order by a plaintext column, expose `eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle integration. v2 (`encryptedSupabase`) is unchanged.
1 parent cbf2da4 commit 60fd960

12 files changed

Lines changed: 913 additions & 169 deletions

.changeset/eql-v3-supabase-adapter.md

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,46 @@ Every column name a query carries — filters, `match`, `not`, raw `filter`,
2020
`or()`, `order()`, and the `onConflict` option — is now resolved from its JS
2121
property name to its DB column name in a single pass before the query is built,
2222
so a declared rename round-trips everywhere rather than only on the paths that
23-
remembered to translate. `order()` on a column whose domain has no
24-
`orderAndRange` capability (e.g. a storage-only `public.boolean`) is rejected —
25-
at compile time when `schemas` is supplied, and at runtime otherwise — instead
26-
of silently sorting by the raw ciphertext envelope.
23+
remembered to translate.
2724

28-
v2 (`encryptedSupabase`) is unchanged.
25+
`order()` on ANY encrypted v3 column is now rejected — at compile time when
26+
`schemas` is supplied, and at runtime otherwise. The EQL v3 domains are
27+
`DOMAIN … AS jsonb` and the bundle declares no btree operator class on them, so
28+
`ORDER BY col` resolves through jsonb's default `jsonb_cmp` and sorts by the
29+
envelope's byte structure: a stable, plausible-looking, meaningless row order,
30+
with no error. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which
31+
PostgREST's `order=` cannot express. Order by a plaintext column, expose
32+
`eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle
33+
integration, which emits `ord_term` directly. Note `gte`/`lte` filters remain
34+
correct: the comparison operators *are* declared on the ord domains, and only
35+
sorting resolves through the missing operator class.
36+
37+
`.or()` now understands PostgREST's `column.not.<op>.<value>` negation. It was
38+
previously parsed as `{ op: 'not', value: '<op>.<value>' }`, so on an encrypted
39+
column `or('nickname.not.in.(ada,grace)')` encrypted the literal string
40+
`in.(ada,grace)` as a single plaintext and produced a filter that silently
41+
matched nothing.
42+
43+
Free-text search on the v3 builder is `contains(column, value)`. `like`/`ilike`
44+
are not exposed, because EQL v3 free-text search is token containment over a
45+
bloom filter (`@>`, backed by `eql_v3.contains`) rather than SQL wildcard
46+
matching — `%` is tokenized like any other character, so a `like` pattern is a
47+
category error. This matches the v3 Drizzle integration, which omits them for
48+
the same reason. On an encrypted column `like`/`ilike` now throw and name
49+
`contains`; on a plaintext column they remain ordinary PostgREST filters.
50+
51+
`contains` is narrowed at compile time to columns whose domain carries the
52+
`freeTextSearch` capability (`public.text_match`, `public.text_search`), and
53+
guarded at runtime for the untyped surface. A raw `filter(column, operator, …)`
54+
on an encrypted v3 column now derives its query type from the operator instead
55+
of always encrypting an equality term, so `filter('bio', 'cs', …)` on a
56+
`public.text_match` column works rather than being rejected, and an unsupported
57+
operator throws instead of silently encrypting the wrong term.
58+
59+
Substring `contains` still matches only when the needle equals the stored value
60+
or is exactly the tokenizer's window (3 characters): the operand is a storage
61+
envelope whose bloom carries the whole needle as an `include_original` token.
62+
This is shared with v3 Drizzle's `contains` and tracked upstream in EQL.
63+
64+
v2 (`encryptedSupabase`) is unchanged: it keeps `like`/`ilike` (`eql_v2.like`,
65+
`~~`) and its raw-`filter` query-type mapping, so no v2 ciphertext moves.

packages/stack/__tests__/helpers/supabase-mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export function createMockSupabase(resultData: unknown = []) {
155155
'lte',
156156
'like',
157157
'ilike',
158+
'contains',
158159
'is',
159160
'in',
160161
'filter',

packages/stack/__tests__/supabase-helpers.test.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('addJsonbCastsV3', () => {
6262
expect(addJsonbCastsV3('a:toString', propToDb)).toBe('a:toString')
6363
})
6464

65+
// Pins the leading-whitespace capture: drop it and ` email` loses its space.
6566
it('maps each token of a multi-column select independently', () => {
6667
expect(addJsonbCastsV3('id, email, createdAt', propToDb)).toBe(
6768
'id, email::jsonb, createdAt:created_at::jsonb',
@@ -82,8 +83,8 @@ describe('addJsonbCastsV3', () => {
8283
const ENVELOPE = '{"v":1,"i":{"t":"users","c":"email"},"c":"ct:abc"}'
8384

8485
/** `rebuildOrString` takes DB-space conditions; `column` is a branded `DbName`. */
85-
function cond(column: string, op: string, value: unknown) {
86-
return { column, op, value } as unknown as DbPendingOrCondition
86+
function cond(column: string, op: string, value: unknown, negate?: boolean) {
87+
return { column, op, value, negate } as unknown as DbPendingOrCondition
8788
}
8889

8990
describe('rebuildOrString quoting', () => {
@@ -110,7 +111,9 @@ describe('rebuildOrString quoting', () => {
110111

111112
describe('parseOrString / rebuildOrString round-trip', () => {
112113
it('round-trips an encrypted JSON envelope operand', () => {
113-
const conditions = [{ column: 'email', op: 'eq', value: ENVELOPE }]
114+
const conditions = [
115+
{ column: 'email', op: 'eq', negate: false, value: ENVELOPE },
116+
]
114117
expect(
115118
parseOrString(
116119
rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))),
@@ -119,7 +122,9 @@ describe('parseOrString / rebuildOrString round-trip', () => {
119122
})
120123

121124
it('round-trips a value carrying backslashes and quotes', () => {
122-
const conditions = [{ column: 'a', op: 'eq', value: 'x\\"y,z' }]
125+
const conditions = [
126+
{ column: 'a', op: 'eq', negate: false, value: 'x\\"y,z' },
127+
]
123128
expect(
124129
parseOrString(
125130
rebuildOrString(conditions.map((c) => cond(c.column, c.op, c.value))),
@@ -138,3 +143,86 @@ describe('parseOrString / rebuildOrString round-trip', () => {
138143
expect(parsed[1].value).toBe('7')
139144
})
140145
})
146+
147+
// ---------------------------------------------------------------------------
148+
// PostgREST negation inside .or()
149+
//
150+
// The parser split on the first two dots, so `col.not.in.(a,b)` yielded
151+
// `{ op: 'not', value: 'in.(a,b)' }`. On a plaintext column that round-tripped
152+
// by accident (rebuild re-joins the pieces verbatim). On an ENCRYPTED column the
153+
// literal string `in.(a,b)` was encrypted as one plaintext, producing a filter
154+
// that silently matched nothing.
155+
// ---------------------------------------------------------------------------
156+
157+
describe('parseOrString negation', () => {
158+
it('lifts a not. prefix off the operator', () => {
159+
expect(parseOrString('nickname.not.eq.ada')).toEqual([
160+
{ column: 'nickname', op: 'eq', negate: true, value: 'ada' },
161+
])
162+
})
163+
164+
it('parses a negated in-list as a real list, not a literal string', () => {
165+
expect(parseOrString('nickname.not.in.(ada,grace)')).toEqual([
166+
{ column: 'nickname', op: 'in', negate: true, value: ['ada', 'grace'] },
167+
])
168+
})
169+
170+
it('parses not.is.null', () => {
171+
expect(parseOrString('email.not.is.null')).toEqual([
172+
{ column: 'email', op: 'is', negate: true, value: null },
173+
])
174+
})
175+
176+
it('leaves a non-negated condition unnegated', () => {
177+
expect(parseOrString('nickname.in.(ada,grace)')).toEqual([
178+
{ column: 'nickname', op: 'in', negate: false, value: ['ada', 'grace'] },
179+
])
180+
})
181+
182+
it('does not mistake a column or value named "not" for the prefix', () => {
183+
expect(parseOrString('not.eq.ada')).toEqual([
184+
{ column: 'not', op: 'eq', negate: false, value: 'ada' },
185+
])
186+
expect(parseOrString('nickname.eq.not')).toEqual([
187+
{ column: 'nickname', op: 'eq', negate: false, value: 'not' },
188+
])
189+
})
190+
191+
it('does not swallow a condition whose not. prefix has no operator', () => {
192+
// `col.not.<value>` is malformed PostgREST. Consuming the prefix would leave
193+
// no operator, and the condition would be silently DROPPED from the or-string
194+
// — quietly widening the result set. Pass it through so PostgREST rejects it.
195+
expect(parseOrString('nickname.not.ada')).toEqual([
196+
{ column: 'nickname', op: 'not', negate: false, value: 'ada' },
197+
])
198+
expect(
199+
rebuildOrString(
200+
parseOrString('nickname.not.ada') as DbPendingOrCondition[],
201+
),
202+
).toBe('nickname.not.ada')
203+
})
204+
})
205+
206+
describe('rebuildOrString negation', () => {
207+
it('re-emits the not. prefix', () => {
208+
expect(rebuildOrString([cond('nickname', 'eq', 'ada', true)])).toBe(
209+
'nickname.not.eq.ada',
210+
)
211+
})
212+
213+
it('round-trips a negated in-list through parse → rebuild', () => {
214+
const parsed = parseOrString('nickname.not.in.(ada,grace)')
215+
expect(rebuildOrString(parsed as DbPendingOrCondition[])).toBe(
216+
'nickname.not.in.(ada,grace)',
217+
)
218+
})
219+
220+
it('omits the prefix when negate is false or absent', () => {
221+
expect(rebuildOrString([cond('nickname', 'eq', 'ada', false)])).toBe(
222+
'nickname.eq.ada',
223+
)
224+
expect(rebuildOrString([cond('nickname', 'eq', 'ada')])).toBe(
225+
'nickname.eq.ada',
226+
)
227+
})
228+
})

0 commit comments

Comments
 (0)