Repository navigation
Share one operation layer across both entries — inject the FFI backend instead of importing it #798
Description
@cipherstash/stack has two runtime entries whose client surfaces have drifted apart. Three open issues — #792 (bulk call shape), #793 (lock context absent and mis-explained), #797 (implement .withLockContext() on WASM) — are symptoms of one missing seam, not three independent defects. Resolving them separately risks three inconsistent answers to the same question.
The cause is static imports, not WASM
Ten files under packages/stack/src/ carry a value import of the Node-API entry:
import { encrypt as ffiEncrypt } from '@cipherstash/protect-ffi'Six are the leaf operation classes — encrypt.ts, decrypt.ts, bulk-encrypt.ts, bulk-decrypt.ts, encrypt-query.ts, batch-encrypt-query.ts — plus encryption/index.ts, two helpers, and dynamodb/helpers.ts.
A module-level value import means anything transitively importing EncryptOperation drags in a Node-API binary that cannot load in a V8 isolate. So wasm-inline.ts could not reuse the operation classes at all. It reimplemented the client surface against @cipherstash/protect-ffi/wasm-inline — 1,600 lines — and drifted on shape while doing so.
The divergence is a consequence of how the FFI is reached, not anything intrinsic to WASM.
Most of the work is already done
The chainable machinery is already backend-agnostic. Six of the twelve operation files have no native import at all: base-operation.ts (which defines .audit() and the thenable), mapped-decrypt.ts (which makes .audit() and .withLockContext() compose in either order), and all four model helpers. Only the leaves that make the actual FFI call are contaminated.
Half the injection exists. Operation classes already take the client handle as a constructor argument (private client: Client). The object is injected; only the module-level functions are not.
The boundary is already understood. Six other files use import type { … } from '@cipherstash/protect-ffi' specifically so that referencing the binding's types does not pull the binary — types.ts:48 derives Client from typeof newClient this way. Someone drew this line deliberately; it just stops short of the call sites.
Sharing is already routine. wasm-inline.ts imports eight modules from @/ — model traversal, validation, eql/v3, schema, errors, types. The model-traversal comment states the rationale outright: "shared, not ported, so the two entries cannot drift on which fields get encrypted." That argument was applied once and then not extended to operations.
Proposal
Define a narrow backend interface — six methods: encrypt, decrypt, encryptBulk, decryptBulk, encryptQuery, encryptQueryBulk. Operation classes take it instead of importing it. Each entry injects its own binding.
The two FFI surfaces are already near-identical: the WASM binding types its opts as any across serde into the same Rust core, and the NAPI entry types the same shapes explicitly. The interface is mostly a re-declaration of what both already accept.
What this closes structurally rather than by remembering:
.audit()and.withLockContext()exist on both entries automatically → Implement .withLockContext() on the wasm-inline entry #797, and most of wasm-inline has no lock context — and explains its absence incorrectly #793- The bulk call shape cannot diverge, because there is one implementation → bulkEncrypt call shape diverges between the native and wasm-inline entries #792
- Future operations land on both entries at once
Risks
Bundle contamination. The entire value of wasm-inline is that nothing native loads. That currently holds — the shipped packages/stack/dist/wasm-inline.js references only @cipherstash/protect-ffi/wasm-inline, with zero references to the NAPI entry. Shared operation code turns that from an observed property into one that must be enforced: a build-time test asserting the built bundle contains no NAPI reference, in the same spirit as the existing runtime-versions-embed.e2e.test.ts which checks the built artifact rather than the source.
Client is native-derived. types.ts:48 is Awaited<ReturnType<typeof newClient>>. The abstraction needs a client type not defined by one backend.
Result vs thenable — the breaking decision. Native returns chainable operation objects; WASM returns Promise<WasmResult<T>> directly. One shared operation layer means picking one, which is breaking on the other entry. This is the same question #792 and #797 each raise in their own scope.
Scope. This is a refactor of the core client path. It wants its own release, and probably a staged landing (interface first, one operation migrated, then the rest).
Suggested sequencing
Decide the surface question here — chainable or Result, and whether both entries converge on one — before acting on #792, #793, or #797. Once that is settled those three become mechanical. Taken in the other order, each one hard-codes an answer the others have to live with.
Activity
chore(changeset): record the shared operation layer (#798)
docs(stack): document the CryptoBackend contract and the shared opera…
revert(stack): back out the WASM encrypt migration (#798, stage 4)
coderdan commentedon Jul 26, 2026
Stage 4 is blocked upstream: cipherstash/protectjs-ffi#142.
protect-ffi's WASM
.d.tstypes every binding function asopts: anyand exports none of the option types — they exist only in the Node-API.d.ts. SoCryptoBackendmust import them from the native specifier, which then lands in the WASM entry's published types, and the WASM backend needs anas nevercast on every argument. The interface meant to keep both bindings honest ends up checking only one of them.Stages 1–3 are in #800 and stand on their own. Stage 4 was attempted there and reverted — details in the PR and in
docs/plans/798-shared-operation-layer.md.docs(plans): link the upstream protect-ffi blocker for #798 stage 4
refactor(stack): inject the FFI backend instead of importing it (#798,…
refactor(stack): make operations satisfy Promise structurally (#798, …
refactor(stack): migrate the remaining operations to the injected bac…
docs(plans): correct #798 — stage 5 was already done
refactor(stack): make the operation path WASM-reachable (#798, stage …
feat(stack)!: WASM entry adopts the shared encrypt operation (#798, s…
chore(changeset): record the shared operation layer (#798)
docs(stack): document the CryptoBackend contract and the shared opera…
revert(stack): back out the WASM encrypt migration (#798, stage 4)
docs(plans): link the upstream protect-ffi blocker for #798 stage 4
test(stack): assert the WASM entry imports without a process global
docs(plans): record that #798 stage 4's blocker is fixed upstream