Skip to content

Share one operation layer across both entries — inject the FFI backend instead of importing it #798

Description

@coderdan

@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:

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.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions