Repository navigation
Description
Background
@cipherstash/stack is our JavaScript/TypeScript encryption library. It ships several entry points — different import paths into the same package, each built for a different runtime:
@cipherstash/stackand@cipherstash/stack/v3— the default, for Node servers. It loads a native module.@cipherstash/stack/wasm-inline— for runtimes that cannot load native modules: Cloudflare Workers, Deno, Bun, and Supabase Edge Functions. It has the WebAssembly build of the engine inlined into the JavaScript.
Before you can encrypt anything you describe your data with a schema. You write it once, with encryptedTable and the types.* helpers, and hand it to a client:
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
export const users = encryptedTable('users', {
email: types.TextSearch('email'),
})The schema is meant to be one shared module. A typical project has a Node server and an Edge Function that talk to the same database, so both need the same table description, and our own stash-edge skill tells people to write it once and import it in both places.
Problem
The mechanism
The published package contains two separate copies of the same class, and TypeScript treats them as two different types.
packages/stack/tsup.config.ts builds the package with tsup. It declares three separate build configurations, and two of them each generate their own TypeScript declaration files (.d.ts). The wasm-inline entry gets its own declaration pass (packages/stack/tsup.config.ts:47):
{
entry: { 'wasm-inline': 'src/wasm-inline.ts' },
format: ['esm'],
dts: { entry: { 'wasm-inline': 'src/wasm-inline.ts' } },
...
}That is a second, independent rollup run. It does not share code with the first one, so it writes out its own copy of every column class instead of pointing at the shared chunk the other entries use. You can see both copies in a build:
dist/eql/v3/index.d.tsimports its column classes from a shared chunk,types-public-lSPz8ww9.d.ts, whereEncryptedV3Columnis declared once at line 424.dist/wasm-inline.d.tsimports no such chunk. It declares its ownEncryptedV3Columnat line 1184.
Normally duplicate copies of an identical class are harmless — TypeScript compares types by their shape, so two identical shapes are interchangeable. That is not true here. EncryptedV3Column has private fields (packages/stack/src/eql/v3/columns.ts:475):
private readonly columnName: string,
private readonly definition: D,TypeScript compares classes that have private members by where they were declared, not by their shape. Two copies of an identical class with a private field are two unrelated types. So the two entry points ship column classes that TypeScript refuses to mix.
What the user hits
A schema written against the WASM entry is a compile error everywhere an EQL v3 table is expected — encryptedSupabase's schemas option, the Drizzle helpers, Prisma Next, and the native Encryption factory. It fails in the other direction too: a schema written against the native entry is rejected by the WASM entry's Encryption.
Reproduced against main at 17c9fadd, compiling against the built dist/:
import type { AnyV3Table } from '@cipherstash/stack/eql/v3'
import { encryptedTable, types } from '@cipherstash/stack/wasm-inline'
const users = encryptedTable('users', { email: types.TextSearch('email') })
export const crossEntry: AnyV3Table = userserror TS2322: Type 'EncryptedTable<{ email: EncryptedTextSearchColumn; }> & …'
is not assignable to type 'AnyV3Table'.
Types of property 'columnBuilders' are incompatible.
Type 'EncryptedTextSearchColumn' is not assignable to type 'AnyEncryptedV3Column'.
Types have separate declarations of a private property 'columnName'.
The code runs correctly. Our runtime checks (isV3ColumnLike at packages/stack-supabase/src/column-map.ts:57, and hasBuildColumnKeyMap re-exported at packages/stack/src/adapter-kit.ts:66) look at the shape of the object, not its class, so they accept a table from either entry. Only the compiler objects. That combination — it works when you run it, the type checker says no — is what makes as any the obvious workaround, and as any is exactly the thing that would hide a real schema mistake later.
wasm-inline is the entry our edge examples and the managed-platform path use, so this is the published shape for Workers, Deno, Bun and Supabase Edge — the runtimes with the least room to debug a type problem.
Why nothing catches it
Our type tests run against source, not the built package. packages/stack-supabase/tsconfig.json maps @cipherstash/stack/* to ../stack/src, so those tests compare source to source and never see how the entry points resolve for someone who installed the package. There is exactly one class in the source tree; the duplication only exists after the build.
There are declaration gates that do read dist/ — packages/stack/dist-types/, run by test:types:dist (packages/stack/package.json:201) — but none of them imports from two entries at once, so none of them can see the two copies.
We currently document this as intended behaviour
Three skill files, which ship inside the stash npm tarball and get copied into customer repositories, tell people this is permanent:
skills/stash-edge/SKILL.md:384— a section headed "Schema Modules Do Not Cross Entries", quoting the diagnostic.skills/stash-encryption/SKILL.md:526— "The schema is not shareable between entries either… It works at runtime, which makesas anythe tempting fix — don't."skills/stash-managed-platforms/SKILL.md:179— the same claim.
Proposal
-
Emit the
wasm-inlinedeclarations from the main build configuration. Addsrc/wasm-inline.tsto the main config'sdtslist and setdts: falseon thewasm-inlineconfig. All entries then share onetypes-public-*.d.tschunk, so there is one declaration of every column class. JavaScript emission does not move —./wasm-inlinekeeps its ESM-only shape and itsexportsmap keeps norequirebranch. -
Derive the
dtslist from the entry list rather than writing it out twice. Two hand-maintained lists is how a subpath added to one and not the other silently loses its types. -
Add a gate that reads the built
.d.tsand imports from two entries at once, so a future build change that re-splits the declarations fails the build instead of shipping. One gate per module-resolution mode, because they resolve the entry points differently:moduleResolution: bundler, over relative paths intodist/.- Node16, by package name through the
exportsmap.
Both should pin all four directions: a WASM-authored table against
AnyV3Tableand against the nativeEncryption, and a native-authored table against the WASMEncryption.test:types:distis already wired into CI and cannot be skipped. -
Correct the three skill files. They state the limitation as permanent behaviour. Once the fix ships they should describe the shared-schema story instead, and keep the diagnostic only as a signal that the reader is on an old version.
There is a cheap interim step if step 1 is not wanted yet: the skills are currently telling customers that as any is the wrong fix for a problem we know how to remove. Correcting them to say "fixed in version X, upgrade" is worth doing on its own.
Evidence
Verified against main at 17c9fadd:
# Two declarations of the class
grep -c "class EncryptedV3Column" packages/stack/dist/wasm-inline.d.ts # 1
grep -n "private readonly columnName" packages/stack/dist/wasm-inline.d.ts # 1184
grep -n "private readonly columnName" packages/stack/dist/types-public-*.d.ts # 424
# wasm-inline pulls in no shared types chunk; eql/v3 does
grep -n "types-public" packages/stack/dist/wasm-inline.d.ts # no match
grep -n "types-public" packages/stack/dist/eql/v3/index.d.ts # line 1The tsc output above was produced by compiling the four-line reproduction against the built dist/ with moduleResolution: bundler.
Relationship to other work
- The runtime half of this same duplicate-class hazard is already fixed.
isV3ColumnLike(packages/stack-supabase/src/column-map.ts:57) probes structurally so the runtime accepts a table from either entry. This issue is the type-level half of the same problem. - Share one operation layer across both entries — inject the FFI backend instead of importing it #798 ("Share one operation layer across both entries — inject the FFI backend instead of importing it") addresses a related but different problem: the two entries duplicating operation logic. It would not remove the duplicated declarations on its own.
- The
diagnosticsentry also gets its own declaration pass, but it declares no column classes, so it is not affected.
Activity
tobyhede commentedon Sep 2, 2026
Draft PR: #964.
It rebases the fix (written 2026-07-30, never pushed) onto current
main, and adds the twodist/-reading gates described in step 3 of the proposal. Both were watched failing against a build made frommain'stsup.config.ts, then confirmed green with the fix.It also corrects a fourth skill the proposal did not name —
stash-managed-platforms, which did not exist when the original commit was written and repeats the same claim in three places.test(scripts),docs(skills): retire the guard this fix falsifies