|
| 1 | +--- |
| 2 | +'@cipherstash/stack': minor |
| 3 | +--- |
| 4 | + |
| 5 | +Fix encrypted `in`-list operands in the Supabase adapter, and widen the `is` / |
| 6 | +`contains` type surfaces. |
| 7 | + |
| 8 | +**`in()` on an encrypted column produced a request PostgREST rejects.** Every |
| 9 | +encrypted operand is a serialized envelope, dense with `"` and `,`. postgrest-js |
| 10 | +wraps a comma-bearing element as `"…"` but never escapes the quotes already |
| 11 | +inside it, so `.in('email', […])` emitted |
| 12 | + |
| 13 | +``` |
| 14 | +in.("{"v":1,"c":"…"}",…) |
| 15 | + ^ PostgREST ends the value here → PGRST100 |
| 16 | +``` |
| 17 | + |
| 18 | +Encrypted lists are now emitted through `filter(col, 'in', …)` with each element |
| 19 | +quoted and escaped, matching what the `.or()` path already did. This affects |
| 20 | +**v2 as well as v3** — v2's `("a@b.com")` composite literal is itself |
| 21 | +quote-bearing and was equally broken. |
| 22 | + |
| 23 | +**`not(col, 'in', […])` encrypted the whole list as a single ciphertext**, so |
| 24 | +the filter silently matched nothing, and emitted an unparenthesized |
| 25 | +`not.in.a,b`. Each element is now encrypted separately and the operand is |
| 26 | +rendered as `not.in.(…)`. Passing a PostgREST list literal (`'(a,b)'`) for an |
| 27 | +encrypted column now throws instead of silently matching nothing — pass an |
| 28 | +array. |
| 29 | + |
| 30 | +**`is(col, null)` is now allowed on every column**, including storage-only |
| 31 | +encrypted ones (`types.Boolean`, `types.Integer`, …). `is` is never encrypted |
| 32 | +and a NULL plaintext is stored as a SQL NULL, so `IS NULL` is not merely legal |
| 33 | +there but the only predicate those columns support. `is(col, true)` remains a |
| 34 | +compile error on encrypted columns. |
| 35 | + |
| 36 | +**`contains()` accepts native operands on plaintext array and jsonb columns.** A |
| 37 | +plaintext jsonb/array column falls through to PostgREST's native containment, so |
| 38 | +`contains('tags', ['vip'])` and `contains('meta', { plan: 'pro' })` now |
| 39 | +typecheck. A plaintext SCALAR column does not: `@>` is undefined on `text`, so |
| 40 | +the operand type follows the column's own shape and a scalar rejects every |
| 41 | +containment operand. Encrypted match columns still take a `string` token. |
| 42 | +Relatedly, `.or([{ op: 'contains' }])` now emits PostgREST's `cs` operator for |
| 43 | +plaintext columns too — previously only encrypted conditions were translated, so |
| 44 | +a plaintext containment reached the wire as `.contains.` and failed to parse. |
| 45 | + |
| 46 | +**Direct `contains()` / `not(col, 'contains', …)` now serialize their operand.** |
| 47 | +postgrest-js builds an array operand as `cs.{a,b}` with no element quoting, so |
| 48 | +`contains('tags', ['with,comma'])` reached Postgres as two elements; and its |
| 49 | +`not()` stringifies the operand outright, emitting `not.contains.with,comma` |
| 50 | +(no braces, and the wrong operator token) or `[object Object]` for a jsonb |
| 51 | +operand. Both paths now build the containment literal the `.or()` path already |
| 52 | +built, and emit the `cs` token. |
| 53 | + |
| 54 | +**`.or()` no longer drops a condition after an unbalanced brace or paren.** A |
| 55 | +scalar operand containing `{` left the parser's depth counter stranded above |
| 56 | +zero, so no later comma separated a condition and everything behind it was |
| 57 | +swallowed into that operand. With a plaintext column first, the group was then |
| 58 | +forwarded verbatim — running the swallowed condition against a ciphertext column |
| 59 | +with a plaintext operand. Braces are now quoted on emit (they are structural to |
| 60 | +PostgREST inside `or=(…)`), and the parser falls back to quote-only splitting |
| 61 | +when its depth tracking does not balance. |
| 62 | + |
| 63 | +**`is(col, true)` is now rejected on every encrypted column, not just the |
| 64 | +storage-only ones.** The boolean form was gated on the filterable keys, which |
| 65 | +exclude storage-only columns but keep queryable encrypted ones — so |
| 66 | +`is(emailTextSearchColumn, true)` compiled and emitted `IS TRUE` against a jsonb |
| 67 | +ciphertext. |
| 68 | + |
| 69 | +**In-list operands encrypt in one crossing per column.** The element-wise `in` / |
| 70 | +`not.in` encoding above spent one ZeroKMS round-trip per element; terms are now |
| 71 | +grouped by column and each group takes a single `bulkEncrypt` call, matching the |
| 72 | +Drizzle v3 path. Falls back to per-term encryption for clients without |
| 73 | +`bulkEncrypt`, and rejects a bulk response whose length does not match the list |
| 74 | +rather than silently truncating the predicate. |
0 commit comments