Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/wasm-inline-type-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@cipherstash/stack': patch
'@cipherstash/stack-supabase': patch
'stash': patch
---

Fix: a schema authored with `encryptedTable`/`types` from
`@cipherstash/stack/wasm-inline` was a compile error wherever an EQL v3 table was
expected — `encryptedSupabase`'s `schemas`, the Drizzle helpers, Prisma Next, the
native `Encryption` — and native-authored tables were rejected by the WASM
`Encryption`, with `Types have separate declarations of a private property
'columnName'`. The two entries shipped separately-emitted copies of every column
class, and TypeScript compares classes with private members by declaration
origin. The runtime was never affected, which made `as any` the tempting fix.

Every entry now resolves one declaration, so one schema module can be shared
between a Node server and an Edge Function in either direction. `./wasm-inline`
keeps its ESM-only shape.
11 changes: 7 additions & 4 deletions packages/stack-supabase/__tests__/helpers/supabase-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,16 @@ export function createMockSupabase(resultData: unknown = []) {

/**
* A table whose column builders are structurally EQL v3 but are NOT instances
* of the `EncryptedV3Column` this package imports — which is exactly how a
* table authored from `@cipherstash/stack/wasm-inline` presents, because tsup
* emits that class twice (see `isV3ColumnLike` in `src/column-map.ts`).
* of the `EncryptedV3Column` this package imports. That is how a table presents
* whenever the adapter and the schema resolve different emitted copies of the
* class — every CommonJS consumer regardless of subpath, and ESM consumers
* authoring from `@cipherstash/stack/wasm-inline` (see `isV3ColumnLike` in
* `src/column-map.ts` for which bundles carry which copy).
*
* Object literals, not the real classes: reproducing the split with the real
* ones needs a built `dist/`, and `vitest.shared.ts:4-14` keeps `pnpm test`
* free of that. The dist-level version lives in the portable-entry plan.
* free of that. `packages/stack/dist-types/wasm-inline-type-identity.ts` covers
* the dist-level TYPE half of the same hazard.
*/
export function wasmAuthoredV3Table(tableName: string, columnNames: string[]) {
const columnBuilders = Object.fromEntries(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,16 @@ describe('groupUnmodelledRows', () => {
})

describe('ColumnMap recognises v3 columns structurally, not by class identity', () => {
// tsup emits `EncryptedV3Column` TWICE — once into the chunk
// `dist/adapter-kit.js` imports, once inline in `dist/wasm-inline.js` (a
// separate esbuild run). A table authored from `@cipherstash/stack/wasm-inline`
// therefore failed `builder instanceof EncryptedV3Column` for EVERY column,
// leaving `v3Columns` empty — so the filter collector skipped every term and
// the RAW PLAINTEXT operand went into the PostgREST query string, while
// `::jsonb` casts and decryption kept working.
// tsup emits `EncryptedV3Column` into several bundles: ESM code-splits, so
// `dist/adapter-kit.js` and `dist/eql/v3/index.js` share one chunk but
// `dist/wasm-inline.js` (a separate esbuild run) does not; and CJS does not
// split at all, so `adapter-kit.cjs`, `eql/v3/index.cjs` and
// `encryption/v3.cjs` each define their own. Whenever the adapter and the
// schema resolved different copies — every CJS consumer, and ESM consumers
// authoring from wasm-inline — `builder instanceof EncryptedV3Column` failed
// for EVERY column, leaving `v3Columns` empty. The filter collector then
// skipped every term and the RAW PLAINTEXT operand went into the PostgREST
// query string, while `::jsonb` casts and decryption kept working.
//
// These two assert the MECHANISM (`v3Columns` is populated / not
// over-populated). The HARM — what PostgREST actually receives — is asserted
Expand Down Expand Up @@ -286,8 +289,8 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity',
build: () => ({ tableName: 'users', columns: {} }),
}

// Pin the SPECIFIC message, not just the `[supabase v3]` prefix: 32 errors
// across this package share that prefix, two of them thrown by `ColumnMap`
// Pin the SPECIFIC message, not just the `[supabase v3]` prefix: 40 errors
// across this package share that prefix, three of them thrown by `ColumnMap`
// itself. A prefix-only matcher stays green whenever a DIFFERENT one of
// those fires first — measured: with `assertNoPropertyDbNameCollision`
// throwing unconditionally, so the fail-closed probe below is never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,15 @@ describe('encryptedSupabaseV3 factory', () => {
databaseUrl: 'postgres://x',
schemas: { users },
}),
).rejects.toThrow(/text_eq|text_search/)
// Pin the domain-mismatch message from `verify.ts`, not just the domain
// names. `assertTableIsModelled` runs FIRST (`index.ts`, the same loop that
// then calls `verifyDeclaredSchemas`) and its message interpolates
// `public.${domainName}` too, so `/text_eq|text_search/` stayed green
// whichever of the two fired — exactly the prefix-only weakness pinned
// down in `supabase-schema-builder.test.ts`.
).rejects.toThrow(
/\[supabase v3\]: column "users\.email" has domain "eql_v3_text_search" but the schema declares "eql_v3_text_eq"/,
)
// ...and Encryption must never be reached.
expect(encryptionMock).not.toHaveBeenCalled()
})
Expand Down
31 changes: 21 additions & 10 deletions packages/stack-supabase/src/column-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,27 @@ export type V3ColumnLike = {
/**
* Whether a column builder is an EQL v3 column, checked STRUCTURALLY.
*
* NOT `instanceof EncryptedV3Column`. tsup emits that class twice — once into
* the chunk `dist/adapter-kit.js` imports, and once inline in
* `dist/wasm-inline.js`, a separate esbuild run
* (`packages/stack/tsup.config.ts:43-52`). A table authored with
* `encryptedTable`/`types` from `@cipherstash/stack/wasm-inline` is built from
* the second copy, so an `instanceof` against the first returned `false` for
* every column: `v3Columns` came out empty and the adapter treated encrypted
* columns as plaintext — filter operands reached PostgREST in the clear, while
* `::jsonb` casts and decryption kept working (they read `buildColumnKeyMap()`
* and the encrypt config, not this map).
* NOT `instanceof EncryptedV3Column`. tsup emits that class into more than one
* bundle, and which copy a caller gets depends on module format and entry:
*
* - **CJS does not code-split at all**, so every entry is self-contained:
* `dist/adapter-kit.cjs`, `dist/eql/v3/index.cjs` and `dist/encryption/v3.cjs`
* each define the class independently. A `require()`-based consumer hit this
* on ANY subpath, including `@cipherstash/stack/eql/v3`.
* - **ESM does code-split**, so `dist/adapter-kit.js` and `dist/eql/v3/index.js`
* share one chunk — but `dist/wasm-inline.js` is a separate esbuild run
* (`packages/stack/tsup.config.ts`) and carries its own copy, so an ESM
* consumer authoring from `@cipherstash/stack/wasm-inline` hit it too.
*
* Whenever the adapter and the schema resolved different copies, `instanceof`
* returned `false` for every column: `v3Columns` came out empty and the adapter
* treated encrypted columns as plaintext — filter operands reached PostgREST in
* the clear, while `::jsonb` casts and decryption kept working (they read
* `buildColumnKeyMap()` and the encrypt config, not this map).
*
* The same hazard has a type-level half, fixed separately in
* `packages/stack/tsup.config.ts` and gated by
* `packages/stack/dist-types/wasm-inline-type-identity.ts`.
*
* Mirrors `hasBuildColumnKeyMap` (`packages/stack/src/types.ts:276-283`), the
* repo's canonical answer to the same problem, used identically at
Expand Down
2 changes: 1 addition & 1 deletion packages/stack-supabase/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ async function construct(
// a caller's perspective unrelated to the mistake they made.
if (!hasBuildColumnKeyMap(table)) {
throw new Error(
`[supabase v3]: schemas entry "${key}" is an EQL v2 table — it has no buildColumnKeyMap(), the marker every v3 table carries. This adapter is EQL v3 only. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\`.`,
`[supabase v3]: schemas entry "${key}" is an EQL v2 table — it has no buildColumnKeyMap(), the marker every v3 table carries. This adapter is EQL v3 only. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\` or \`@cipherstash/stack/wasm-inline\`.`,
)
}
assertTableIsModelled(key, unmodelled)
Expand Down
24 changes: 19 additions & 5 deletions packages/stack/__tests__/logger-edge-safety.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* `@cipherstash/stack/adapter-kit` re-exports this package's `logger`
* (`src/adapter-kit.ts:60`), and three first-party adapters value-import
* (`src/adapter-kit.ts`), and three first-party adapters value-import
* adapter-kit: `packages/stack-supabase/src/column-map.ts:1`,
* `packages/stack-drizzle/src/column.ts:1`,
* `packages/stack-prisma/src/exports/column-types.ts:19`. A realm with no
Expand All @@ -13,9 +13,14 @@
*
* It reads `dist/`, so it SKIPS when the package has not been built — run
* `pnpm --filter @cipherstash/stack build` first for it to mean anything.
* (`turbo.json` wires `test` to `build`, so the turbo path cannot skip it; a
* bare `pnpm --filter … test` on a clean checkout still can.) The
* portable-entry plan will point the same harness at the WASM entry.
* `packages/stack/turbo.json` wires `test` to `build`, and the root `build` task
* declares `outputs: ["dist/**"]` so a cache hit restores the artefact rather
* than replaying logs over a missing one — without that, this gate reported
* `1 skipped` while the suite stayed green. A bare `pnpm --filter … test` on an
* unbuilt checkout can still skip, so CI turns that skip into a hard failure
* below.
*
* The portable-entry plan will point the same harness at the WASM entry.
*/
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
Expand All @@ -28,8 +33,17 @@ const execFileAsync = promisify(execFile)
const testsDir = fileURLToPath(new URL('.', import.meta.url))
const harness = resolve(testsDir, 'helpers/process-free-realm.mjs')
const emittedEntry = resolve(testsDir, '../dist/adapter-kit.js')
const isBuilt = existsSync(emittedEntry)

// A skip is an acceptable local convenience and an unacceptable CI result: the
// gate would report green having never run. Fail loudly instead.
if (!isBuilt && process.env.CI) {
throw new Error(
`${emittedEntry} is missing in CI — this gate cannot skip here. Run \`pnpm --filter @cipherstash/stack build\` before \`test\`.`,
)
}

describe.skipIf(!existsSync(emittedEntry))(
describe.skipIf(!isBuilt)(
'the emitted adapter-kit seam imports without a process global',
() => {
it('evaluates dist/adapter-kit.js in a process-free realm', async () => {
Expand Down
55 changes: 48 additions & 7 deletions packages/stack/dist-types/node16/wasm-inline.mts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* The THIRD declaration artifact: `dist/wasm-inline.d.ts`.
* The wasm-inline declaration artifact: `dist/wasm-inline.d.ts`.
*
* `tsup.config.ts` runs a second, independent DTS pass for the wasm-inline
* entry, which inlines its own copy of `EncryptedV3Column` and the helpers that
* invert its domain parameter. Neither the bundler gate nor the `.cts`/`.mts`
* probes above reach it — they resolve `./v3` and `./eql/v3`, which come from
* the first pass. So the entry documented for Workers, Deno, Bun and Supabase
* `tsup.config.ts` used to run a second, independent DTS pass for this entry,
* which inlined its own copy of `EncryptedV3Column` and the helpers that invert
* its domain parameter. Neither the bundler gate nor the `.cts`/`.mts` probes
* above reached it — they resolve `./v3` and `./eql/v3`, which come from the
* main pass. So the entry documented for Workers, Deno, Bun and Supabase
* Edge — the runtimes with the least margin for a broken type — was the one
* artifact nothing typechecked.
* artifact nothing typechecked. Its declarations now come from the main config
* so all entries share one set of chunks; `wasmTableCrossesEntries` below is
* what holds that in place.
*
* `./wasm-inline` is ESM-only in the `exports` map (no `require` branch, by
* design: the inlined WASM blob cannot be `require`d), hence `.mts` and no
Expand All @@ -19,6 +21,11 @@
* what collapses if the phantom carrier is lost on emit.
*/

import {
type AnyV3Table as NativeAnyV3Table,
encryptedTable as nativeEncryptedTable,
types as nativeTypes,
} from '@cipherstash/stack/eql/v3'
import {
type AnyV3Table,
type EncryptedTextSearchColumn,
Expand Down Expand Up @@ -57,6 +64,40 @@ const wasmConfig = {
clientKey: 'key',
}

/** The same table authored from the NATIVE entry, for the cross-entry probes. */
const nativeUsers = nativeEncryptedTable('users', {
email: nativeTypes.TextSearch('email'),
})

/**
* A wasm-inline-authored table must be the SAME type as an `./eql/v3` one.
*
* `EncryptedV3Column` carries `private readonly columnName`, and TypeScript
* compares classes with private members by declaration origin rather than
* structurally. While this entry got its own DTS pass it carried its own copy of
* the class, so this assignment failed with "Types have separate declarations of
* a private property 'columnName'" — and since every first-party adapter types
* its `schemas` in terms of `AnyV3Table`, a table authored here could not be
* passed to `encryptedSupabase`, the Drizzle helpers, or Prisma Next. The
* runtime accepted it (`isV3ColumnLike` probes structurally); only the compiler
* refused, which is why nothing but a gate over the emitted `.d.ts` caught it.
*
* The sibling `../wasm-inline-type-identity.ts` asserts the same identity under
* `moduleResolution: bundler` over relative paths. This one resolves both
* entries BY PACKAGE NAME through the `exports` map — the way a customer does.
*/
export const wasmTableCrossesEntries: NativeAnyV3Table = wasmUsers

/**
* ...and the reverse direction: an `./eql/v3`-authored table into this entry's
* `Encryption`. Asserted explicitly because the failure was symmetric — each
* entry rejected the other's schema — so a fix that only made one direction work
* would leave the shared-schema-module story broken and this gate green.
*/
export async function nativeTableIntoWasmClient() {
await WasmEncryption({ schemas: [nativeUsers], config: wasmConfig })
}

export async function wasmSchemaShapes() {
await WasmEncryption({ schemas: [wasmUsers], config: wasmConfig })

Expand Down
114 changes: 114 additions & 0 deletions packages/stack/dist-types/wasm-inline-type-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* The TYPE-level half of the two-copies-of-a-class hazard that `isV3ColumnLike`
* (`packages/stack-supabase/src/column-map.ts`) fixed at runtime.
*
* `EncryptedV3Column` carries `private readonly columnName`, and TypeScript
* compares classes with private members by DECLARATION ORIGIN, not
* structurally. So the moment two entries of this package ship two separately
* emitted declarations of that class, a table authored from one is a COMPILE
* error against the other — even though the runtime accepts it.
*
* That is what `tsup.config.ts` used to do: the wasm-inline config ran its own
* DTS pass, which inlined a private copy of every column class instead of
* sharing the `types-public-*.d.ts` chunk that `./eql/v3` and `./adapter-kit`
* both reference. A table authored with `encryptedTable`/`types` from
* `@cipherstash/stack/wasm-inline` therefore failed to typecheck against every
* first-party adapter's `schemas`:
*
* error TS2322: Type 'EncryptedV3Table<…>' is not assignable to type
* 'AnyV3Table'. Types have separate declarations of a private property
* 'columnName'.
*
* `wasm-inline` is the entry the edge examples use, so that was the published
* shape for Workers, Deno, Bun and Supabase Edge. `tsup.config.ts` now emits
* the wasm-inline declarations from the MAIN config so both entries resolve one
* declaration; give that config its own `dts` back, rebuild, and this file fails
* to compile.
*
* Lives here rather than in a `test-d`/vitest typecheck suite because those
* resolve `@cipherstash/stack/*` to `../stack/src` (see
* `packages/stack-supabase/tsconfig.json`) — source against source, which never
* sees how the subpaths resolve for an installed consumer. Only a gate that
* reads `dist/` can. This one uses `moduleResolution: bundler` over relative
* paths; `node16/wasm-inline.mts` asserts the same thing by package name through
* the `exports` map.
*/

import {
Encryption as NativeEncryption,
encryptedTable as nativeEncryptedTable,
types as nativeTypes,
} from '../dist/encryption/v3.js'
import type { AnyV3Table } from '../dist/eql/v3/index.js'
import {
Encryption as WasmEncryption,
encryptedTable as wasmEncryptedTable,
types as wasmTypes,
} from '../dist/wasm-inline.js'

const wasmUsers = wasmEncryptedTable('users', {
email: wasmTypes.TextSearch('email'),
amount: wasmTypes.IntegerOrd('amount'),
})

/**
* The assertion. Every first-party adapter types its `schemas` option in terms
* of `AnyV3Table`, so a wasm-inline-authored table that is not assignable here
* cannot be passed to `encryptedSupabase`, the Drizzle helpers, or Prisma Next.
*
* `@cipherstash/stack-supabase` is the concrete case, and this line is what
* replaced a text-level guard over it. `V3Schemas = Record<string, AnyV3Table>`
* (`packages/stack-supabase/src/schema-builder.ts:7`) imports `AnyV3Table` from
* `@cipherstash/stack/eql/v3` — the same declaration resolved here — so pinning
* assignability to it pins the adapter pairing too, without this package taking
* a build-graph dependency on one that depends on it.
*
* `scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs` used to grep
* shipped docs for a snippet pairing `encryptedSupabase` from the adapter's
* wasm-inline entry with `encryptedTable`/`types` from
* `@cipherstash/stack/wasm-inline`, on the grounds that it did not compile. It
* compiles now, and that is the point of this file — so the guard was deleted
* rather than reworded. A guard asserting a false claim is worse than no guard:
* it would have blocked the first person to write the example the `stash-edge`
* skill now recommends, citing a compiler error that no longer happens.
*/
export const wasmTableIsAV3Table: AnyV3Table = wasmUsers

/**
* The column class itself, not just the table wrapper.
*
* The table assignment above happens to surface the diagnostic today, but it
* does so through `columnBuilders`. Pinning a bare column too means the gate
* still fails if `AnyV3Table` is ever loosened to erase its column types.
*/
export const wasmColumnIsAV3Column: AnyV3Table['columnBuilders'][string] =
wasmTypes.TextSearch('email')

/**
* A schema module authored on either entry must build EITHER client.
*
* This is the shape the `stash-edge` skill documents: one `schema.ts` shared
* between a Node server and an Edge Function. Both directions are pinned because
* the failure was symmetric — each entry rejected the other's schema — so a fix
* that only worked one way would leave the shared-module story broken.
*
* `@cipherstash/stack/v3` is named explicitly rather than left to transitivity
* through `./eql/v3`: it is the entry the skills tell people to author against.
*/
const nativeUsers = nativeEncryptedTable('users', {
email: nativeTypes.TextSearch('email'),
})

export async function schemaModulesCrossEntries() {
await NativeEncryption({ schemas: [nativeUsers] })
await NativeEncryption({ schemas: [wasmUsers] })

const wasmConfig = {
workspaceCrn: 'crn',
accessKey: 'ak',
clientId: 'id',
clientKey: 'key',
}
await WasmEncryption({ schemas: [wasmUsers], config: wasmConfig })
await WasmEncryption({ schemas: [nativeUsers], config: wasmConfig })
}
Loading
Loading