@cipherstash/stack 0.19.0 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +531 -0
  2. package/README.md +95 -77
  3. package/dist/adapter-kit.cjs +923 -0
  4. package/dist/adapter-kit.cjs.map +1 -0
  5. package/dist/adapter-kit.d.cts +82 -0
  6. package/dist/adapter-kit.d.ts +82 -0
  7. package/dist/adapter-kit.js +47 -0
  8. package/dist/adapter-kit.js.map +1 -0
  9. package/dist/base-operation-AOAIvsSB.d.cts +32 -0
  10. package/dist/base-operation-FXEzUXIq.d.ts +32 -0
  11. package/dist/{chunk-4AVL4VZD.js → chunk-3B5ZX3IS.js} +3 -1
  12. package/dist/chunk-3B5ZX3IS.js.map +1 -0
  13. package/dist/chunk-7333ZC6L.js +48 -0
  14. package/dist/chunk-7333ZC6L.js.map +1 -0
  15. package/dist/{chunk-MP3SSDNN.js → chunk-CLM7E4I6.js} +15 -15
  16. package/dist/{chunk-MP3SSDNN.js.map → chunk-CLM7E4I6.js.map} +1 -1
  17. package/dist/{chunk-U66S7VIF.js → chunk-V2Q3NYIH.js} +166 -107
  18. package/dist/chunk-V2Q3NYIH.js.map +1 -0
  19. package/dist/chunk-X3JRXEIB.js +98 -0
  20. package/dist/chunk-X3JRXEIB.js.map +1 -0
  21. package/dist/{chunk-36AA7IBJ.js → chunk-ZTP5QJ27.js} +51 -18
  22. package/dist/chunk-ZTP5QJ27.js.map +1 -0
  23. package/dist/client.cjs +29 -12
  24. package/dist/client.cjs.map +1 -1
  25. package/dist/client.d.cts +3 -2
  26. package/dist/client.d.ts +3 -2
  27. package/dist/client.js +2 -2
  28. package/dist/{table-DihEAlxG.d.cts → columns-D2_YzrCX.d.cts} +155 -136
  29. package/dist/{table-CIH7jZ2h.d.ts → columns-rZc7fQHI.d.ts} +155 -136
  30. package/dist/dynamodb/index.d.cts +3 -2
  31. package/dist/dynamodb/index.d.ts +3 -2
  32. package/dist/encryption/index.cjs +54 -16
  33. package/dist/encryption/index.cjs.map +1 -1
  34. package/dist/encryption/index.d.cts +807 -6
  35. package/dist/encryption/index.d.ts +807 -6
  36. package/dist/encryption/index.js +4 -4
  37. package/dist/encryption/v3.cjs +1078 -973
  38. package/dist/encryption/v3.cjs.map +1 -1
  39. package/dist/encryption/v3.d.cts +15 -12
  40. package/dist/encryption/v3.d.ts +15 -12
  41. package/dist/encryption/v3.js +49 -21
  42. package/dist/encryption/v3.js.map +1 -1
  43. package/dist/eql/v3/index.cjs +130 -79
  44. package/dist/eql/v3/index.cjs.map +1 -1
  45. package/dist/eql/v3/index.d.cts +115 -13
  46. package/dist/eql/v3/index.d.ts +115 -13
  47. package/dist/eql/v3/index.js +16 -6
  48. package/dist/index-BquA71_Y.d.ts +24 -0
  49. package/dist/index-fhWTOV0K.d.cts +24 -0
  50. package/dist/index.cjs +79 -27
  51. package/dist/index.cjs.map +1 -1
  52. package/dist/index.d.cts +5 -17
  53. package/dist/index.d.ts +5 -17
  54. package/dist/index.js +4 -4
  55. package/dist/schema/index.cjs +29 -12
  56. package/dist/schema/index.cjs.map +1 -1
  57. package/dist/schema/index.d.cts +1 -1
  58. package/dist/schema/index.d.ts +1 -1
  59. package/dist/schema/index.js +2 -2
  60. package/dist/{types-public-CpS5KjwX.d.ts → types-public-QMjYNfQO.d.cts} +765 -726
  61. package/dist/{types-public-CpS5KjwX.d.cts → types-public-QMjYNfQO.d.ts} +765 -726
  62. package/dist/types-public.cjs.map +1 -1
  63. package/dist/types-public.d.cts +1 -1
  64. package/dist/types-public.d.ts +1 -1
  65. package/dist/types-public.js +1 -1
  66. package/dist/wasm-inline.d.ts +779 -405
  67. package/dist/wasm-inline.js +606 -299
  68. package/dist/wasm-inline.js.map +1 -1
  69. package/package.json +25 -53
  70. package/dist/chunk-36AA7IBJ.js.map +0 -1
  71. package/dist/chunk-4AVL4VZD.js.map +0 -1
  72. package/dist/chunk-IADZCZEA.js +0 -23
  73. package/dist/chunk-IADZCZEA.js.map +0 -1
  74. package/dist/chunk-IBSK6P33.js +0 -209
  75. package/dist/chunk-IBSK6P33.js.map +0 -1
  76. package/dist/chunk-U66S7VIF.js.map +0 -1
  77. package/dist/client-DSGHBN-g.d.cts +0 -834
  78. package/dist/client-DfCrlHXh.d.ts +0 -834
  79. package/dist/drizzle/index.cjs +0 -5617
  80. package/dist/drizzle/index.cjs.map +0 -1
  81. package/dist/drizzle/index.d.cts +0 -358
  82. package/dist/drizzle/index.d.ts +0 -358
  83. package/dist/drizzle/index.js +0 -1220
  84. package/dist/drizzle/index.js.map +0 -1
  85. package/dist/supabase/index.cjs +0 -5951
  86. package/dist/supabase/index.cjs.map +0 -1
  87. package/dist/supabase/index.d.cts +0 -223
  88. package/dist/supabase/index.d.ts +0 -223
  89. package/dist/supabase/index.js +0 -1217
  90. package/dist/supabase/index.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,536 @@
1
1
  # @cipherstash/stack
2
2
 
3
+ ## 1.0.0-rc.0
4
+
5
+ ### Major Changes
6
+
7
+ - 7c7dbca: CipherStash Stack 1.0 (release candidate).
8
+
9
+ This is the first 1.0-line release of `@cipherstash/stack`, the first published
10
+ release of the split-out EQL v3 adapters `@cipherstash/stack-drizzle` and
11
+ `@cipherstash/stack-supabase`, and moves the `stash` CLI to 1.0 alongside them.
12
+ These four packages now version together as the Stack 1.0 family.
13
+
14
+ ### Minor Changes
15
+
16
+ - 31ca318: Split the Drizzle and Supabase integrations into their own packages.
17
+
18
+ The adapters now ship as first-party packages that depend on `@cipherstash/stack`,
19
+ following the `@cipherstash/prisma-next` precedent:
20
+
21
+ - **`@cipherstash/stack-drizzle`** — Drizzle ORM integration. EQL v2 on the package
22
+ root (`@cipherstash/stack-drizzle`: `encryptedType`, `extractEncryptionSchema`,
23
+ `createEncryptionOperators`) and EQL v3 on `@cipherstash/stack-drizzle/v3`
24
+ (`types` factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, …).
25
+ - **`@cipherstash/stack-supabase`** — Supabase integration: `encryptedSupabase` (v2)
26
+ and `encryptedSupabaseV3` (v3, connect-time introspection).
27
+
28
+ **Breaking (`@cipherstash/stack`):** the `./drizzle`, `./supabase`, and
29
+ `./eql/v3/drizzle` subpath exports are removed. Migrate imports:
30
+
31
+ - `@cipherstash/stack/drizzle` → `@cipherstash/stack-drizzle`
32
+ - `@cipherstash/stack/eql/v3/drizzle` → `@cipherstash/stack-drizzle/v3`
33
+ - `@cipherstash/stack/supabase` → `@cipherstash/stack-supabase`
34
+
35
+ Add the relevant package to your dependencies alongside `@cipherstash/stack`. A new
36
+ `@cipherstash/stack/adapter-kit` subpath exposes the narrow core internals the
37
+ first-party adapters consume; it is the core↔adapter seam, not general-purpose API.
38
+
39
+ - c4787c0: Restore the EQL v3 envelope and `Result` types the adapters were erasing.
40
+
41
+ Both v3 adapters typed their operand-encryption paths as `unknown` and dropped
42
+ the `Result` wrapper, so the query-type encoding and the failure channel were
43
+ invisible to the type system:
44
+
45
+ - `eql/v3/drizzle/operators.ts` typed the client's `encrypt`/`bulkEncrypt` as
46
+ returning `unknown`, collapsed the operation's `Result` to
47
+ `{ data?: unknown; failure?: { message } }`, and cast the bulk response to
48
+ `Array<{ data: unknown }>`.
49
+ - `supabase/query-builder-v3.ts` returned `Promise<unknown[]>` from
50
+ `encryptCollectedTerms`, `bulkEncryptGroup` and `encryptGroupPerTerm`, and the
51
+ base `query-builder.ts` did the same.
52
+
53
+ These now carry the SDK's real types — `Encrypted` (the storage envelope union,
54
+ which includes every v3 per-domain payload), `BulkEncryptedData`, and
55
+ `EncryptedQueryResult` — threaded through a properly-typed operation surface that
56
+ resolves `Result<T, EncryptionError>`. The Supabase divergence the erasure hid is
57
+ now explicit: the v2 path yields `encryptQuery` composite literals and the v3
58
+ path yields `JSON.stringify`'d envelope strings, and both are `EncryptedQueryResult`.
59
+
60
+ Bumped `minor`, not `patch`: `createEncryptionOperatorsV3` is a public export
61
+ (`@cipherstash/stack/eql/v3/drizzle`), and tightening its client contract from
62
+ `unknown` to a typed operation surface is a compile-time breaking change — a
63
+ downstream consumer passing a loosely-typed (`unknown`-returning) client double
64
+ will now fail `tsc`. That tightening has teeth: `operators.test-d.ts` pins it
65
+ with a negative type-test asserting an `unknown`-returning `{ encrypt }` double
66
+ is rejected (a positive "correctly-typed double is accepted" assertion cannot
67
+ catch a re-erasure, since a correct value is assignable to `unknown`).
68
+
69
+ Behaviour is otherwise unchanged, with one addition: the Supabase v3 bulk path
70
+ now rejects a `null` envelope returned by `bulkEncrypt` (the restored
71
+ `Encrypted | null` type makes that arm reachable, and a `null` would otherwise
72
+ be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand).
73
+
74
+ - 66a0e02: Add the EQL v3 bigint domain family to the public DSL: `types.Bigint`,
75
+ `types.BigintEq`, `types.BigintOrdOre`, and `types.BigintOrd`, backed by the
76
+ `public.bigint*` concrete domains. Plaintext is a JS `bigint`, round-tripped
77
+ losslessly across the protect-ffi 0.28 boundary (i64 bounds enforced at the
78
+ FFI — out-of-range values surface as encryption errors). Index emission follows
79
+ the numeric rule: `bigint_eq` → unique (hm); `bigint_ord`/`bigint_ord_ore` →
80
+ ore (equality answered via ob).
81
+ - 7eba32d: EQL v3 Drizzle: encrypt every query operand with `encryptQuery`, not `encrypt` (#622).
82
+
83
+ The v3 Drizzle operators (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`notBetween`/
84
+ `inArray`/`notInArray`/`contains`) previously encrypted their operands with
85
+ `client.encrypt`, producing a full storage envelope (including the ciphertext `c`)
86
+ cast to `::jsonb`. A WHERE-clause operand should be a query _term_, not a value to
87
+ store. Every operator now uses `client.encryptQuery`, which yields a
88
+ ciphertext-free query term cast to the column's `eql_v3.query_<domain>` type — so
89
+ predicates carry no ciphertext and reach the bundle's `(domain, query_<domain>)`
90
+ operator overloads. This unifies the scalar/text operators with the JSON
91
+ containment path (already on `encryptQuery`) and removes the previously-optional
92
+ `encryptQuery` guard: it is now a required capability of the operand client.
93
+
94
+ `@cipherstash/stack` gains a batch `encryptQuery(terms)` overload on
95
+ `TypedEncryptionClient` (the type `EncryptionV3` returns), mirroring the nominal
96
+ `EncryptionClient`. This is additive — it lets `inArray`/`notInArray` encrypt a
97
+ whole list of query terms in one crossing.
98
+
99
+ - 0ebf57e: Close two fail-open paths in the EQL v3 Drizzle adapter.
100
+
101
+ `ops.contains()` now throws `EncryptionOperatorError` for a search term that
102
+ tokenizes to nothing: the empty string, or a term shorter than the match index
103
+ tokenizer's `token_length` (3 by default). Such a term produces an empty bloom
104
+ filter, and `stored_bf @> '{}'` is true for every row — so a user searching
105
+ `"ad"` silently received the entire table. Measured live, the terms `"ad"`,
106
+ `"a"` and `"x"` each returned every seeded row, including one in which `"x"`
107
+ did not appear.
108
+
109
+ The floor counts Unicode codepoints, matching the tokenizer. A UTF-16 length
110
+ check would wave through an astral-plane term — `"👍👍"` is 4 code units but
111
+ only 2 codepoints, yields no trigram, and matched every row.
112
+
113
+ **Breaking for callers passing short terms:** `contains()` calls that previously
114
+ returned every row now throw. Terms of 3+ codepoints are unaffected.
115
+
116
+ `v3FromDriver()` now throws the new `EqlV3CodecError` on a payload that is not
117
+ an EQL envelope, instead of surfacing a raw `SyntaxError` for malformed JSON and
118
+ passing a bare scalar through unchecked — `v3FromDriver('5')` previously returned
119
+ `5` typed as `Encrypted`, which then reached `decrypt` as garbage. The guard
120
+ accepts both scalar envelopes (ciphertext at `c`) and SteVec documents
121
+ (ciphertext at `sv[0].c`). A SteVec's `sv` must be a non-empty array: `sv[0]` is
122
+ the decryption root, so `sv: []` carries a ciphertext key but no ciphertext, and
123
+ is now rejected rather than passed to `decrypt`. `EqlV3CodecError` is exported
124
+ from `@cipherstash/stack/eql/v3/drizzle` so callers can catch it.
125
+
126
+ Also removes an unreachable branch in `inArray`/`notInArray`, whose empty-list
127
+ guard already throws before it.
128
+
129
+ Note: the v2 Drizzle adapter's `like`/`ilike` path builds the same bloom filters
130
+ and has the same short-term fail-open. It is **not** fixed here — v2 terms carry
131
+ SQL wildcards, so the floor must be measured against what its tokenizer actually
132
+ receives before the shared guard can be reused. Tracked separately.
133
+
134
+ - d73a03c: Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native
135
+ `types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares
136
+ encrypted columns whose Postgres type is the semantic `public.<domain>`; the concrete
137
+ type drives the legal query operators. `createEncryptionOperatorsV3` provides
138
+ capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/
139
+ `asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with
140
+ full-envelope operands, and
141
+ `extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2
142
+ `@cipherstash/stack/drizzle` integration is unchanged.
143
+
144
+ The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not
145
+ exposed because v3 free-text search is token containment rather than SQL wildcard
146
+ matching.
147
+
148
+ - 89b903f: Upgrade `@cipherstash/protect-ffi` to 0.28.0 and update EQL v3 concrete Postgres domain names to match the SQL fixture (`integer*`, `smallint*`, `bool`, `real*`, and `double*`). The public factories remain semantic (`Integer`, `Smallint`, `Boolean`, `Real`, `Double`) while their concrete domains change, so this is a minor release.
149
+ - 229ce59: Re-baseline EQL v3 on the eql-3.0.0 GA release and protect-ffi 0.29.
150
+
151
+ - **Breaking (v3 preview surface):** the EQL v3 column domains follow the
152
+ eql-3.0.0 naming convention — flat, prefixed names in `public`
153
+ (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, …) instead of the
154
+ alpha-era bare names. Databases installed from an alpha bundle must be
155
+ re-installed (`stash eql install --eql-version 3` replaces the schema).
156
+ - `encryptQuery` under `eqlVersion: 3` now returns EQL v3 query operands
157
+ (protect-ffi 0.29): term-only scalar operands for the `eql_v3.query_<name>`
158
+ domains, the `eql_v3.query_jsonb` containment needle, and bare selector
159
+ hashes for JSON path queries — v3 scalar and selector queries no longer
160
+ throw `EQL_V3_QUERY_UNSUPPORTED` (the code is gone).
161
+ - v2 `searchableJson()` columns now pin the SteVec encoding to `standard`
162
+ explicitly. protect-ffi 0.29 flipped the library default to `compat`
163
+ (EQL v3's encoding); without the pin, v2 JSON containment queries would
164
+ silently match nothing and newly written rows would not be comparable with
165
+ existing ones.
166
+ - The EQL v3 test/install SQL is sourced from the pinned `@cipherstash/eql`
167
+ package (3.0.0) instead of a hand-vendored fixture.
168
+
169
+ - 50c0a9c: Add EQL v3 JSON columns. `types.Json('col')` declares a `public.eql_v3_json`
170
+ column that encrypts a JSON document to an ste_vec `SteVecDocument` and
171
+ round-trips it losslessly through `encrypt`/`decrypt` and the model path. A new
172
+ `searchableJson` query capability emits the ste_vec index; the index uses
173
+ `mode: 'compat'`, which eql-3.0.0's `eql_v3_json` requires (it orders ste_vec
174
+ entries by the CLLW-OPE `op` term, so v2's `'standard'`/CLLW-`oc` terms are
175
+ rejected).
176
+
177
+ The Drizzle integration's `contains(col, subObject)` now answers encrypted-JSONB
178
+ containment on a `types.Json` column, emitting the `@>` operator with a
179
+ `query_jsonb` needle (from `encryptQuery`). The ste_vec index indexes array
180
+ elements by identity but not position, so containment is a true subset test
181
+ (`{ roles: ['x'] }` matches any document whose `roles` array contains `x`,
182
+ regardless of index).
183
+
184
+ - 5d23e80: Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is
185
+ now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or
186
+ `(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns
187
+ by their Postgres domain (`information_schema.columns.domain_name`), and derives
188
+ each column's encryption config from its domain — callers no longer pass a
189
+ schema to `from()`. `select('*')` is supported (expanded from the introspected
190
+ column list, and aliased back to each declared column's JS property name so a
191
+ property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model
192
+ (e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently
193
+ passing through. Supplying `schemas` remains optional and adds compile-time
194
+ types plus startup verification of the declared tables against the database.
195
+ Requires a Postgres connection for introspection (`pg` is a new optional peer),
196
+ so it cannot run in a Worker or the browser.
197
+
198
+ Every column name a query carries — filters, `match`, `not`, raw `filter`,
199
+ `or()`, `order()`, and the `onConflict` option — is now resolved from its JS
200
+ property name to its DB column name in a single pass before the query is built,
201
+ so a declared rename round-trips everywhere rather than only on the paths that
202
+ remembered to translate.
203
+
204
+ `order()` on ANY encrypted v3 column is now rejected — at compile time when
205
+ `schemas` is supplied, and at runtime otherwise. The EQL v3 domains are
206
+ `DOMAIN … AS jsonb` and the bundle declares no btree operator class on them, so
207
+ `ORDER BY col` resolves through jsonb's default `jsonb_cmp` and sorts by the
208
+ envelope's byte structure: a stable, plausible-looking, meaningless row order,
209
+ with no error. Correct ordering is `ORDER BY eql_v3.ord_term(col)`, which
210
+ PostgREST's `order=` cannot express. Order by a plaintext column, expose
211
+ `eql_v3.ord_term()` as a generated column or view, or use the EQL v3 Drizzle
212
+ integration, which emits `ord_term` directly. Note `gte`/`lte` filters remain
213
+ correct: the comparison operators _are_ declared on the ord domains, and only
214
+ sorting resolves through the missing operator class.
215
+
216
+ `.or()` now understands PostgREST's `column.not.<op>.<value>` negation. It was
217
+ previously parsed as `{ op: 'not', value: '<op>.<value>' }`, so on an encrypted
218
+ column `or('nickname.not.in.(ada,grace)')` encrypted the literal string
219
+ `in.(ada,grace)` as a single plaintext and produced a filter that silently
220
+ matched nothing.
221
+
222
+ Free-text search on the v3 builder is `contains(column, value)`. `like`/`ilike`
223
+ are not exposed, because EQL v3 free-text search is token containment over a
224
+ bloom filter (`@>`, backed by `eql_v3.contains`) rather than SQL wildcard
225
+ matching — `%` is tokenized like any other character, so a `like` pattern is a
226
+ category error. This matches the v3 Drizzle integration, which omits them for
227
+ the same reason. On an encrypted column `like`/`ilike` now throw and name
228
+ `contains`; on a plaintext column they remain ordinary PostgREST filters.
229
+
230
+ `contains` is narrowed at compile time to columns whose domain carries the
231
+ `freeTextSearch` capability (`public.text_match`, `public.text_search`), and
232
+ guarded at runtime for the untyped surface. A raw `filter(column, operator, …)`
233
+ on an encrypted v3 column now derives its query type from the operator instead
234
+ of always encrypting an equality term, so `filter('bio', 'cs', …)` on a
235
+ `public.text_match` column works rather than being rejected, and an unsupported
236
+ operator throws instead of silently encrypting the wrong term.
237
+
238
+ Substring `contains` matches any needle whose trigrams are all present in the
239
+ stored value; needles shorter than the tokenizer's window (3 characters) bloom to
240
+ nothing and are rejected rather than silently matching every row. The v3 match
241
+ index now emits `include_original: false` — the flag is inert in protect-ffi (the
242
+ bloom is trigram-only either way), so this moves no ciphertext and only pins the
243
+ value a substring-search domain wants.
244
+
245
+ v2 (`encryptedSupabase`) is unchanged: it keeps `like`/`ilike` (`eql_v2.like`,
246
+ `~~`) and its raw-`filter` query-type mapping, so no v2 ciphertext moves.
247
+
248
+ - 1aa9a11: Add the EQL v3 `text_search` authoring DSL on a new `@cipherstash/stack/eql/v3`
249
+ subpath (`types.TextSearch`, v3 `encryptedTable` / `buildEncryptConfig`). The v3
250
+ builders emit the existing `EncryptConfig` shape, so encryption, payloads, and
251
+ query paths are unchanged at runtime.
252
+
253
+ Also widens the public client types (`EncryptionClientConfig.schemas`,
254
+ `EncryptOptions`, `SearchTerm`/`EncryptQueryOptions`) to a structural contract so
255
+ both v2 and v3 builders are accepted by `Encryption` / `encrypt` / `decrypt` /
256
+ `encryptQuery`. This is a backward-compatible widening — existing v2 usage is
257
+ unaffected. The structural contracts themselves (`BuildableColumn`,
258
+ `BuildableQueryColumn`, `BuildableV3QueryableColumn`, `BuildableTable`,
259
+ `BuildableTableColumns`) and the `encryptModel` return-type mapper
260
+ (`EncryptedFromBuildableTable`) are exported from `@cipherstash/stack/types` so
261
+ consumers can name them.
262
+
263
+ - af2d04e: Add a strongly-typed EQL v3 client surface on a new `@cipherstash/stack/v3`
264
+ subpath (`EncryptionV3`, `typedClient`, `TypedEncryptionClient`). It re-exports
265
+ the v3 `types` namespace and table API (from `@cipherstash/stack/eql/v3`), so a
266
+ single import provides everything needed to author and use a v3 schema.
267
+
268
+ Every method derives its types from the concrete `table` / `column` builder
269
+ arguments:
270
+
271
+ - `encrypt` / `encryptQuery` pin the plaintext to the column's domain type
272
+ (`text → string`, `timestamp → Date`, …).
273
+ - `encryptQuery` constrains `queryType` to the column's capabilities and rejects
274
+ storage-only columns at compile time.
275
+ - `encryptModel` / `bulkEncryptModels` validate schema-column fields against their
276
+ inferred plaintext type (passthrough fields are untouched) and return a precise
277
+ encrypted model.
278
+ - `decryptModel` / `bulkDecryptModels` return the precise plaintext model,
279
+ reconstructing `Date` values from the encrypt-config `cast_as`.
280
+
281
+ Because the typed methods bind to the concrete branded v3 classes, a hand-rolled
282
+ structural table/column is rejected — closing the soundness gap where a non-branded
283
+ table could be encrypted at runtime while typed as plaintext.
284
+
285
+ Runtime behaviour is unchanged: the encrypt/query paths return the same operations
286
+ as the base client; only the model-decrypt paths add a per-column `Date`
287
+ reconstruction step. The v2 client surface (`Encryption`) is untouched.
288
+
289
+ - b8a3d20: Add EQL v3 schema builders for supported generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per supported EQL v3 domain, e.g. `types.TextEq` / `types.IntegerOrd` / `types.Timestamp`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).
290
+
291
+ Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` (via the new `Plaintext` type), so v3 `date` / `timestamp` domains can be encrypted and queried with their natural JavaScript values.
292
+
293
+ - a0f3b2c: `@cipherstash/stack/wasm-inline` is now EQL v3 (#614).
294
+
295
+ The WASM entry (Deno / Bun / Cloudflare Workers / Supabase Edge) previously
296
+ created a client pinned to the FFI's EQL v2 wire format, so a v3 schema
297
+ (concrete `eql_v3_*` domains) failed every encrypt on the edge. It now targets
298
+ EQL v3 exclusively:
299
+
300
+ - The factory constructs the WASM client with `eqlVersion: 3`, so v3 schemas
301
+ encrypt/decrypt correctly on the edge.
302
+ - The entry re-exports the **v3** authoring surface (`types`, `encryptedTable`,
303
+ the column classes, `buildEncryptConfig`, and the inference helpers) — the
304
+ same API as `@cipherstash/stack/eql/v3` — so an Edge Function authors and runs
305
+ v3 from one import:
306
+
307
+ ```ts
308
+ import {
309
+ Encryption,
310
+ encryptedTable,
311
+ types,
312
+ } from "@cipherstash/stack/wasm-inline";
313
+
314
+ const patients = encryptedTable("patients", {
315
+ email: types.TextSearch("email"),
316
+ });
317
+ const client = await Encryption({ schemas: [patients], config });
318
+ ```
319
+
320
+ The v2 schema builders (`encryptedColumn` / `encryptedField` / the v2
321
+ `encryptedTable`) are no longer exported from this entry, and passing a v2 table
322
+ throws a clear error. The WASM path was never announced or documented for v2 and
323
+ had no known users; EQL v2 remains fully supported on the native
324
+ `@cipherstash/stack` entry.
325
+
326
+ - 5411a13: Add the `@cipherstash/stack/adapter-kit` subpath — a narrow support surface for
327
+ the first-party adapter packages (`@cipherstash/stack-supabase`,
328
+ `@cipherstash/stack-drizzle`) being split out of this package (#627). It
329
+ re-exports exactly the core internals the adapters consume (the logger,
330
+ `AuditConfig`, the v3 column model + `DATE_LIKE_CASTS`, the domain registry, the
331
+ match-index guard, and the model→composite helpers) so those imports resolve
332
+ across the package boundary without leaking six internal module paths. This is the
333
+ core↔adapter seam, not general-purpose public API.
334
+ - 99f8b0a: Fix encrypted `in`-list operands in the Supabase adapter, and widen the `is` /
335
+ `contains` type surfaces.
336
+
337
+ **`in()` on an encrypted column produced a request PostgREST rejects.** Every
338
+ encrypted operand is a serialized envelope, dense with `"` and `,`. postgrest-js
339
+ wraps a comma-bearing element as `"…"` but never escapes the quotes already
340
+ inside it, so `.in('email', […])` emitted
341
+
342
+ ```
343
+ in.("{"v":1,"c":"…"}",…)
344
+ ^ PostgREST ends the value here → PGRST100
345
+ ```
346
+
347
+ Encrypted lists are now emitted through `filter(col, 'in', …)` with each element
348
+ quoted and escaped, matching what the `.or()` path already did. This affects
349
+ **v2 as well as v3** — v2's `("a@b.com")` composite literal is itself
350
+ quote-bearing and was equally broken.
351
+
352
+ **`not(col, 'in', […])` encrypted the whole list as a single ciphertext**, so
353
+ the filter silently matched nothing, and emitted an unparenthesized
354
+ `not.in.a,b`. Each element is now encrypted separately and the operand is
355
+ rendered as `not.in.(…)`. Passing a PostgREST list literal (`'(a,b)'`) for an
356
+ encrypted column now throws instead of silently matching nothing — pass an
357
+ array.
358
+
359
+ **`filter(col, 'in', […])` encrypted the whole list as a single ciphertext.**
360
+ The raw `.filter()` path reached `in` with none of the element-splitting the
361
+ `in()`, `not(…, 'in', …)` and `.or()` paths perform, so the entire list operand
362
+ was encrypted as one equality term. The two wire formats then failed
363
+ differently, which is why this went unnoticed: **v2**'s `("json")` composite
364
+ literal is already parenthesized, so PostgREST parsed it as a one-element list
365
+ and answered `200 []` — a filter that silently matched nothing. **v3**'s bare
366
+ `{…}` envelope is not, so PostgREST rejected the request outright with
367
+ `PGRST100 (failed to parse filter)`.
368
+
369
+ Each element is now encrypted separately and the operand rendered as a quoted
370
+ PostgREST list literal. As on the `not` path, passing a list literal
371
+ (`'(a,b)'`) for an encrypted column now throws instead — pass an array.
372
+
373
+ Plaintext columns are unaffected, including the pre-existing quirk that
374
+ postgrest-js renders `.filter(col, 'in', [array])` as an unparenthesized
375
+ `in.a,b` that PostgREST rejects; pass a list literal there, or use `.in()`.
376
+
377
+ **`is(col, null)` is now allowed on every column**, including storage-only
378
+ encrypted ones (`types.Boolean`, `types.Integer`, …). `is` is never encrypted
379
+ and a NULL plaintext is stored as a SQL NULL, so `IS NULL` is not merely legal
380
+ there but the only predicate those columns support. `is(col, true)` remains a
381
+ compile error on encrypted columns.
382
+
383
+ **`contains()` accepts native operands on plaintext array and jsonb columns.** A
384
+ plaintext jsonb/array column falls through to PostgREST's native containment, so
385
+ `contains('tags', ['vip'])` and `contains('meta', { plan: 'pro' })` now
386
+ typecheck. A plaintext SCALAR column does not: `@>` is undefined on `text`, so
387
+ the operand type follows the column's own shape and a scalar rejects every
388
+ containment operand. Encrypted match columns still take a `string` token.
389
+ Relatedly, `.or([{ op: 'contains' }])` now emits PostgREST's `cs` operator for
390
+ plaintext columns too — previously only encrypted conditions were translated, so
391
+ a plaintext containment reached the wire as `.contains.` and failed to parse.
392
+
393
+ **Direct `contains()` / `not(col, 'contains', …)` now serialize their operand.**
394
+ postgrest-js builds an array operand as `cs.{a,b}` with no element quoting, so
395
+ `contains('tags', ['with,comma'])` reached Postgres as two elements; and its
396
+ `not()` stringifies the operand outright, emitting `not.contains.with,comma`
397
+ (no braces, and the wrong operator token) or `[object Object]` for a jsonb
398
+ operand. Both paths now build the containment literal the `.or()` path already
399
+ built, and emit the `cs` token.
400
+
401
+ **`.or()` no longer drops a condition after an unbalanced brace or paren.** A
402
+ scalar operand containing `{` left the parser's depth counter stranded above
403
+ zero, so no later comma separated a condition and everything behind it was
404
+ swallowed into that operand. With a plaintext column first, the group was then
405
+ forwarded verbatim — running the swallowed condition against a ciphertext column
406
+ with a plaintext operand. Braces are now quoted on emit (they are structural to
407
+ PostgREST inside `or=(…)`), and the parser falls back to quote-only splitting
408
+ when its depth tracking does not balance.
409
+
410
+ **`is(col, true)` is now rejected on every encrypted column, not just the
411
+ storage-only ones.** The boolean form was gated on the filterable keys, which
412
+ exclude storage-only columns but keep queryable encrypted ones — so
413
+ `is(emailTextSearchColumn, true)` compiled and emitted `IS TRUE` against a jsonb
414
+ ciphertext.
415
+
416
+ **In-list operands encrypt in one crossing per column.** The element-wise `in` /
417
+ `not.in` encoding above spent one ZeroKMS round-trip per element; terms are now
418
+ grouped by column and each group takes a single `bulkEncrypt` call, matching the
419
+ Drizzle v3 path. Falls back to per-term encryption for clients without
420
+ `bulkEncrypt`, and rejects a bulk response whose length does not match the list
421
+ rather than silently truncating the predicate.
422
+
423
+ - 9b65ae8: **`order()` now works on EQL v3 encrypted ordering columns in the Supabase
424
+ adapter.** It was rejected outright on every encrypted column.
425
+
426
+ A bare `ORDER BY col` on an EQL v3 domain really is wrong — the bundle declares
427
+ no btree operator class on any domain, so the sort falls through to jsonb's
428
+ default `jsonb_cmp` and compares the envelope's keys in storage order, starting
429
+ at the random ciphertext `c`. Measured over ten rows it returns
430
+ `r00,r04,r08,r01,…` where the plaintext order is `r00..r09`. No error, a stable
431
+ and plausible-looking meaningless order.
432
+
433
+ But the correct sort key is reachable without a function call. `eql_v3.ord_term`
434
+ returns the domain's `op` term, and OPE is order-preserving, so ordering by the
435
+ term reproduces the plaintext order. PostgREST cannot emit
436
+ `ORDER BY eql_v3.ord_term(col)`, but it can emit a jsonb path. The builder now
437
+ emits `order=col->op` for an encrypted ordering column, verified against a live
438
+ PostgREST for `integer_ord` and `text_search` in both directions.
439
+
440
+ The guard is now on the ordering FLAVOUR, not on encryption:
441
+
442
+ - **`ope` present → supported.** Every plain `*_ord` domain, plus `text_ord` and
443
+ `text_search`.
444
+ - **`ore` present → rejected.** The `ob` term is an array of ORE blocks whose
445
+ comparison needs the superuser-only operator class, which no jsonb path can
446
+ reach. (Such a column cannot hold data on managed Postgres anyway: its domain
447
+ CHECK raises `ore_domain_unavailable`.) ORE columns are now excluded from
448
+ `order()` at COMPILE time too, not only at runtime — `.order(oreColumn)` is a
449
+ type error, matching the rejection.
450
+ - **neither → rejected.** Storage-only, equality-only and match-only columns
451
+ carry no ordering term.
452
+
453
+ The path is `col->op` (jsonb), not `col->>op` (text). Neither avoids the
454
+ database collation — Postgres compares jsonb strings with `varstr_cmp` under the
455
+ default collation, exactly as it does text. What makes the ordering
456
+ collation-independent is the term's encoding: lowercase hex, fixed-width for
457
+ numeric and date domains, and per-character (16 hex chars each) for text, so
458
+ lexicographic order reproduces plaintext order including the prefix case
459
+ (`ada` < `adam`). `ope-term.integration.test.ts` pins that shape.
460
+
461
+ `V3OrderableKeys` widens to admit OPE-backed ordering columns (`*_ord`,
462
+ `text_ord`, `text_search`) while still excluding ORE (`*_ord_ore`) columns, so
463
+ `order()` typechecks exactly where it works. `is(col, true)` is unaffected — it
464
+ stays plaintext-only, and now has its own `V3PlaintextKeys` rather than
465
+ borrowing the orderable set.
466
+
467
+ ### Patch Changes
468
+
469
+ - cfd46ee: Source the EQL v3 install bundle from `@cipherstash/eql@3.0.0-alpha.3` instead of a hand-vendored 43k-line SQL fixture committed to the test tree. The package publishes its SQL and its TypeScript wire types from the same `eql-bindings` commit, so the bundle is now pinned to a released EQL version rather than tracked by convention.
470
+
471
+ Test-and-tooling only — `@cipherstash/eql` is a `devDependency` and no public API changes.
472
+
473
+ The staleness check in the v3 install helper now compares `eql_v3.version()` against the pinned release instead of probing for a hand-picked sentinel domain. The previous sentinel (`public.timestamp`) exists in both the old and new bundles, so it would have reported a stale install as current and left the suite silently running the wrong SQL.
474
+
475
+ - 63ca540: Re-vendor the EQL v3 SQL bundle and align the v3 DSL to it: encrypted type domains now live in the `public` schema (`public.text`, `public.integer`, …) rather than `eql_v3`, and the boolean domain is `public.boolean` (was `eql_v3.bool`). The `eql_v3` schema now holds only the operator-backing functions, and the index-term constructors (`hmac_256`, `ore_block_256`, `bloom_filter`) moved to `eql_v3_internal`. This keeps the SDK's emitted domain names byte-matched to the installed bundle so `CREATE TABLE`/cast resolution succeeds.
476
+ - f23f952: Remove the leftovers from the secrets removal (`1929c8fe`), which deleted
477
+ `packages/stack/src/secrets/` but left its export, build entry, skill, and docs
478
+ behind. Secrets tooling is not ready; nothing here was functional.
479
+
480
+ - **Drop the dead `@cipherstash/stack/secrets` subpath export.** It pointed at
481
+ `./dist/secrets/index.js`, which has no source and is not in the tarball, so
482
+ `import '@cipherstash/stack/secrets'` has been throwing `ERR_MODULE_NOT_FOUND`
483
+ for every consumer since the source was removed. Also drops the dangling
484
+ `src/secrets/index.ts` entry from `tsup.config.ts`. Removing an export that
485
+ cannot resolve breaks nothing.
486
+ - **Remove the `stash-secrets` agent skill** and its references in `AGENTS.md`
487
+ and the init setup-prompt skill index. It was never installed by `stash init`
488
+ (it is absent from `SKILL_MAP`), so no user project ever received it.
489
+ - **Remove the secrets documentation** from both published READMEs: the
490
+ `Secrets` class API and the `npx stash secrets` command reference in
491
+ `@cipherstash/stack`, and the `npx stash secrets` section in `stash`. The CLI
492
+ command does not exist — `stash secrets` returns `Unknown command`.
493
+
494
+ - fd33aad: Fix the Supabase adapter encrypting `is` and `null` filter operands.
495
+
496
+ `is` is a SQL predicate — PostgREST accepts only `null`/`true`/`false` after it
497
+ — and a `null` operand is SQL NULL, never a value to search for. Only the direct
498
+ `.is()` filter skipped encryption; `not()`, `or()`, `match()`, raw `filter()`,
499
+ and the `in()` element list all encrypted whatever they were handed. So
500
+ `or('age.is.null')` emitted `age.is."("null")"` and `eq('email', null)` emitted
501
+ `email=("null")` — operands PostgREST rejects. A null plaintext is stored as a
502
+ NULL column rather than ciphertext, so it is found with an unencrypted
503
+ `IS NULL`; encrypting the operand could never match.
504
+
505
+ A single `isEncryptableTerm(operator, value)` predicate now guards every term
506
+ collector. Affects both `encryptedSupabase` (v2) and `encryptedSupabaseV3`. On
507
+ v3 this additionally removes a spurious `does not support equality queries`
508
+ error, which `is` raised because it maps to the `equality` query type and so hit
509
+ the column-capability guard — `or('active.is.null')` on a storage-only column
510
+ threw rather than querying.
511
+
512
+ Relatedly, an `or()` string is now rebuilt whenever a condition _references_ an
513
+ encrypted column, not only when one of its values was encrypted. An `is` on an
514
+ encrypted column encrypts nothing, and the old condition sent it down the
515
+ verbatim path, forwarding the caller's JS property name to a database that only
516
+ knows the column's DB name.
517
+
518
+ - 8cd485d: Fix the Supabase adapter's `.or()` string parser mis-splitting conditions, and pin `contains()` on a mixed union column key to the encrypted operand.
519
+
520
+ An `.or()` string is only rebuilt from its parse when it references an encrypted column — otherwise the caller's string is forwarded verbatim — so each of these corrupts precisely the mixed encrypted/plaintext case.
521
+
522
+ **Quotes were tracked only at brace depth 0.** A `}` inside a quoted array element or jsonb string value closed the literal early, and the next `"` re-opened quoting, so the following top-level comma never split: `.or('tags.cs.{"a}b"},email.eq.secret')` parsed as a single condition and silently absorbed `email.eq.secret` into the operand. Quotes are now opaque at every depth.
523
+
524
+ **A stray `}` or `)` drove the depth counter negative**, after which no comma split again. `}` and `)` are not PostgREST reserved characters, so `a}b` is a valid unquoted operand and `.or('nickname.eq.a}b,id.eq.1')` dropped `id.eq.1`. Depth now floors at zero.
525
+
526
+ **`in`-list elements were split on every comma, ignoring quotes.** `.or('email.in.("a,b",c)')` parsed as three elements with the quotes still embedded; on an encrypted column each fragment was encrypted as its own term, so the intended element never matched. Elements are now split on top-level commas and unquoted, the inverse of what the rebuild emits.
527
+
528
+ **A parenthesized operand was read as a list for every operator.** Only `in` and the range operators (`ov`, `sl`, `sr`, `nxr`, `nxl`, `adj`) take a paren-delimited operand; elsewhere `(` is an ordinary character. `email.eq.(foo)` parsed as `['foo']` and encrypted a JS array rather than the string, matching nothing.
529
+
530
+ **A string operand spelling `null`, `true` or `false` is now quoted.** PostgREST reads a bare `null` as SQL NULL, so `.or([{ column: 'name', op: 'eq', value: 'null' }])` emitted `name.eq.null` and compared against NULL instead of the three-character string.
531
+
532
+ **`contains(col, …)` where `col` is a union spanning an encrypted and a plaintext column** accepted an array or object operand. The union is now only as permissive as its strictest member: any declared encrypted column in the union pins the operand to `string`. A literal column argument was never affected.
533
+
3
534
  ## 0.19.0
4
535
 
5
536
  ### Minor Changes