Skip to content

Commit 508f1d5

Browse files
authored
feat(stack)!: align wasm-inline to the Result contract, and add bulkEncrypt/bulkDecrypt (#741)
* feat(stack): bulkEncrypt/bulkDecrypt on the wasm-inline entry (#737) The WASM entry exposed no bulk operations at all — only encrypt/decrypt — so rendering an N-row list on Deno, Cloudflare Workers, or Supabase Edge Functions cost N sequential ZeroKMS round trips. Combined with the ~6.6s cold start that made list endpoints impractical on the edge, and it left the entry with no access to the bulk path AGENTS.md names as the way to exercise ZeroKMS bulk speed. Surfaced as M1 in the rc.3 skilltester run. The FFI already had the primitives — `encryptBulk`, `decryptBulkFallible` are exported from protect-ffi's WASM build (identical in 0.29 and 0.30). This wires them up; no new capability was needed underneath. Signature deliberately differs from the native entry's bulk methods. It follows `encryptQueryBulk`, the bulk primitive already on this client: an index-aligned array with per-item table/column routing, throwing rather than a `{ data } | { failure }` envelope. No `{ id, plaintext }` payload envelopes either — protect-ffi's `EncryptPayload` has no `id` field, so the native one is dropped at the boundary and buys nothing once positions are stable. Consistency within this surface beats parity with a different entry point, since a WASM caller is reading this module's API. Per-item routing is what makes the saving real: one call covers several columns across many rows, where a single-column batch would still cost one round trip per column. Two failure modes are handled rather than assumed away: - `bulkDecrypt` builds on the FALLIBLE primitive, so when items fail it throws once naming every failing index and reason, instead of surfacing the first and discarding work the caller already paid for. - Both methods assert the FFI returned as many results as were sent. Matching is positional (no correlation id), so a short response would silently leave trailing slots null — indistinguishable from "this row had no value". That is silent wrong data, so it throws. Model helpers stay Node-only: this entry has no single-model operation to build them on, so adding bulk-model alone would be incoherent. Noted in the class docblock as a separate port. 14 new unit tests: one-FFI-call-per-batch, payload shape, mixed tables/columns with declared-vs-property column names, position stability across null/undefined, all-null and empty short-circuits, per-index failure reporting, and both length-mismatch guards. 823 tests pass; typecheck and build clean; 0 biome errors. Skill updated — `stash-encryption` documented only the native bulk shape, which would not compile on the edge. * feat(stack)!: align the wasm-inline entry to the Result contract The WASM entry threw on every fallible method — `encrypt`, `decrypt`, `encryptQuery`, `encryptQueryBulk` — while every other surface in the repo returns `{ data } | { failure }`. AGENTS.md states that as a contract, not a preference: "Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `EncryptionErrorTypes`." This was drift, not a design decision. Nothing about WASM prevents it: `@byteslice/result` is ALREADY bundled into dist/wasm-inline.js (tsup.config.ts `noExternal`). The cost was that edge code had to be written in a different shape from every other surface, with failures that were easy to miss. Fixed now because it is breaking and 1.0.0 has not shipped — `@cipherstash/stack@latest` is still 0.19.0, so this surface has only ever been published under the `rc` tag. After GA it would have waited for a major. The earlier commit on this branch made the problem worse by adding two more throwing methods; this corrects both. All six fallible methods now return `Result<T, EncryptionError>`, with `failure.type` distinguishing encrypt-side (`EncryptionError`) from decrypt-side (`DecryptionError`) and `failure.code` carrying the FFI code. `isEncrypted` stays a bare boolean — a pure predicate with nothing to fail at, as on the native entry. Consumers updated: the supabase-worker example (now returns a 500 with the failure message rather than throwing past the handler), and the WASM integration adapter via an `unwrap` helper that aborts the harness loudly. A new `wasm-inline-result-contract.test.ts` pins the contract on both paths for all six methods so it cannot drift back. One thing found while testing: `withResult`'s `ensureError` REPLACES any non-Error throw with `new Error('Something went wrong')` before the error mapper runs, discarding the original value (@byteslice/result@0.2.0). That is library-wide — the native entry has it too — so it is pinned as a test rather than worked around, and will surface if the #532 bump to 0.5.0 changes it. 831 tests pass. Zero new tsc errors (verified by diffing the error set against main — the pre-existing 107 are unchanged). 0 biome errors. * fix(stack): preserve non-Error rejection detail on the wasm-inline entry `withResult`'s default `ensureError` REPLACES any non-Error throw with `new Error('Something went wrong')`, discarding the original value (@byteslice/result@0.2.0, dist/result.js:27). But that is only the FALLBACK — the library prefers an `onException` hook: const error = hooks?.onException?.(ex) ?? ensureError(ex) Nothing in this repo passes it. On the native entry that rarely bites, because the FFI throws real `ProtectError` instances. On WASM it is a live hazard: wasm-bindgen rejects with the raw `JsValue` the Rust side produced (`throw takeFromExternrefTable0(...)` in the generated glue), and the WASM build exports no `ProtectError` class at all — so a genuine FFI failure can arrive as a bare string or object. That made the Result conversion in the previous commit a partial REGRESSION: the old throwing behaviour at least propagated the raw value to the caller, whereas `withResult` was silently swallowing it. Fixed by supplying `onException`. The six call sites now go through one `wasmResult()` seam that binds both the failure shape and the hook, so a method added later cannot omit either — an omission would not fail a build, it would just quietly degrade failure messages, which is the whole bug. `toError` preserves strings as-is, JSON-serializes objects (so `{code, detail}` survives rather than becoming "[object Object]"), and falls back to `String()` for cycles and other unserializable values. +2 tests covering string and object preservation, plus the cyclic fallback. 833 pass; 0 biome errors; zero new tsc errors (diffed against main's error set, not counted). * fix(stack): address review — native FFI leak, Result plumbing, batch bugs Review found 15 issues. This commit takes the source and test ones. CRITICAL (1/15) — the entry pulled in the NATIVE protect-ffi. `@/encryption/helpers/error-code` value-imports `ProtectError` for an `instanceof` narrow, and protect-ffi is not in tsup `noExternal`, so `dist/wasm-inline.js` carried a bare `@cipherstash/protect-ffi` import — the NAPI entry, in the one bundle that exists to avoid it. On Workers / Edge the non-`node` condition resolves it to a module exporting no `ProtectError`; under Deno it resolves to the NAPI loader. I had previously dismissed this as pre-existing. That was wrong: I ran `git stash -u` on a clean tree, so it stashed nothing and I rebuilt my own branch and compared it against itself. Re-verified properly by swapping in main's `wasm-inline.ts` and rebuilding: main emits only the `/wasm-inline` specifier, mine emitted both. Replaced with a structural `readErrorCode` — which is the only thing that could ever work here anyway, since the WASM build ships no error class for `instanceof` to match. GH #744 is filed on my faulty premise and needs correcting. New `wasm-inline-bundle-isolation.test.ts` asserts the built bundle's external imports, so this class of regression fails CI. Verified it catches the exact regression by reintroducing it (2 of 3 assertions fail), rather than assuming. 3/15 — `failure.code` could never be populated: `withResult` runs `onException` first, so the mapper only ever saw a fresh Error. `toError` now carries a structural `code` onto the synthesized Error, and `bulkDecrypt` puts each item's FFI code in its per-index message (a batch has no single code, but dropping it lost the only machine-readable part). 9/15 — `toError`'s `String(ex)` fallback could itself throw on a null-prototype object, escaping the Result contract entirely and rejecting the call. Guarded with `safeString`. 10/15 — sparse inputs. `items.map(() => null)` SKIPS holes, so a hole came back `undefined` rather than the documented `null` while lengths still matched, which the length guard cannot see. Now `Array.from`. 15/15 — the positional-batch scaffolding was hand-rolled three times, so a fourth batch method could omit `assertBatchLength` with no build failure. One `runBatch` helper owns compaction, short-circuit, length assert and scatter; the sparse fix lives there once. 5/15, 6/15, 7/15 — docs and surface: removed a duplicate stale `@returns`, migrated the module-header and all five `encryptQuery` examples off the pre-Result contract (they interpolated the envelope straight into SQL), corrected the pending `.changeset/wasm-encrypt-query.md` sentence that still said errors throw, and exported `EncryptionError` / `EncryptionErrorTypes` so an edge consumer can discriminate `failure.type` from the same import. `WasmResult<T>` is now declared locally rather than re-exporting `@byteslice/result`'s. Re-exporting put that specifier in the emitted `.d.ts`, which Deno cannot resolve — the same reason `WasmPlaintext` re-declares protect-ffi's `JsPlaintext`. 14/15 — test gaps: falsy-but-live values (`0` / `''` / `false`) per batch method, the sparse-hole case, `encryptQueryBulk`'s previously untested length guard, and the two stale "throws" titles. 840 tests pass. Zero new tsc errors, diffed against main's error set. 0 biome errors. Bundle verified free of the native FFI. * fix(stack): review round 2 — e2e, example, skill, and adapter bulk usage 2/15 — the Deno e2e suites still consumed bare values, so with live credentials `isEncrypted(encrypted)` received a Result, `decrypt` was handed the envelope as ciphertext, and every `assertV3Term` saw `term.v === undefined`. Secret-gated, so it would have landed silently. Both suites now unwrap at each boundary. `roundtrip` also gains the first live coverage of `bulkEncrypt`/`bulkDecrypt` anywhere in the repo — including the index-aligned null hole — which the unit tests' "live coverage runs in the Deno e2e" claim previously had no backing for. (`deno check` reports pre-existing column-brand errors on these files; the task runs `--no-check` deliberately for that reason, documented in `e2e/wasm/deno.json`. Confirmed no NEW error kinds were introduced.) 4/15 — the supabase-worker example could not run against any published version: it pinned `@^0.18.0` (bare-value entry, so `encryptResult.data` was undefined and `decrypt` threw), and bumping the pin broke it earlier still because it authored a v2 schema the v3-only entry rejects. Pinned to `^1.0.0-rc.3` and migrated to `types.TextEq`. README's surface claim updated with it. 8/15 — the skill's WASM bulk section sat in a file where the running `client` is the NATIVE one, so an agent would apply the per-item shape to `bulkEncrypt(plaintexts, { table, column })` and fail in a customer repo. It now constructs the WASM client explicitly, with a callout that it is a different client, and `@cipherstash/stack/wasm-inline` is listed in the subpath table. 13/15 — the WASM integration adapter encrypted one field per request inside `Promise.all`: concurrency hides latency, not request count, so a 100-row × 5-field family was 500 ZeroKMS requests. Now one `bulkEncrypt` per row, which also makes the harness live coverage for the path. 840 tests pass; zero new tsc errors; 0 biome errors.
1 parent cddb182 commit 508f1d5

15 files changed

Lines changed: 1547 additions & 151 deletions

File tree

.changeset/wasm-encrypt-query.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ free-text match, ORE range, and JSON containment/selector — with the same
1414
index-type resolution as the native client (explicit `queryType`, or
1515
inference from the column's configured indexes). Cast the term to the
1616
column's `eql_v3.query_<domain>` type in SQL to reach the indexed operators.
17-
Errors throw, consistent with the WASM surface's `encrypt`/`decrypt`; the
18-
bulk form is position-stable (`null` values pass through as `null`).
17+
Both return `{ data } | { failure }`, consistent with the WASM surface's
18+
`encrypt`/`decrypt`; the bulk form is position-stable (`null` values pass
19+
through as `null`).

.changeset/wasm-inline-bulk-ops.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@cipherstash/stack': minor
3+
'stash': patch
4+
---
5+
6+
**Breaking (`@cipherstash/stack/wasm-inline`):** every fallible method now returns a `Result``{ data } | { failure }` — instead of throwing. And `bulkEncrypt` / `bulkDecrypt` are added, so a list of encrypted rows costs **one** ZeroKMS round trip instead of one per row.
7+
8+
### Result alignment
9+
10+
`encrypt`, `decrypt`, `encryptQuery` and `encryptQueryBulk` previously threw on failure, and returned bare values on success. They now return `{ data } | { failure }`, with `failure.type` drawn from `EncryptionErrorTypes` (`EncryptionError` for encrypt-side operations, `DecryptionError` for decrypt-side) and `failure.code` carrying the FFI error code where there is one.
11+
12+
```typescript
13+
// before
14+
const encrypted = await client.encrypt(plaintext, { table: users, column: users.email })
15+
16+
// after
17+
const result = await client.encrypt(plaintext, { table: users, column: users.email })
18+
if (result.failure) throw new Error(result.failure.message)
19+
const encrypted = result.data
20+
```
21+
22+
This is the contract the native entry has always honoured, and the one `AGENTS.md` states outright: *"Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `EncryptionErrorTypes`."* The WASM entry never followed it. That was drift rather than a design decision — nothing about WASM prevents it (`@byteslice/result` is already bundled into `dist/wasm-inline.js`), and it meant edge code had to be written in a different shape from every other surface, with failures that were easy to miss.
23+
24+
Fixed now because it is a breaking change and 1.0.0 has not shipped: `@cipherstash/stack@latest` is still `0.19.0`, so this surface has only ever been published under the `rc` tag. After GA it would have had to wait for a major.
25+
26+
`isEncrypted` is unchanged — a pure predicate with nothing to fail at, exactly as on the native entry.
27+
28+
### Bulk operations
29+
30+
```typescript
31+
// Write: several columns across many rows, one round trip
32+
const encrypted = await client.bulkEncrypt([
33+
{ plaintext: "alice@example.com", table: users, column: users.email },
34+
{ plaintext: "hello", table: users, column: users.bio },
35+
])
36+
37+
// Read: a whole page in one call
38+
const emails = await client.bulkDecrypt(rows.map((r) => r.email))
39+
```
40+
41+
The WASM entry previously exposed no bulk operations at all, so rendering an N-row list on Deno, Cloudflare Workers, or Supabase Edge Functions meant N sequential ZeroKMS calls. Combined with the WASM cold start, that made list endpoints impractical on the edge.
42+
43+
Both are index-aligned with their input, and `null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS (an all-null batch makes no call at all). Because each entry names its own table and column, a single `bulkEncrypt` can cover several columns across many rows — which is what makes the saving real, since a single-column batch would still cost one round trip per column.
44+
45+
`bulkDecrypt` builds on the fallible FFI primitive, so when items fail the `failure.message` names **every** failing index with its reason, rather than surfacing the first and discarding the rest.
46+
47+
The model helpers (`encryptModel` / `decryptModel` and their bulk forms) remain Node-only: the WASM entry has no single-model operation to build them on, so those need their own port.

e2e/wasm/query-types.test.ts

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ const catalog = encryptedTable('wasm_query_matrix', {
7272
})
7373

7474
/** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */
75+
/**
76+
* Unwrap a `{ data } | { failure }` Result (#741 — every fallible method on
77+
* this entry returns one). Asserting on the envelope instead of its payload
78+
* silently passes: `term.v` is `undefined` on a Result, so a shape check reads
79+
* as "not v3" rather than "you forgot to unwrap".
80+
*/
81+
function unwrap<T>(
82+
result:
83+
| { data: T; failure?: never }
84+
| { data?: never; failure: { message: string } },
85+
label: string,
86+
): T {
87+
assertEquals(
88+
result.failure,
89+
undefined,
90+
`${label}: ${result.failure?.message}`,
91+
)
92+
return result.data as T
93+
}
94+
7595
function assertV3Term(term: unknown, label: string) {
7696
assertExists(term, `${label}: encryptQuery returned null`)
7797
const obj = term as Record<string, unknown>
@@ -119,42 +139,54 @@ Deno.test({
119139

120140
// equality → unique index
121141
assertV3Term(
122-
await client.encryptQuery('alice@example.com', {
123-
table: catalog,
124-
column: catalog.email,
125-
queryType: 'equality',
126-
}),
142+
unwrap(
143+
await client.encryptQuery('alice@example.com', {
144+
table: catalog,
145+
column: catalog.email,
146+
queryType: 'equality',
147+
}),
148+
'equality',
149+
),
127150
'equality',
128151
)
129152

130153
// freeTextSearch → match index
131154
assertV3Term(
132-
await client.encryptQuery('needle phrase', {
133-
table: catalog,
134-
column: catalog.bio,
135-
queryType: 'freeTextSearch',
136-
}),
155+
unwrap(
156+
await client.encryptQuery('needle phrase', {
157+
table: catalog,
158+
column: catalog.bio,
159+
queryType: 'freeTextSearch',
160+
}),
161+
'freeTextSearch',
162+
),
137163
'freeTextSearch',
138164
)
139165

140166
// orderAndRange → ore index (numeric)
141167
assertV3Term(
142-
await client.encryptQuery(42, {
143-
table: catalog,
144-
column: catalog.age,
145-
queryType: 'orderAndRange',
146-
}),
168+
unwrap(
169+
await client.encryptQuery(42, {
170+
table: catalog,
171+
column: catalog.age,
172+
queryType: 'orderAndRange',
173+
}),
174+
'orderAndRange',
175+
),
147176
'orderAndRange',
148177
)
149178

150179
// searchableJson, string value → ste_vec_selector (JSONPath). By
151180
// contract the selector "term" is a BARE selector-hash string — no
152181
// envelope — bound as the text argument of `->` / `->>`.
153-
const selector = await client.encryptQuery('$.theme', {
154-
table: catalog,
155-
column: catalog.prefs,
156-
queryType: 'searchableJson',
157-
})
182+
const selector = unwrap(
183+
await client.encryptQuery('$.theme', {
184+
table: catalog,
185+
column: catalog.prefs,
186+
queryType: 'searchableJson',
187+
}),
188+
'selector',
189+
)
158190
assertExists(selector, 'selector: encryptQuery returned null')
159191
assertEquals(
160192
typeof selector,
@@ -171,30 +203,36 @@ Deno.test({
171203
// strict {sv: [...]}, no version envelope — per the eql_v3.query_jsonb
172204
// wire contract)
173205
assertContainmentNeedle(
174-
await client.encryptQuery(
175-
{ theme: 'dark' },
176-
{
177-
table: catalog,
178-
column: catalog.prefs,
179-
queryType: 'searchableJson',
180-
},
206+
unwrap(
207+
await client.encryptQuery(
208+
{ theme: 'dark' },
209+
{
210+
table: catalog,
211+
column: catalog.prefs,
212+
queryType: 'searchableJson',
213+
},
214+
),
215+
'searchableJson/containment',
181216
),
182217
'searchableJson/containment',
183218
)
184219

185220
// Omitted queryType → inference from the column's indexes (TextEq has
186221
// exactly one: unique), mirroring the native client.
187222
assertV3Term(
188-
await client.encryptQuery('bob@example.com', {
189-
table: catalog,
190-
column: catalog.email,
191-
}),
223+
unwrap(
224+
await client.encryptQuery('bob@example.com', {
225+
table: catalog,
226+
column: catalog.email,
227+
}),
228+
'inference',
229+
),
192230
'inference',
193231
)
194232

195233
// Bulk: one round trip across mixed query types, position-stable with
196234
// nulls passing through.
197-
const bulk = await client.encryptQueryBulk([
235+
const bulkResult = await client.encryptQueryBulk([
198236
{
199237
value: 'alice@example.com',
200238
table: catalog,
@@ -225,6 +263,7 @@ Deno.test({
225263
queryType: 'searchableJson',
226264
},
227265
])
266+
const bulk = unwrap(bulkResult, 'bulk')
228267
assertEquals(bulk.length, 5)
229268
assertV3Term(bulk[0], 'bulk/equality')
230269
assertEquals(bulk[1], null, 'bulk: null value must yield null')

e2e/wasm/roundtrip.test.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,19 @@ Deno.test({
102102

103103
const plaintext = `wasm-v3-smoke-${crypto.randomUUID()}@example.com`
104104

105-
const encrypted = await client.encrypt(plaintext, {
105+
// Every fallible method returns `{ data } | { failure }` (#741). Unwrap
106+
// at each boundary — passing an envelope on would fail in ways that look
107+
// like an encryption bug rather than a plumbing one.
108+
const encryptResult = await client.encrypt(plaintext, {
106109
column: users.email,
107110
table: users,
108111
})
112+
assertEquals(
113+
encryptResult.failure,
114+
undefined,
115+
`encrypt() failed: ${encryptResult.failure?.message}`,
116+
)
117+
const encrypted = encryptResult.data
109118

110119
assertEquals(
111120
isEncrypted(encrypted),
@@ -138,17 +147,28 @@ Deno.test({
138147
}
139148
assertEquals(String(wire.v), '3', 'wire envelope is not v3')
140149

141-
const decrypted = await client.decrypt(encrypted)
142-
assertEquals(decrypted, plaintext, 'round-trip plaintext mismatch')
150+
const decryptResult = await client.decrypt(encrypted)
151+
assertEquals(
152+
decryptResult.failure,
153+
undefined,
154+
`decrypt() failed: ${decryptResult.failure?.message}`,
155+
)
156+
assertEquals(decryptResult.data, plaintext, 'round-trip plaintext mismatch')
143157

144158
// 5. (#662) Searchable encryption is reachable on the edge: mint a v3
145159
// QUERY TERM for the column's free-text index. Terms are
146160
// ciphertext-free needles — assert the wire shape, not decryption.
147-
const term = (await client.encryptQuery(plaintext, {
161+
const termResult = await client.encryptQuery(plaintext, {
148162
column: users.email,
149163
table: users,
150164
queryType: 'freeTextSearch',
151-
})) as Record<string, unknown> | null
165+
})
166+
assertEquals(
167+
termResult.failure,
168+
undefined,
169+
`encryptQuery() failed: ${termResult.failure?.message}`,
170+
)
171+
const term = termResult.data as Record<string, unknown> | null
152172
assertExists(term, 'encryptQuery() returned null for live plaintext')
153173
assertEquals(term.v, 3, 'query term is not EQL v3')
154174
assertEquals(
@@ -158,12 +178,48 @@ Deno.test({
158178
)
159179

160180
// Bulk form is position-stable, nulls pass through.
161-
const bulk = await client.encryptQueryBulk([
181+
const bulkResult = await client.encryptQueryBulk([
162182
{ value: plaintext, column: users.email, table: users },
163183
{ value: null, column: users.email, table: users },
164184
])
185+
assertEquals(
186+
bulkResult.failure,
187+
undefined,
188+
`encryptQueryBulk() failed: ${bulkResult.failure?.message}`,
189+
)
190+
const bulk = bulkResult.data
165191
assertEquals(bulk.length, 2)
166192
assertExists(bulk[0])
167193
assertEquals(bulk[1], null)
194+
195+
// 6. (#741) The value-level bulk ops — the whole reason a list read on the
196+
// edge is one ZeroKMS round trip instead of N. Nothing else in the repo
197+
// exercises these against real ZeroKMS.
198+
const second = `wasm-v3-bulk-${crypto.randomUUID()}@example.com`
199+
const bulkEncrypted = await client.bulkEncrypt([
200+
{ plaintext, column: users.email, table: users },
201+
{ plaintext: null, column: users.email, table: users },
202+
{ plaintext: second, column: users.email, table: users },
203+
])
204+
assertEquals(
205+
bulkEncrypted.failure,
206+
undefined,
207+
`bulkEncrypt() failed: ${bulkEncrypted.failure?.message}`,
208+
)
209+
const payloads = bulkEncrypted.data
210+
assertEquals(payloads.length, 3, 'bulkEncrypt is not index-aligned')
211+
assertEquals(payloads[1], null, 'null plaintext did not yield null')
212+
assertExists(payloads[0])
213+
assertExists(payloads[2])
214+
assertEquals(isEncrypted(payloads[0]), true, 'bulkEncrypt[0] not a payload')
215+
216+
const bulkDecrypted = await client.bulkDecrypt(payloads)
217+
assertEquals(
218+
bulkDecrypted.failure,
219+
undefined,
220+
`bulkDecrypt() failed: ${bulkDecrypted.failure?.message}`,
221+
)
222+
// Round-trips at the ORIGINAL indices, with the null hole preserved.
223+
assertEquals(bulkDecrypted.data, [plaintext, null, second])
168224
},
169225
})

examples/supabase-worker/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ There are none. The `@cipherstash/stack/wasm-inline` subpath embeds the protect-
5959
## What this verifies
6060

6161
- Protect's WASM build works inside Supabase Edge Functions.
62-
- The full `@cipherstash/stack/wasm-inline` developer surface (`Encryption`, `encryptedTable`, `encryptedColumn`, …) is usable from an Edge Function with no native dependencies.
62+
- The full `@cipherstash/stack/wasm-inline` developer surface (`Encryption`, `encryptedTable`, the `types.*` EQL v3 domains, …) is usable from an Edge Function with no native dependencies.
6363
- A CipherStash service-to-service `AccessKeyStrategy` is the right credential shape for serverless / edge environments.
6464

6565
Automated coverage of the same code path lives at `e2e/wasm/roundtrip.test.ts` and runs in CI on every PR — this example is the runnable runbook version.

examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@
1515

1616
import {
1717
Encryption,
18-
encryptedColumn,
1918
encryptedTable,
2019
isEncrypted,
21-
} from 'npm:@cipherstash/stack@^0.18.0/wasm-inline'
20+
types,
21+
} from 'npm:@cipherstash/stack@^1.0.0-rc.3/wasm-inline'
2222

23+
// EQL v3: the WASM entry exports the v3 authoring surface only (`types.*`),
24+
// not the v2 chainable builders — `encryptedColumn('email').equality()` is
25+
// rejected by the factory outright. `TextEq` is the v3 equality-capable text
26+
// domain, i.e. the direct replacement.
2327
const users = encryptedTable('users', {
24-
email: encryptedColumn('email').equality(),
28+
email: types.TextEq('email'),
2529
})
2630

2731
Deno.serve(async (_req: Request) => {
@@ -61,11 +65,28 @@ Deno.serve(async (_req: Request) => {
6165
})
6266

6367
const plaintext = 'alice@example.com'
64-
const encrypted = await client.encrypt(plaintext, {
68+
// Every fallible method returns `{ data } | { failure }` — the same
69+
// contract as the native entry (see AGENTS.md).
70+
const encryptResult = await client.encrypt(plaintext, {
6571
column: users.email,
6672
table: users,
6773
})
68-
const decrypted = await client.decrypt(encrypted)
74+
if (encryptResult.failure) {
75+
return Response.json(
76+
{ ok: false, error: encryptResult.failure.message },
77+
{ status: 500 },
78+
)
79+
}
80+
const encrypted = encryptResult.data
81+
82+
const decryptResult = await client.decrypt(encrypted)
83+
if (decryptResult.failure) {
84+
return Response.json(
85+
{ ok: false, error: decryptResult.failure.message },
86+
{ status: 500 },
87+
)
88+
}
89+
const decrypted = decryptResult.data
6990

7091
return Response.json(
7192
{

0 commit comments

Comments
 (0)