Skip to content

Commit bbd5b18

Browse files
committed
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.
1 parent ffadd95 commit bbd5b18

9 files changed

Lines changed: 560 additions & 216 deletions

File tree

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,29 @@
33
'stash': patch
44
---
55

6-
Add `bulkEncrypt` / `bulkDecrypt` to `@cipherstash/stack/wasm-inline`, so a list of encrypted rows costs **one** ZeroKMS round trip instead of one per row.
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.
77

8-
The WASM entry previously exposed no bulk operations at all — only `encrypt` / `decrypt` — 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, and it left the entry with no access to the bulk path the docs recommend for throughput.
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
929

1030
```typescript
1131
// Write: several columns across many rows, one round trip
@@ -18,10 +38,10 @@ const encrypted = await client.bulkEncrypt([
1838
const emails = await client.bulkDecrypt(rows.map((r) => r.email))
1939
```
2040

21-
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.
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.
2242

23-
**The signature deliberately differs from the native entry's** `bulkEncrypt` / `bulkDecrypt`. It follows `encryptQueryBulk`, the bulk primitive already on this client: a plain index-aligned array with per-item routing, and errors that throw rather than a `{ data } | { failure }` envelope. There are no `{ id, plaintext }` payload envelopes — protect-ffi's `EncryptPayload` has no `id` field, so the native one is dropped at the FFI boundary and buys nothing when positions are already stable.
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.
2444

25-
`bulkDecrypt` builds on the fallible FFI primitive, so when items fail it throws once and names **every** failing index with its reason, rather than surfacing the first and discarding the rest.
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.
2646

2747
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.

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,28 @@ Deno.serve(async (_req: Request) => {
6161
})
6262

6363
const plaintext = 'alice@example.com'
64-
const encrypted = await client.encrypt(plaintext, {
64+
// Every fallible method returns `{ data } | { failure }` — the same
65+
// contract as the native entry (see AGENTS.md).
66+
const encryptResult = await client.encrypt(plaintext, {
6567
column: users.email,
6668
table: users,
6769
})
68-
const decrypted = await client.decrypt(encrypted)
70+
if (encryptResult.failure) {
71+
return Response.json(
72+
{ ok: false, error: encryptResult.failure.message },
73+
{ status: 500 },
74+
)
75+
}
76+
const encrypted = encryptResult.data
77+
78+
const decryptResult = await client.decrypt(encrypted)
79+
if (decryptResult.failure) {
80+
return Response.json(
81+
{ ok: false, error: decryptResult.failure.message },
82+
{ status: 500 },
83+
)
84+
}
85+
const decrypted = decryptResult.data
6986

7087
return Response.json(
7188
{
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Result } from '@byteslice/result'
2+
import type { EncryptionError } from '../../src/errors'
3+
4+
/**
5+
* Narrow a `Result` to its success arm in tests.
6+
*
7+
* `Result<S, F>` is `Success<S> | Failure<F>`, and `Failure` has NO `data`
8+
* property — so `result.data` doesn't type-check until the union is narrowed.
9+
* Every assertion on a successful payload would otherwise need its own
10+
* `if (r.failure) throw` preamble.
11+
*
12+
* `F` is pinned to `EncryptionError` rather than left generic: the library
13+
* constrains it to `FailureCase | Error`, and a bare type parameter neither
14+
* satisfies that constraint nor lets TypeScript discriminate the union.
15+
* Every caller here is an encryption operation anyway.
16+
*
17+
* Throws (rather than using `expect`) so it stays usable outside an assertion
18+
* context and reports the failure's own message, which is far more useful than
19+
* a bare "expected data to be defined".
20+
*/
21+
export function expectData<S>(result: Result<S, EncryptionError>): S {
22+
if (result.failure) {
23+
throw new Error(
24+
`expected { data } but got { failure } (${result.failure.type}): ${result.failure.message}`,
25+
)
26+
}
27+
return result.data
28+
}

packages/stack/__tests__/wasm-inline-bulk.test.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({
3737

3838
import { encryptedTable, types } from '../src/eql/v3'
3939
import { Encryption } from '../src/wasm-inline'
40+
import { expectData } from './helpers/expect-result'
4041

4142
const users = encryptedTable('users', {
4243
email: types.TextEq('email'),
@@ -128,12 +129,13 @@ describe('WasmEncryptionClient.bulkEncrypt', () => {
128129
{ plaintext: 'c@d.com', table: users, column: users.email },
129130
])
130131

131-
expect(out).toHaveLength(4)
132-
expect(out[0]).toBeNull()
133-
expect(out[2]).toBeNull()
132+
const values = expectData(out)
133+
expect(values).toHaveLength(4)
134+
expect(values[0]).toBeNull()
135+
expect(values[2]).toBeNull()
134136
// Live values keep their ORIGINAL indices, not their compacted ones.
135-
expect(out[1]).toEqual({ v: 3, i: {}, c: 'ct-0' })
136-
expect(out[3]).toEqual({ v: 3, i: {}, c: 'ct-1' })
137+
expect(values[1]).toEqual({ v: 3, i: {}, c: 'ct-0' })
138+
expect(values[3]).toEqual({ v: 3, i: {}, c: 'ct-1' })
137139

138140
// Nulls never reach ZeroKMS.
139141
const [, opts] = ffi.encryptBulk.mock.calls[0]
@@ -147,13 +149,13 @@ describe('WasmEncryptionClient.bulkEncrypt', () => {
147149
{ plaintext: undefined, table: users, column: users.email },
148150
])
149151

150-
expect(out).toEqual([null, null])
152+
expect(out).toEqual({ data: [null, null] })
151153
expect(ffi.encryptBulk).not.toHaveBeenCalled()
152154
})
153155

154156
it('returns an empty array for an empty batch, with no FFI call', async () => {
155157
const c = await client()
156-
expect(await c.bulkEncrypt([])).toEqual([])
158+
expect(await c.bulkEncrypt([])).toEqual({ data: [] })
157159
expect(ffi.encryptBulk).not.toHaveBeenCalled()
158160
})
159161
})
@@ -185,14 +187,16 @@ describe('WasmEncryptionClient.bulkDecrypt', () => {
185187
const c = await client()
186188
const out = await c.bulkDecrypt([null, ct('a'), undefined, ct('b')])
187189

188-
expect(out).toEqual([null, 'plain-0', null, 'plain-1'])
190+
expect(out).toEqual({ data: [null, 'plain-0', null, 'plain-1'] })
189191
const [, opts] = ffi.decryptBulkFallible.mock.calls[0]
190192
expect(opts.ciphertexts).toHaveLength(2)
191193
})
192194

193195
it('short-circuits an all-null batch without calling the FFI', async () => {
194196
const c = await client()
195-
expect(await c.bulkDecrypt([null, undefined])).toEqual([null, null])
197+
expect(await c.bulkDecrypt([null, undefined])).toEqual({
198+
data: [null, null],
199+
})
196200
expect(ffi.decryptBulkFallible).not.toHaveBeenCalled()
197201
})
198202

@@ -208,27 +212,29 @@ describe('WasmEncryptionClient.bulkDecrypt', () => {
208212
// input 1/2/3 map to live 0/1/2, and the two failures are inputs 2 and 3.
209213
await expect(
210214
c.bulkDecrypt([null, ct('a'), ct('b'), ct('c')]),
211-
).rejects.toThrow(/failed for 2 of 3 payload\(s\)/)
215+
).resolves.toMatchObject({
216+
failure: {
217+
type: 'DecryptionError',
218+
message: expect.stringMatching(/failed for 2 of 3 payload\(s\)/),
219+
},
220+
})
212221

213222
ffi.decryptBulkFallible.mockResolvedValueOnce([
214223
{ data: 'ok' },
215224
{ error: 'boom-one' },
216225
{ error: 'boom-two' },
217226
] as never)
218-
const err = await c
219-
.bulkDecrypt([null, ct('a'), ct('b'), ct('c')])
220-
.catch((e: Error) => e)
227+
const res = await c.bulkDecrypt([null, ct('a'), ct('b'), ct('c')])
221228

222-
expect((err as Error).message).toContain('[2]: boom-one')
223-
expect((err as Error).message).toContain('[3]: boom-two')
229+
expect(res.failure?.message).toContain('[2]: boom-one')
230+
expect(res.failure?.message).toContain('[3]: boom-two')
224231
})
225232

226233
it('succeeds when every item decrypts', async () => {
227234
const c = await client()
228-
await expect(c.bulkDecrypt([ct('a'), ct('b')])).resolves.toEqual([
229-
'plain-0',
230-
'plain-1',
231-
])
235+
await expect(c.bulkDecrypt([ct('a'), ct('b')])).resolves.toEqual({
236+
data: ['plain-0', 'plain-1'],
237+
})
232238
})
233239
})
234240

@@ -249,7 +255,11 @@ describe('bulk result/input length mismatch', () => {
249255
{ plaintext: 'a', table: users, column: users.email },
250256
{ plaintext: 'b', table: users, column: users.email },
251257
]),
252-
).rejects.toThrow(/sent 2 payload\(s\).*received 1 back/s)
258+
).resolves.toMatchObject({
259+
failure: {
260+
message: expect.stringMatching(/sent 2 payload\(s\).*received 1 back/s),
261+
},
262+
})
253263
})
254264

255265
it('bulkDecrypt throws rather than returning a partially-null batch', async () => {
@@ -258,8 +268,10 @@ describe('bulk result/input length mismatch', () => {
258268
] as never)
259269

260270
const c = await client()
261-
await expect(c.bulkDecrypt([ct('a'), ct('b')])).rejects.toThrow(
262-
/sent 2 payload\(s\).*received 1 back/s,
263-
)
271+
await expect(c.bulkDecrypt([ct('a'), ct('b')])).resolves.toMatchObject({
272+
failure: {
273+
message: expect.stringMatching(/sent 2 payload\(s\).*received 1 back/s),
274+
},
275+
})
264276
})
265277
})

packages/stack/__tests__/wasm-inline-query.test.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({
3030

3131
import { encryptedTable, types } from '../src/eql/v3'
3232
import { Encryption } from '../src/wasm-inline'
33+
import { expectData } from './helpers/expect-result'
3334

3435
const users = encryptedTable('users', {
3536
// TextEq → unique index only
@@ -131,7 +132,12 @@ describe('WasmEncryptionClient.encryptQuery', () => {
131132
column: users.email,
132133
queryType: 'freeTextSearch',
133134
}),
134-
).rejects.toThrow(/not configured on column "email"/)
135+
).resolves.toMatchObject({
136+
failure: {
137+
type: 'EncryptionError',
138+
message: expect.stringMatching(/not configured on column "email"/),
139+
},
140+
})
135141
expect(ffi.encryptQuery).not.toHaveBeenCalled()
136142
})
137143

@@ -167,7 +173,7 @@ describe('WasmEncryptionClient.encryptQuery', () => {
167173
const c = await client()
168174
expect(
169175
await c.encryptQuery(null, { table: users, column: users.email }),
170-
).toBeNull()
176+
).toEqual({ data: null })
171177
expect(ffi.encryptQuery).not.toHaveBeenCalled()
172178
})
173179

@@ -182,7 +188,9 @@ describe('WasmEncryptionClient.encryptQuery', () => {
182188
column: users.age,
183189
queryType: 'orderAndRange',
184190
}),
185-
).rejects.toThrow('[encryption]: Cannot encrypt NaN value')
191+
).resolves.toMatchObject({
192+
failure: { message: '[encryption]: Cannot encrypt NaN value' },
193+
})
186194
expect(ffi.encryptQuery).not.toHaveBeenCalled()
187195
})
188196

@@ -194,7 +202,13 @@ describe('WasmEncryptionClient.encryptQuery', () => {
194202
column: users.bio,
195203
queryType: 'freeTextSearch',
196204
}),
197-
).rejects.toThrow(/Cannot use 'match' index with numeric value/)
205+
).resolves.toMatchObject({
206+
failure: {
207+
message: expect.stringMatching(
208+
/Cannot use 'match' index with numeric value/,
209+
),
210+
},
211+
})
198212
expect(ffi.encryptQuery).not.toHaveBeenCalled()
199213
})
200214

@@ -209,7 +223,9 @@ describe('WasmEncryptionClient.encryptQuery', () => {
209223
queryType: 'orderAndRange',
210224
},
211225
]),
212-
).rejects.toThrow('[encryption]: Cannot encrypt Infinity value')
226+
).resolves.toMatchObject({
227+
failure: { message: '[encryption]: Cannot encrypt Infinity value' },
228+
})
213229
expect(ffi.encryptQueryBulk).not.toHaveBeenCalled()
214230
})
215231
})
@@ -235,10 +251,11 @@ describe('WasmEncryptionClient.encryptQueryBulk', () => {
235251
},
236252
])
237253

238-
expect(out).toHaveLength(3)
239-
expect(out[0]).toEqual({ v: 3, n: 0 })
240-
expect(out[1]).toBeNull()
241-
expect(out[2]).toEqual({ v: 3, n: 1 })
254+
const terms = expectData(out)
255+
expect(terms).toHaveLength(3)
256+
expect(terms[0]).toEqual({ v: 3, n: 0 })
257+
expect(terms[1]).toBeNull()
258+
expect(terms[2]).toEqual({ v: 3, n: 1 })
242259
// Only the two live terms reached the FFI, with per-term resolution.
243260
const { queries } = ffi.encryptQueryBulk.mock.calls[0][1] as {
244261
queries: Array<{ indexType: string }>
@@ -251,7 +268,7 @@ describe('WasmEncryptionClient.encryptQueryBulk', () => {
251268
const out = await c.encryptQueryBulk([
252269
{ value: null, table: users, column: users.email },
253270
])
254-
expect(out).toEqual([null])
271+
expect(out).toEqual({ data: [null] })
255272
expect(ffi.encryptQueryBulk).not.toHaveBeenCalled()
256273
})
257274
})

0 commit comments

Comments
 (0)