Skip to content

bulkEncrypt call shape diverges between the native and wasm-inline entries #792

Description

@coderdan

bulkEncrypt has two different call shapes depending on which entry of @cipherstash/stack you import, and the divergence has no design justification that survives inspection.

Native (@cipherstash/stack) WASM (@cipherstash/stack/wasm-inline)
Signature bulkEncrypt(plaintexts, { table, column }) bulkEncrypt(items)
Item shape { id?, plaintext } (BulkEncryptPayload, types.ts:433) { plaintext, table, column } (WasmBulkPlaintext)
Routing one column for the whole call per item, may mix tables/columns
Result Array<{ id?, data }> plain index-aligned array

The stated rationale does not hold

wasm-inline.ts:919 argues the divergence is "about capability, not convention" — per-item routing makes the round-trip saving real, and the id bookkeeping buys nothing once positions are stable.

Both halves of that are true. Neither is an argument for having two interfaces. If per-item routing is better, native is the one that should change; the reasoning justifies a design, then applies it to only one of two surfaces.

What the layers actually look like

The FFI primitive is already per-item — protect-ffi@0.30:

export type EncryptBulkOptions = { plaintexts: EncryptPayload[]; unverifiedContext?:  }
export type EncryptPayload = { plaintext: JsPlaintext; column: string; table: string; lockContext?: Context }

Every payload carries its own table and column, and there is no id field. So:

  • Batching is not column-scoped at any layer. The WASM entry exposes the primitive's shape.
  • Native narrows it: createEncryptPayloads (bulk-encrypt.ts:33) takes the single column/table from EncryptOptions and writes the same value onto every payload.
  • Native's id is not part of the FFI type. It is re-attached client-side by index in mapEncryptedDataToResult, which means it is redundant with the index alignment native already depends on. It is also already optional (id?: string).

The one justification that would have worked, and doesn't

A per-call { table, column } could let TypeScript derive the plaintext type for the whole batch, which per-item routing would weaken. That is not what happens: BulkEncryptPayload is Array<{ id?: string; plaintext: Plaintext | null }> — a loose union, not column-derived. The native bulk path is no more type-safe than the WASM one.

Likely history

Native bulkEncrypt long predates the WASM one, which was added greenfield in #741 (21 Jul) and matched the FFI shape. The JSDoc rationale reads as post-hoc.

Cost

  • skills/stash-edge carries a "The bulk shape differs — don't copy the native form" section that exists only because of this.
  • Moving code between a Node server and an edge function means rewriting bulk calls — on top of the schema nominal-typing problem already documented in the same skill.
  • Two entries of one package disagree on the call shape for the same operation.

Options

  1. Widen native to per-item, as an overload. Keep (plaintexts, { table, column }) working, add (items). Non-breaking, converges the entries, drops nothing — id is optional and redundant. The skill's warning section then goes away.
  2. Narrow WASM to native's shape. Converges, but discards multi-column batching the FFI gives for free.
  3. Keep the split, and rewrite wasm-inline.ts:919 to describe it as history rather than design.

Option 1 looks strictly better, but it is a public API decision for whoever owns #741.

Related

wasm-inline.ts:345 also contains a wording bug found while investigating this: it says "a single-column batch would still cost one ZeroKMS call per column", which implies batching is column-scoped. It isn't — the constraint is in the native call signature, not in batching. Worth fixing whichever option is chosen.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions