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
- 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.
- Narrow WASM to native's shape. Converges, but discards multi-column batching the FFI gives for free.
- 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.
bulkEncrypthas two different call shapes depending on which entry of@cipherstash/stackyou import, and the divergence has no design justification that survives inspection.@cipherstash/stack)@cipherstash/stack/wasm-inline)bulkEncrypt(plaintexts, { table, column })bulkEncrypt(items){ id?, plaintext }(BulkEncryptPayload,types.ts:433){ plaintext, table, column }(WasmBulkPlaintext)Array<{ id?, data }>The stated rationale does not hold
wasm-inline.ts:919argues the divergence is "about capability, not convention" — per-item routing makes the round-trip saving real, and theidbookkeeping 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:Every payload carries its own
tableandcolumn, and there is noidfield. So:createEncryptPayloads(bulk-encrypt.ts:33) takes the singlecolumn/tablefromEncryptOptionsand writes the same value onto every payload.idis not part of the FFI type. It is re-attached client-side by index inmapEncryptedDataToResult, 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:BulkEncryptPayloadisArray<{ 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
bulkEncryptlong 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-edgecarries a "The bulk shape differs — don't copy the native form" section that exists only because of this.Options
(plaintexts, { table, column })working, add(items). Non-breaking, converges the entries, drops nothing —idis optional and redundant. The skill's warning section then goes away.wasm-inline.ts:919to 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:345also 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.