You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@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/stack and @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:
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):
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.ts imports its column classes from a shared chunk, types-public-lSPz8ww9.d.ts, where EncryptedV3Column is declared once at line 424.
dist/wasm-inline.d.ts imports no such chunk. It declares its own EncryptedV3Column at 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):
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/:
error 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 makes as any the tempting fix — don't."
skills/stash-managed-platforms/SKILL.md:179 — the same claim.
Proposal
Emit the wasm-inline declarations from the main build configuration. Add src/wasm-inline.ts to the main config's dts list and set dts: false on the wasm-inline config. All entries then share one types-public-*.d.ts chunk, so there is one declaration of every column class. JavaScript emission does not move — ./wasm-inline keeps its ESM-only shape and its exports map keeps no require branch.
Derive the dts list 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.ts and 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 into dist/.
Node16, by package name through the exports map.
Both should pin all four directions: a WASM-authored table against AnyV3Table and against the native Encryption, and a native-authored table against the WASM Encryption. test:types:dist is 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 1
The 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.
Background
@cipherstash/stackis 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
encryptedTableand thetypes.*helpers, and hand it to a client: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-edgeskill 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.tsbuilds the package withtsup. It declares three separate build configurations, and two of them each generate their own TypeScript declaration files (.d.ts). Thewasm-inlineentry gets its own declaration pass (packages/stack/tsup.config.ts:47):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.
EncryptedV3Columnhas private fields (packages/stack/src/eql/v3/columns.ts:475):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'sschemasoption, the Drizzle helpers, Prisma Next, and the nativeEncryptionfactory. It fails in the other direction too: a schema written against the native entry is rejected by the WASM entry'sEncryption.Reproduced against
mainat17c9fadd, compiling against the builtdist/:The code runs correctly. Our runtime checks (
isV3ColumnLikeatpackages/stack-supabase/src/column-map.ts:57, andhasBuildColumnKeyMapre-exported atpackages/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 makesas anythe obvious workaround, andas anyis exactly the thing that would hide a real schema mistake later.wasm-inlineis 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.jsonmaps@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 bytest: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
stashnpm 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/.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 anyis 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
mainat17c9fadd:The
tscoutput above was produced by compiling the four-line reproduction against the builtdist/withmoduleResolution: bundler.Relationship to other work
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.diagnosticsentry also gets its own declaration pass, but it declares no column classes, so it is not affected.