feat(stack)!: align wasm-inline to the Result contract, and add bulkEncrypt/bulkDecrypt#741
Conversation
🦋 Changeset detectedLatest commit: 1ffdc56 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📝 WalkthroughWalkthroughThe WASM encryption client now returns typed ChangesWASM Result and bulk operations
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant WasmEncryptionClient
participant decryptBulkFallible
participant ZeroKMS
Caller->>WasmEncryptionClient: bulkDecrypt(ciphertexts)
WasmEncryptionClient->>decryptBulkFallible: live ciphertext payloads
decryptBulkFallible->>ZeroKMS: one bulk decrypt request
ZeroKMS-->>decryptBulkFallible: success and failure results
decryptBulkFallible-->>WasmEncryptionClient: fallible result array
WasmEncryptionClient-->>Caller: aligned plaintexts or aggregated failure
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/stack/src/wasm-inline.ts (1)
612-618: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate live-item partitioning logic across bulk methods.
The filter-into-
live/pre-fill-out-with-null/short-circuit-on-empty pattern is now written three times in this file:encryptQueryBulk(541-547, pre-existing),bulkEncrypt(612-618), andbulkDecrypt(683-689). SinceassertBatchLengthwas already extracted as a shared helper for the positional-safety contract, extracting this partitioning step too would keep all three bulk methods' index-alignment logic in one place, reducing the risk of the pattern silently diverging if one method's null-handling changes later.♻️ Example extraction
function partitionLive<T, L>( items: readonly T[], isLive: (item: T) => boolean, ): { live: Array<{ item: T; at: number }>; outTemplate: Array<L | null> } { const live: Array<{ item: T; at: number }> = [] items.forEach((item, at) => { if (isLive(item)) live.push({ item, at }) }) return { live, outTemplate: items.map(() => null) } }Also applies to: 683-689
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/src/wasm-inline.ts` around lines 612 - 618, Extract the repeated live-item partitioning and null-output initialization into a shared helper near the bulk methods, such as partitionLive, accepting the items and a liveness predicate and returning live entries plus an output template. Update encryptQueryBulk, bulkEncrypt, and bulkDecrypt to use this helper while preserving their existing empty-live short-circuit and index alignment behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/stack/src/wasm-inline.ts`:
- Around line 612-618: Extract the repeated live-item partitioning and
null-output initialization into a shared helper near the bulk methods, such as
partitionLive, accepting the items and a liveness predicate and returning live
entries plus an output template. Update encryptQueryBulk, bulkEncrypt, and
bulkDecrypt to use this helper while preserving their existing empty-live
short-circuit and index alignment behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8861492a-6efa-4fbd-9e0d-42ff760d924f
📒 Files selected for processing (5)
.changeset/wasm-inline-bulk-ops.mdpackages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.tspackages/stack/__tests__/wasm-inline-bulk.test.tspackages/stack/src/wasm-inline.tsskills/stash-encryption/SKILL.md
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/stack/src/wasm-inline.ts (1)
598-629: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffConsider extracting the shared bulk scaffolding.
encryptQueryBulk,bulkEncrypt, andbulkDecryptall repeat the same pattern: filter live inputs into{ item, at }, seed an all-nullalignedout, short-circuit on empty,assertBatchLength, then write results back positionally byslot.at. A small generic helper (taking the liveness predicate, the FFI call, and a per-slot writer) would remove the triplication while keepingbulkDecrypt's failure aggregation as the writer's responsibility. Optional given the divergence in the decrypt path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/src/wasm-inline.ts` around lines 598 - 629, The bulk methods encryptQueryBulk, bulkEncrypt, and bulkDecrypt duplicate live-item filtering, null-aligned output initialization, empty-input handling, batch-length validation, and positional result mapping. Extract this shared scaffolding into a small generic helper parameterized by the liveness predicate, FFI operation, and per-slot writer; keep bulkDecrypt’s failure aggregation within its writer, and update each method to use the helper without changing behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/stack/src/wasm-inline.ts`:
- Around line 598-629: The bulk methods encryptQueryBulk, bulkEncrypt, and
bulkDecrypt duplicate live-item filtering, null-aligned output initialization,
empty-input handling, batch-length validation, and positional result mapping.
Extract this shared scaffolding into a small generic helper parameterized by the
liveness predicate, FFI operation, and per-slot writer; keep bulkDecrypt’s
failure aggregation within its writer, and update each method to use the helper
without changing behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: df90ce52-d222-4c71-88a9-1bbcbe230720
📒 Files selected for processing (9)
.changeset/wasm-inline-bulk-ops.mdexamples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.tspackages/stack/__tests__/helpers/expect-result.tspackages/stack/__tests__/wasm-inline-bulk.test.tspackages/stack/__tests__/wasm-inline-query.test.tspackages/stack/__tests__/wasm-inline-result-contract.test.tspackages/stack/integration/wasm/adapter.tspackages/stack/src/wasm-inline.tsskills/stash-encryption/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/stack/tests/wasm-inline-bulk.test.ts
freshtonic
left a comment
There was a problem hiding this comment.
Overview
Two changes to @cipherstash/stack/wasm-inline: (1) every fallible method now returns { data } | { failure } instead of throwing — aligning the edge surface with the native entry and the AGENTS.md Result contract (breaking, but the surface has only ever shipped under the rc tag, so no stable consumers); (2) adds bulkEncrypt/bulkDecrypt, collapsing an N-row list from N ZeroKMS round trips to one — the thing that made list endpoints impractical on Deno/Workers/Edge.
Carefully implemented and, importantly, correct at the FFI boundary. LGTM / approvable — details below; no blocking issues.
What I verified
- Result contract is correct and at parity with the native entry. Each method routes to the right error type:
encrypt/encryptQuery/encryptQueryBulk/bulkEncrypt→EncryptionErrorTypes.EncryptionError;decrypt/bulkDecrypt→DecryptionError.toFailureproduces{ type, message, code: getErrorCode(error) }— the same shape the nativebulk-decrypt.tsfailure mapper emits.isEncryptedcorrectly stays a plainboolean(pure predicate), matching native. - The FFI boundary contracts all match the native reference — the one place a stub-based test can pass while disagreeing with the real FFI, so I checked against
packages/protect:encryptBulkpayload field isplaintexts(matches nativemodel-helpers.ts);decryptBulkFalliblepayload field isciphertexts(matches nativebulk-decrypt.ts); and the per-item result discriminant('error' in result) ? result.error : result.datais exactly what nativemapDecryptedDataToResultuses. No boundary drift. - Bulk logic is right. Both methods filter live (non-null) entries carrying their original index, pre-fill an index-aligned
nulloutput, short-circuit with no FFI call when the batch is all-null, and scatter results back by position.null/undefinednever reaches ZeroKMS. Per-item table/column routing (vs the native single-column batch) is what makes the round-trip saving real, and it's free at the FFI boundary sinceEncryptPayloadis per-item. - Fail-closed length guard.
assertBatchLengthon all three batch methods (including the pre-existingencryptQueryBulk, which previously left trailing slots silentlynull) turns a short FFI response — which positional matching would otherwise render as indistinguishable-from-"no value"nulls — into a{ failure }. Correct call: silent wrong data is the worse outcome. - Partial-decrypt aggregation is thorough.
bulkDecryptuses the fallible primitive and collects every failing index+reason before throwing (caught bywithResult→ one{ failure }), rather than surfacing the first and discarding work already paid for. The[at]indices are correctly into the input array (viaslot.at), not the compacted live array. - Changeset (
@cipherstash/stackminor +stashpatch for the skill) is appropriate; the breaking change is justified pre-1.0 (rc-tag-only surface). Skill, thesupabase-workerexample, and the WASM integration adapter are updated for the new shape.
Notes — non-blocking
- Per-item
codes are dropped from the structured output on partial decrypt failure.decryptBulkFalliblereturns{ error, code? }per item, but onlyresult.error(the message) is aggregated; the finalfailure.codecomes from the synthesized aggregateError, which has none. Reasonable — a batch has no single code and the reasons are in the message — but if callers ever want to branch on per-row codes, they aren't there. The author already flags a per-itemResult[]as a possible future shape; that would also address this. ensureErrorquirk is documented, not worked around —@byteslice/result@0.2.0replaces a non-Errorthrow withnew Error('Something went wrong')before the mapper runs, sotoFailure'serror instanceof Error ? … : String(error)narrowing can't actually see a raw non-Error value today. It's library-wide (native has it too) and pinned as a test that will surface if the #532 bump to 0.5.0 changes it. Good handling; noting for awareness.- Local
node_moduleshas protect-ffi 0.23/0.26/0.27, not 0.30 in this checkout — the "stale install" the PR calls out. Doesn't affect the diff (the bulk primitives are identical across 0.29/0.30 per the PR), but it means a localpnpm testhere wouldn't exercise the real 0.30 FFI withoutpnpm install --frozen-lockfilefirst. CI is the real signal.
Verdict
Correct, careful, and verified where it matters most — the FFI field names and per-item result shapes all match the native implementation, the Result routing is right per operation side, and the batch length/partial-failure handling is fail-closed. The notes are non-blocking. I'd approve this — say the word and I'll flip it to a formal approval.
Follow-up:
|
coderdan
left a comment
There was a problem hiding this comment.
Deep review — 15 findings
The core implementation checks out: FFI routing, payload shapes, error classification, and positional matching are all correct, and the new unit suites pass 36/36 in isolation. The critical issue is a transitive import that breaks the entry on the very runtimes it targets (inline comment on src/wasm-inline.ts:89).
10 findings are inline on the diff (tagged by rank). The 5 below anchor outside the diff hunks:
2/15 — Deno e2e suites not migrated to the Result contract
e2e/wasm/roundtrip.test.ts and e2e/wasm/query-types.test.ts still consume bare values. With live CS_* credentials: isEncrypted(encrypted) (roundtrip:111) receives { data } and fails; roundtrip:141 passes the Result envelope to decrypt as ciphertext; bulk.length (roundtrip:165, query-types:~228) is undefined on a Result; every assertV3Term(await client.encryptQuery(...)) sees term.v === undefined. The suites are secret-gated so this lands silently — and the unit tests' claim that “live coverage runs in the Deno e2e” has no backing for the new bulk ops either (no bulkEncrypt/bulkDecrypt calls in e2e/wasm/). Separately, the e2e import map (e2e/wasm/deno.json) maps only the three /wasm-inline subpaths, so the new bare @cipherstash/protect-ffi specifier in the built entry (finding 1) is unresolvable there.
6/15 — pending changeset now contradicts this PR
.changeset/wasm-encrypt-query.md:17 still says “Errors throw, consistent with the WASM surface's encrypt/decrypt”. Both changesets roll into the same GA changelog entry when pre mode exits — one tells users encryptQuery throws, the adjacent one says every fallible method returns a Result. Update the stale sentence in this PR.
7/15 — the entry doesn't export its own failure vocabulary
Every method is now typed Result<T, EncryptionError> and the changeset directs users to discriminate failure.type against EncryptionErrorTypes — but the entry (export block at src/wasm-inline.ts:121) exports neither EncryptionError, EncryptionErrorTypes, nor Result. An edge consumer writing switch (result.failure.type) { case EncryptionErrorTypes.DecryptionError: ... } must reach for the Node-oriented @cipherstash/stack/errors subpath (undocumented on edge) or depend on the bundled-internal @byteslice/result — contradicting the entry's single-import design.
11/15 — the same latent flaws stay unfixed on the native entry
The onException fix and assertBatchLength guard exist only here. Every native withResult call (packages/stack/src/encryption/operations/*, packages/protect/src/ffi/operations/*) omits onException, so a non-Error rejection still becomes “Something went wrong” — the exact loss this PR documents and fixes for WASM. And native bulk ops match FFI results positionally unguarded: a short response makes decryptResult undefined and 'error' in decryptResult (packages/stack/src/encryption/operations/bulk-decrypt.ts:49) throws a bewildering TypeError. Native bulkDecrypt also preserves per-item partial success that the WASM one collapses — same-named op, divergent failure contract. Deeper fix: hoist toError/toFailure/assertBatchLength into @/encryption/helpers and use them from both entries.
12/15 — Linear ID in public artifacts
Commit ffadd95b's message and the PR title carry the internal Linear ID; the changeset, skill, and source comments are clean (#737/#741). Reword the commit via rebase and drop the suffix from the PR title before merge — after merge it's permanent.
Review method: 11 independent finder angles → dedup → per-candidate verification against the built artifact, FFI typings, and files on disk → gap sweep. Findings 9/15 and 10/15 are PLAUSIBLE (mechanism verified, trigger uncommon); all others CONFIRMED.
| isEncrypted as wasmIsEncrypted, | ||
| newClient as wasmNewClient, | ||
| } from '@cipherstash/protect-ffi/wasm-inline' | ||
| import { getErrorCode } from '@/encryption/helpers/error-code' |
There was a problem hiding this comment.
1/15 · critical — this import breaks the entry on every runtime it exists for.
@/encryption/helpers/error-code does a runtime value-import of ProtectError from the native @cipherstash/protect-ffi root entry, and protect-ffi is not in tsup noExternal — so the built dist/wasm-inline.js now contains a bare import { ProtectError as FfiProtectError } from "@cipherstash/protect-ffi" (verified by building).
- On Cloudflare Workers / Supabase Edge the non-
nodedefaultcondition resolves that todist/wasm/protect_ffi.js, which exports noProtectError→ missing-named-export error at build/link time; it also top-level-imports a raw.wasmasset — the loading mode this entry exists to avoid. - Under Deno's
nodecondition it resolves to the NAPI loader — the native-module dependency this entry exists to avoid. The e2e import map (e2e/wasm/deno.json) maps only the/wasm-inlinesubpaths, so the bare specifier is unresolvable there at all.
The unit tests can't see this: they mock only the /wasm-inline subpath and run under Node, where the native root resolves fine.
Fix: don't import the native-entry helper here — duck-type the code lookup (read .code structurally, with a type-only import of ProtectErrorCode). Worth adding an import-graph assertion on dist/wasm-inline.js so this class of regression fails CI.
| return (error: unknown) => ({ | ||
| type, | ||
| message: error instanceof Error ? error.message : String(error), | ||
| code: getErrorCode(error), |
There was a problem hiding this comment.
3/15 — failure.code can never be populated on this entry, contradicting the changeset's “failure.code carrying the FFI error code where there is one”.
withResult runs onException (toError) first, so by the time toFailure runs, error is always a fresh plain Error — and getErrorCode only matches instanceof the native ProtectError class, which the WASM build doesn't export and wasm-bindgen rejections never are. A rejection of { code: 'EQL_X', detail: '…' } gets its code JSON-flattened into message, never surfaced in failure.code.
Relatedly, bulkDecrypt (line ~829) discards the per-item code the FFI does supply — the real arm is { error: string; code?: ProtectErrorCode } per protect-ffi's lib/index.d.cts — so callers can't branch on error codes (retry vs corrupt payload) anywhere on this entry. No test asserts failure.code, so the dead field ships pinned as passing; the result-contract test even rejects with a code-bearing object but only asserts message.
| const plaintext = 'alice@example.com' | ||
| const encrypted = await client.encrypt(plaintext, { | ||
| // Every fallible method returns `{ data } | { failure }` — the same | ||
| // contract as the native entry (see AGENTS.md). |
There was a problem hiding this comment.
4/15 — as committed, this example can't run against any published version.
- It still pins
npm:@cipherstash/stack@^0.18.0/wasm-inline(line 21) — a version whose entry returns bare values. Deployed as-is:encryptreturns the payload,encryptResult.failureisundefined(check passes),encryptResult.dataisundefined→decrypt(undefined)throws → 500 on every request. - Bump the pin and it breaks earlier: the example authors an EQL v2 schema (
encryptedColumn('email').equality(), line 24), but the current entry exports only the v3 surface (export * from '@/eql/v3'— noencryptedColumn) and its factory rejects non-v3 tables outright.
examples/supabase-worker/README.md:62 still advertises encryptedColumn on this surface too. The example needs the version bump and the v3 schema migration together.
| @@ -429,25 +589,26 @@ export class WasmEncryptionClient { | |||
| * @param opts - Table, column, and (optionally) which index to target — | |||
| * see {@link WasmEncryptQueryOptions.queryType} for the inference rules. | |||
| * @returns The v3 query term, or `null` for null plaintext. | |||
There was a problem hiding this comment.
5/15 — the published TSDoc still teaches the pre-#741 contract.
- This doc block now has two contradictory
@returnstags — this stale bare-value line survived above the new Result-shaped one. Generated docs and IDE hovers render both. - The module-header example (lines 34–49) still does
const dec = await client.decrypt(enc)on a bareencryptresult and interpolates${term}straight into SQL — copying the entry's own headline example passes the{ data }envelope intodecrypt(guaranteed failure) and binds{"data":{…}}as the jsonb query term. encryptQuery's four@exampleblocks (lines 541–584) interpolate the un-unwrapped return into SQL the same way.
Every method signature was migrated but the examples weren't — and they ship in the published .d.ts.
| } | ||
| ``` | ||
|
|
||
| **On the WASM entry (`@cipherstash/stack/wasm-inline`), the batch shape differs** — do not copy the shape above onto the edge. The `{ data } | { failure }` Result is the same, but there are no `{ id, plaintext }` envelopes: each entry carries its own table and column, and the payload is a plain index-aligned array. |
There was a problem hiding this comment.
8/15 — this section is unusable in the skill's own context, and skills ship into customer repos.
At this point in the document the running client is the native EncryptionV3 client; the skill never shows constructing a WASM client (the Encryption factory from @cipherstash/stack/wasm-inline appears nowhere in the file), and the Subpath Exports table (~line 183) doesn't list @cipherstash/stack/wasm-inline at all.
Concrete failure: a customer-repo agent applies this snippet to the client it has in scope — the native one — calling client.bulkEncrypt([{ plaintext, table, column }]). The native signature is bulkEncrypt(plaintexts, { table, column }), so the items are consumed as { id?, plaintext } envelopes with no column argument and the call fails at runtime in the customer's codebase.
Add the WASM client construction (import + Encryption({ schemas, config })) to this section and the subpath to the exports table.
| // a symbol/function and throws on a cycle — `String` covers both. | ||
| return new Error(JSON.stringify(ex) ?? String(ex)) | ||
| } catch { | ||
| return new Error(String(ex)) |
There was a problem hiding this comment.
9/15 (PLAUSIBLE) — this fallback can itself throw, escaping the Result contract.
String(ex) throws for a null-prototype object (Object.create(null) — no toString), and withResult runs onException bare inside its catch (options?.onException?.(ex) ?? ensureError(ex) — no inner try, verified in the @byteslice/result source). So: FFI rejects with a cyclic null-prototype object → JSON.stringify throws on the cycle → this catch → String(ex) throws TypeError: Cannot convert object to primitive value → propagates out of withResult → client.encrypt(...) rejects, and edge code written as if (r.failure) crashes with an unhandled rejection.
Trigger is contrived, but the whole point of toError is being the last line of defence. Guard it: try { return new Error(String(ex)) } catch { return new Error('unserializable rejection') } (or use Object.prototype.toString.call(ex), which can't throw).
| if (ciphertext !== null && ciphertext !== undefined) | ||
| live.push({ ciphertext, at }) | ||
| }) | ||
| const out: Array<WasmPlaintext | null> = ciphertexts.map(() => null) |
There was a problem hiding this comment.
10/15 (PLAUSIBLE) — sparse-array holes yield undefined, not the documented null.
Array.prototype.map and forEach both skip holes: for bulkDecrypt([ct1, , ct2]) (e.g. a partially-filled new Array(n)), forEach correctly excludes index 1 from the FFI call, but this map also skips it, leaving out[1] a hole — result.data[1] is undefined, violating the “null/undefined entries yield null at the same index” contract while lengths still match. Same pattern in bulkEncrypt (line ~728) and encryptQueryBulk (line ~653).
Fix: Array.from({ length: items.length }, () => null) for the out array (holes in the input are still correctly treated as dead slots by the forEach filter).
| @@ -229,10 +252,13 @@ export function makeWasmAdapter(): IntegrationAdapter { | |||
| // concurrently rather than paying fields × RTT per row. | |||
| await Promise.all( | |||
There was a problem hiding this comment.
13/15 — the harness still pays fields × rows round trips, and the new bulk path gets zero live coverage anywhere.
encryptRow issues one client.encrypt per field inside Promise.all (concurrency hides latency, not request count — a 100-row × 5-field family is 500 ZeroKMS requests). Meanwhile the unit tests mock the FFI (“live coverage runs in the Deno e2e”) and e2e/wasm/ contains no bulkEncrypt/bulkDecrypt calls — so nothing in the repo exercises the new bulk ops against real ZeroKMS.
Flattening each row's (or the whole batch's) fields into one client.bulkEncrypt call — its per-item { plaintext, table, column } routing exists precisely for this shape — collapses N×F requests into 1 and makes this harness the live coverage the PR currently lacks. The comment above (“rather than paying fields × RTT per row”) is stale either way.
| ]) | ||
| }) | ||
|
|
||
| it('is position-stable across null and undefined plaintexts', async () => { |
There was a problem hiding this comment.
14/15 — two coverage gaps that would let silent-data-loss regressions through:
- No falsy-but-live values. Position-stability is only pinned with
null/undefinedvs truthy strings. If any of the three live-filters (src/wasm-inline.ts:647/725/802) regressed from!== null && !== undefinedto a truthiness check,bulkEncrypt([{ plaintext: 0 }, { plaintext: '' }, { plaintext: false }])would return{ data: [null, null, null] }without contacting ZeroKMS — real values persisted as NULL — with every test green. Add a case asserting0/''/falsereach the FFI and return at their indices. encryptQueryBulk'sassertBatchLengthis untested. The length-mismatch describe below coversbulkEncrypt/bulkDecryptonly; dropping the guard atsrc/wasm-inline.ts:665would pass the whole suite.
(Also minor: the two mismatch test titles still say “throws rather than returning…” while the bodies assert .resolves.toMatchObject({ failure }) — leftovers from the pre-Result commit.)
| items: readonly WasmBulkPlaintext[], | ||
| ): Promise<Result<Array<Encrypted | null>, EncryptionError>> { | ||
| return wasmResult(async () => { | ||
| const live: Array<{ item: WasmBulkPlaintext; at: number }> = [] |
There was a problem hiding this comment.
15/15 (cleanup) — the positional-batch scaffolding is now hand-rolled three times, and the PR adds a fourth Result-unwrap helper variant.
- The live-slot build / null pre-fill / empty short-circuit /
assertBatchLength/ positional scatter (~13 lines) is triplicated acrossencryptQueryBulk,bulkEncrypt, andbulkDecrypt— the exact code this PR deems subtle enough to need a guard. A future batch method (e.g. the model-helper port) can omitassertBatchLengthwith no build failure — the same silent-omission risk thewasmResultdocstring says that helper exists to prevent, solved for error wrapping but not batching. OnecompactBatch(items, isLive, send)helper owning bookkeeping + length assert + scatter fixes it (and the deadif (!slot)guards afterassertBatchLengthgo away with it). integration/wasm/adapter.ts's newunwrap()and__tests__/helpers/expect-result.ts'sexpectDataduplicateunwrapResult, which already exists in@cipherstash/test-kit(whose docblock says it exists precisely to stop per-fileunwrap()re-declarations) and in this package's own__tests__/fixtures/index.ts:75.
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.
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.
`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).
…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.
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.
22a4b52 to
1ffdc56
Compare
|
Thanks — that review found a genuine blocker I'd missed, and one I'd actively mis-diagnosed. All 15 addressed across 1/15 — critical, and I had this wrong beforeYou're right, and my earlier dismissal of it was based on an invalid test. I'd run Replaced with a structural Added The rest
One thing your finding 7 surfaced indirectlyExporting the failure vocabulary initially meant re-exporting So 11/15 — filed rather than fixed here#746. Hoisting Validation840 tests, zero new tsc errors (diffed against |
Closes #737. #737.
Two changes to
@cipherstash/stack/wasm-inline, the second of which is breaking:bulkEncrypt/bulkDecrypt— a list of encrypted rows now costs one ZeroKMS round trip instead of one per row.Result—{ data } | { failure }— instead of throwing.1. Result alignment (breaking)
encrypt,decrypt,encryptQueryandencryptQueryBulkthrew on failure and returned bare values on success. They now return{ data } | { failure }, withfailure.typefromEncryptionErrorTypes(EncryptionErrorvsDecryptionError) andfailure.codecarrying the FFI code.This is what
AGENTS.md:169requires — "Operations return{ data }or{ failure }. Preserve this shape and errortypevalues inEncryptionErrorTypes" — and what the native entry has always done. The WASM entry never followed it.It was drift, not a design decision. Nothing about WASM prevents it:
@byteslice/resultis already bundled intodist/wasm-inline.js(tsup.config.tsnoExternal). 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.Why now: it's breaking, and 1.0.0 hasn't shipped.
@cipherstash/stack@latestis still0.19.0, so this surface has only ever been published under therctag — there are no stable-tag consumers. After GA this would have waited for a major.isEncryptedis unchanged: a pure predicate with nothing to fail at, as on the native entry.2. Bulk operations
The entry previously had no bulk operations at all, so an N-row list on Deno / Workers / Supabase Edge meant N sequential ZeroKMS calls — impractical alongside the cold start, and no access to the bulk path
AGENTS.mdnames for throughput. The FFI primitives already existed (encryptBulk,decryptBulkFallible, identical in protect-ffi 0.29 and 0.30); this wires them up.Index-aligned with the input;
null/undefinedyieldsnullat the same index without reaching ZeroKMS. Per-item table/column routing is what makes the saving real — one call covers several columns across many rows, where a single-column batch (the native shape) would still cost a round trip per column.Failure modes handled rather than assumed away
bulkDecryptuses the fallible primitive, sofailure.messagenames every failing index and reason, instead of surfacing the first and discarding work already paid for.null— indistinguishable from "this row had no value". Silent wrong data, so it fails instead.Scope: model helpers stay Node-only
#737 also mentions the
*Modelsvariants. Deliberately not here — the WASM entry has noencryptModel/decryptModelat all, so addingbulkEncryptModelsalone would be incoherent. Tracked as #742.Validation
wasm-inline-result-contract.test.tspins the Result shape on both paths for all six methods and both error types;wasm-inline-bulk.test.tscovers one-FFI-call-per-batch, payload shape, mixed tables/columns, position stability, short-circuits, per-index failure reporting, and the length guards.main, not by counting (the pre-existing 107 are unchanged). 0 biome errors. Build clean.Found while testing:
withResult'sensureErrorreplaces any non-Errorthrow withnew Error('Something went wrong')before the error mapper runs, discarding the original value (@byteslice/result@0.2.0,dist/result.js:27). That's library-wide — the native entry has it too — so it's pinned as a test rather than worked around locally, and will surface if the #532 bump to 0.5.0 changes it.Consumers updated
The
supabase-workerexample (now returns a 500 with the failure message rather than throwing past the handler) and the WASM integration adapter (via anunwraphelper that aborts the harness loudly). Skill updated —stash-encryptiondocumented only the native bulk shape, which wouldn't compile on the edge.Note: if you see SteVec/bloom failures locally, that's a stale install —
package.jsonrequires protect-ffi 0.30.0 since #711, andpnpm install --frozen-lockfilefixes it with no lockfile change.Summary by CodeRabbit
Summary by CodeRabbit
null/undefinedinputs.{ data } | { failure }results (instead of throwing), with structuredfailuredetails.Summary by CodeRabbit
{ data }or{ failure }results.