Skip to content

A schema authored on @cipherstash/stack/wasm-inline is a compile error against every other entry — tsup emits two copies of the column classes #963

Description

@tobyhede

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

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.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):

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 = users
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

  1. 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.

  2. 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.

  3. 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.

  4. 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.
  • 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 diagnostics entry also gets its own declaration pass, but it declares no column classes, so it is not affected.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    SDKbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions