diff --git a/.changeset/wasm-inline-type-identity.md b/.changeset/wasm-inline-type-identity.md new file mode 100644 index 000000000..06ec075e6 --- /dev/null +++ b/.changeset/wasm-inline-type-identity.md @@ -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. diff --git a/packages/stack-supabase/__tests__/helpers/supabase-mock.ts b/packages/stack-supabase/__tests__/helpers/supabase-mock.ts index 866a93283..27b96c781 100644 --- a/packages/stack-supabase/__tests__/helpers/supabase-mock.ts +++ b/packages/stack-supabase/__tests__/helpers/supabase-mock.ts @@ -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( diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index b35434041..694426851 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -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 @@ -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 diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index 148ac7d38..c7560012f 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -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() }) diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index ab0258ace..5c8eff0bb 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -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 diff --git a/packages/stack-supabase/src/create.ts b/packages/stack-supabase/src/create.ts index 8472eb2ca..14fd23ec4 100644 --- a/packages/stack-supabase/src/create.ts +++ b/packages/stack-supabase/src/create.ts @@ -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) diff --git a/packages/stack/__tests__/logger-edge-safety.test.ts b/packages/stack/__tests__/logger-edge-safety.test.ts index f6f441c67..7b4b965b2 100644 --- a/packages/stack/__tests__/logger-edge-safety.test.ts +++ b/packages/stack/__tests__/logger-edge-safety.test.ts @@ -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 @@ -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' @@ -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 () => { diff --git a/packages/stack/dist-types/node16/wasm-inline.mts b/packages/stack/dist-types/node16/wasm-inline.mts index 632f07e19..b0ca15a80 100644 --- a/packages/stack/dist-types/node16/wasm-inline.mts +++ b/packages/stack/dist-types/node16/wasm-inline.mts @@ -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 @@ -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, @@ -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 }) diff --git a/packages/stack/dist-types/wasm-inline-type-identity.ts b/packages/stack/dist-types/wasm-inline-type-identity.ts new file mode 100644 index 000000000..feef593f1 --- /dev/null +++ b/packages/stack/dist-types/wasm-inline-type-identity.ts @@ -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` + * (`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 }) +} diff --git a/packages/stack/src/adapter-kit.ts b/packages/stack/src/adapter-kit.ts index 7376e9af7..630544759 100644 --- a/packages/stack/src/adapter-kit.ts +++ b/packages/stack/src/adapter-kit.ts @@ -28,6 +28,16 @@ export { export type { AuditConfig } from './encryption/operations/base-operation.js' // v3 column model + the date-like cast set the Supabase builder uses to // reconstruct `Date` values from PostgREST select aliases. +// +// `EncryptedV3Column` is exported as a VALUE for generic constraints and +// subclassing only. Never `instanceof` it. tsup emits the class into several +// bundles — CJS does not code-split, so every `.cjs` entry gets its own copy, +// and `wasm-inline` is a separate esbuild run with a third — so an identity +// check silently fails for any consumer who resolved a different copy. That is +// how encrypted filter operands once reached PostgREST in plaintext. Probe +// structurally instead, as `isV3ColumnLike` +// (`packages/stack-supabase/src/column-map.ts`) and `hasBuildColumnKeyMap` +// (`src/types.ts`) do. export { type AnyEncryptedV3Column, DATE_LIKE_CASTS, diff --git a/packages/stack/src/utils/logger/index.ts b/packages/stack/src/utils/logger/index.ts index 0d54ead7c..875f02aa7 100644 --- a/packages/stack/src/utils/logger/index.ts +++ b/packages/stack/src/utils/logger/index.ts @@ -15,11 +15,18 @@ const validLevels: readonly LogLevel[] = ['debug', 'info', 'error'] as const function levelFromEnv(): LogLevel { // `process` is absent in a Worker or Deno isolate. This module is reachable - // from `@cipherstash/stack/adapter-kit` (`src/adapter-kit.ts:60`), which the - // Supabase, Drizzle and Prisma Next adapters all value-import — an unguarded - // read here is a ReferenceError at import time on those runtimes. Guard - // `process.env` too: some partial polyfills define `process` without `env`, - // where `process.env.STASH_STACK_LOG` would throw just the same. + // from `@cipherstash/stack/adapter-kit` (which re-exports `logger`), and the + // Supabase, Drizzle and Prisma Next adapters all value-import that entry — an + // unguarded read here is a ReferenceError at import time on those runtimes. + // + // The `!process.env` half only keeps THIS read safe; it does not make a + // partial polyfill (`globalThis.process = {}`) survive import. `initStackLogger()` + // runs at module scope below and calls evlog's `initLogger`, whose + // `detectEnvironment()` and `isDev()` guard `typeof process` but then read + // `process.env.NODE_ENV` — a TypeError under exactly that shape. So the guard + // is cheap local correctness, not support for partial polyfills; supporting + // those needs the evlog call site guarded too, and a `process: {}` case added + // to `__tests__/helpers/process-free-realm.mjs`. const env = typeof process === 'undefined' || !process.env ? undefined diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 90382b3f3..cd2b54c65 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -5,24 +5,51 @@ import { defineConfig } from 'tsup' // otherwise wipe another config's output. The pre-tsup `rimraf dist` // in `package.json`'s build script clears the dir once before any // starts. + +// The dual-format entries. Named so the `dts` list below can be derived from it +// rather than repeated — a second hand-maintained copy would silently drop the +// types for any subpath added to one list and not the other. +const mainEntry = [ + 'src/index.ts', + 'src/types-public.ts', + 'src/identity/index.ts', + 'src/schema/index.ts', + 'src/eql/v3/index.ts', + 'src/dynamodb/index.ts', + 'src/encryption/index.ts', + 'src/encryption/v3.ts', + 'src/errors/index.ts', + 'src/adapter-kit.ts', +] + export default defineConfig([ // Main entries — dual ESM + CJS bundles. { - entry: [ - 'src/index.ts', - 'src/types-public.ts', - 'src/identity/index.ts', - 'src/schema/index.ts', - 'src/eql/v3/index.ts', - 'src/dynamodb/index.ts', - 'src/encryption/index.ts', - 'src/encryption/v3.ts', - 'src/errors/index.ts', - 'src/adapter-kit.ts', - ], + entry: mainEntry, format: ['cjs', 'esm'], sourcemap: true, - dts: true, + // `wasm-inline` is listed here for TYPES ONLY — its JS is emitted by the + // ESM-only config below, which sets `dts: false`. The two configs are + // separate rollup runs, so a `dts` built down there gets its own inlined + // copy of every column class, and `EncryptedV3Column` carries `private` + // members, which TypeScript compares by declaration origin rather than + // structurally. Two copies meant a table authored from + // `@cipherstash/stack/wasm-inline` was a COMPILE error against every + // first-party adapter's `schemas` ("Types have separate declarations of a + // private property 'columnName'"), even though the runtime accepted it. + // Emitting these types here shares the `types-public-*.d.ts` chunk with + // `./eql/v3` and `./adapter-kit`, so every entry yields one declaration. + // This is the type-level half of the same two-copies-of-a-class hazard + // `isV3ColumnLike` fixed at runtime; `dist-types/wasm-inline-type-identity.ts` + // and `dist-types/node16/wasm-inline.mts` hold it in place. + // + // Side effect: this config is dual-format, so the wasm-inline entry now also + // gets a `dist/wasm-inline.d.cts`. Nothing resolves it — `./wasm-inline` has + // no `require` branch in `exports` and there is no `wasm-inline.cjs` for it + // to describe. It is unreferenced ballast in the tarball, accepted because + // tsup cannot scope a `dts` entry to one format, and one shared declaration + // is worth more than the bytes. + dts: { entry: [...mainEntry, 'src/wasm-inline.ts'] }, clean: false, target: 'es2022', tsconfig: './tsconfig.json', @@ -44,7 +71,11 @@ export default defineConfig([ entry: { 'wasm-inline': 'src/wasm-inline.ts' }, format: ['esm'], sourcemap: true, - dts: { entry: { 'wasm-inline': 'src/wasm-inline.ts' } }, + // Types come from the main config above so they share its chunks — see the + // comment there. Emitting them here as well would restore the second copy + // of every column class and re-break the `wasm-inline` authoring path for + // TypeScript consumers. JS emission stays here: only types moved. + dts: false, clean: false, target: 'es2022', tsconfig: './tsconfig.json', diff --git a/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs b/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs deleted file mode 100644 index 72976b6c9..000000000 --- a/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { packageReadmePathspecs } from './lib/package-readmes.mjs' -import { REPO_ROOT } from './lib/repo-root.mjs' - -/** - * A schema authored from `@cipherstash/stack/wasm-inline` cannot be handed to - * `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline`. - * - * `@cipherstash/stack/wasm-inline` is a separate tsup dts bundle. It - * re-declares its own `EncryptedV3Column` / `EncryptedTextSearchColumn` - * classes, each carrying a `private readonly columnName`, and TypeScript - * compares classes with private fields NOMINALLY. The adapter types its - * `schemas` option as `Record` imported from - * `@cipherstash/stack/eql/v3` (`packages/stack-supabase/src/schema-builder.ts`), - * so pairing the two entries is a hard `tsc --strict` error: - * - * error TS2322: Type 'EncryptedTable<...>' is not assignable to type 'AnyV3Table'. - * ... Types have separate declarations of a private property 'columnName'. - * - * The rule is therefore NOT "edge project, edge entry, everywhere". It is: - * author the schema against the entry whose CLIENT TYPE consumes it. The raw - * `Encryption` client from `wasm-inline` consumes `wasm-inline` tables; - * `encryptedSupabase` consumes `eql/v3` tables on BOTH its entries, WASM engine - * or not. - * - * Nothing catches the pairing for us: - * - * - Nothing type-checks a SKILL.md or a README, and these are shipped text — - * `skills/` rides inside the `stash` npm tarball and `stash init` copies it - * into the customer's own repository, where their coding agent reads it as - * instruction. The drift lands in someone else's build, not in ours. - * - Runtime is unaffected, which is why it drifted silently in the first - * place: `packages/stack-supabase/src/column-map.ts` deliberately probes for - * v3 columns STRUCTURALLY rather than with `instanceof`, precisely because - * tsup emits the class twice. Copy-pasting the bad snippet produces working - * code that will not compile. - * - `e2e/wasm/deno.json` runs `deno test --no-check`, so the repo's own edge - * e2e would not report it either. - */ - -/** Modules and names that must not co-occur inside one TypeScript block. */ -const ADAPTER_MODULE = '@cipherstash/stack-supabase/wasm-inline' -const ADAPTER_NAMES = ['encryptedSupabase', 'encryptedSupabaseV3'] -const SCHEMA_MODULE = '@cipherstash/stack/wasm-inline' -const SCHEMA_NAMES = ['encryptedTable', 'types'] - -/** - * Files whose contents are SHIPPED — published to npm, copied into a user's - * repo, or written there by `stash init`. Deliberately not the whole tree: - * CHANGELOGs and `docs/**` are historical records, and rewriting history to - * appease a lint is worse than the drift it prevents. - */ -// `:(glob)` magic so `*` stops at a path separator — without it git's default -// wildmatch crosses `/` and sweeps in files a level deeper. -const SHIPPED_GLOBS = [ - ':(glob)skills/*/SKILL.md', - // Derived, not written down: two package roots sit deeper than one level and - // `:(glob)` does not cross `/`. See `lib/package-readmes.mjs`. - ...packageReadmePathspecs(), - 'README.md', - 'AGENTS.md', -] - -/** Tracked files matching the shipped globs, via git so it honours .gitignore. */ -function shippedFiles() { - const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], { - cwd: REPO_ROOT, - encoding: 'utf8', - }) - return out.split('\0').filter(Boolean) -} - -/** - * Fenced TypeScript blocks, as `{ line, body }`. - * - * Per BLOCK, not per file: a document may legitimately import - * `@cipherstash/stack/wasm-inline` in one snippet (the raw edge client, which - * really does want its own tables) and construct `encryptedSupabase` in - * another. Only the two appearing in the same snippet is the defect. - */ -function typescriptBlocks(body) { - const blocks = [] - const fence = /^```(ts|typescript)[^\n]*\n([\s\S]*?)^```/gm - for (const match of body.matchAll(fence)) { - blocks.push({ - line: body.slice(0, match.index).split('\n').length, - body: match[2], - }) - } - return blocks -} - -/** Named imports in one block, as `{ module, names }`. */ -function namedImports(block) { - const imports = [] - const stmt = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g - for (const match of block.matchAll(stmt)) { - imports.push({ - module: match[2], - names: match[1] - .split(',') - .map((name) => - name - .trim() - .split(/\s+as\s+/)[0] - .trim(), - ) - .filter(Boolean), - }) - } - return imports -} - -/** Does this block import any of `names` from `module`? */ -function importsAny(imports, module, names) { - return imports.some( - (imported) => - imported.module === module && - imported.names.some((name) => names.includes(name)), - ) -} - -describe('supabase edge snippets author schemas from @cipherstash/stack/eql/v3', () => { - const files = shippedFiles() - - it('finds the shipped file set (guards against a silently-empty glob)', () => { - expect(files.length).toBeGreaterThan(5) - expect(files).toContain('skills/stash-supabase/SKILL.md') - expect(files).toContain('skills/stash-managed-platforms/SKILL.md') - expect(files).toContain('skills/stash-edge/SKILL.md') - expect(files).toContain('packages/stack-supabase/README.md') - }) - - it.each(files)('%s', (file) => { - const body = readFileSync(resolve(REPO_ROOT, file), 'utf8') - const offenders = typescriptBlocks(body) - .filter((block) => { - const imports = namedImports(block.body) - return ( - importsAny(imports, ADAPTER_MODULE, ADAPTER_NAMES) && - importsAny(imports, SCHEMA_MODULE, SCHEMA_NAMES) - ) - }) - .map((block) => `${file}:${block.line}`) - - expect( - offenders, - `${offenders.join(', ')} pairs \`encryptedSupabase\` from ${ADAPTER_MODULE} with a schema ` + - `authored from ${SCHEMA_MODULE}. That does not compile: the adapter's \`schemas\` option is ` + - "typed from `@cipherstash/stack/eql/v3`, and the two entries' column classes carry private " + - 'fields TypeScript compares nominally (TS2322, "separate declarations of a private property ' + - "'columnName'\"). Import `encryptedTable` and `types` from `@cipherstash/stack/eql/v3` — the " + - 'engine stays WASM either way.', - ).toEqual([]) - }) -}) diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md index 8b84367e4..81ddbfe6c 100644 --- a/skills/stash-edge/SKILL.md +++ b/skills/stash-edge/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-edge -description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, which `CS_*` variables are mandatory and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, why the entry is server-side only and never belongs in a browser bundle, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. +description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, which `CS_*` variables are mandatory and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, why the entry is server-side only and never belongs in a browser bundle, and how one EQL v3 schema module is shared across both entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. --- # Encryption on the Edge (WASM entry) @@ -46,11 +46,11 @@ together. `@cipherstash/stack-supabase/wasm-inline` (not the package root, which pulls the native engine) and **declare your `schemas`** — the adapter's default behaviour is to introspect the database for its column config, which needs a -Postgres connection. Declaring skips it. Those `schemas` are authored from -`@cipherstash/stack/eql/v3`, not from `@cipherstash/stack/wasm-inline` — the -one place the "use the edge entry for everything" reflex is wrong, and it -fails at `tsc`, not at runtime. See "Schema Modules Do Not Cross Entries" -below, plus `stash-supabase` and `stash-managed-platforms`. +Postgres connection. Declaring skips it. Those `schemas` can be authored from +either `@cipherstash/stack/eql/v3` or `@cipherstash/stack/wasm-inline` — both +entries resolve one declaration of the column classes, so the tables are +interchangeable. See "Schema Modules Cross Entries" below, plus +`stash-supabase` and `stash-managed-platforms`. **`@cipherstash/protect` is not one of the options.** It is the deprecated predecessor of `@cipherstash/stack`; its native `@cipherstash/protect-ffi` @@ -261,7 +261,7 @@ Available: `encrypt`, `decrypt`, `isEncrypted`, `encryptQuery`, | | Native (`@cipherstash/stack`) | WASM (`@cipherstash/stack/wasm-inline`) | |---|---|---| | Factory | `Encryption({ schemas })` | `Encryption({ schemas, config })` — same name, different module | -| Schema authoring | `encryptedTable` / `types` from `@cipherstash/stack/v3` | the entry's own re-exports (see below) | +| Schema authoring | `encryptedTable` / `types` from `@cipherstash/stack/v3` | the entry's own re-exports — interchangeable with the native ones (see below) | | Config | discovered from env / `~/.cipherstash` | passed explicitly — `clientId` + `clientKey`, then either `workspaceCrn` + `accessKey` or a pre-built `authStrategy` (see below) | | Typing | signatures derived from the schema | schema-aware, but not the full typed client | | `.audit()` | chainable on operations | **not available** | @@ -378,33 +378,15 @@ if (rows.failure) throw new Error(rows.failure.message) ``` A wrapper written against the native signature will therefore compile against -one entry and break on the other — one more reason to author against exactly -one entry (see below). +one entry and break on the other. This is a *client*-surface difference — the +schema module itself is shareable (see below); wrapper code is not. -## Schema Modules Do Not Cross Entries +## Schema Modules Cross Entries -A schema authored with `@cipherstash/stack/v3` **will not typecheck** -against the WASM entry's `Encryption`, and the reverse fails too: - -```text -Type 'EncryptedTextSearchColumn' is not assignable to type 'AnyEncryptedV3Column'. - Types have separate declarations of a private property 'columnName'. -``` - -The two entries ship independent type bundles, and the column classes carry -private fields — which TypeScript compares **nominally**. The declarations are -identical in shape but not the same declaration, so assignment is rejected in -both directions. - -It works fine at runtime, which is the trap: the tempting fix is -`as never` / `as any` on the schema, which silences a real signal and will -keep silencing it after a genuine schema mismatch appears. - -**Author the schema module against the entry whose CLIENT TYPE consumes it.** -Not "the entry your runtime uses" — the WASM engine is not what decides this, -the type of the thing you hand the schema to is. For a project that builds a -raw `Encryption` client from `@cipherstash/stack/wasm-inline`, that entry is -also where `encryptedTable` and `types` come from: +**One schema module serves both entries.** A table authored with +`encryptedTable`/`types` from `@cipherstash/stack/v3` builds the WASM entry's +`Encryption`, and one authored from `@cipherstash/stack/wasm-inline` builds the +native `Encryption` — same types, same runtime, both directions: ```ts // schema.ts — the single source of truth for this project's schema @@ -416,35 +398,56 @@ export const users = encryptedTable('users', { }) ``` -Node-side code that imports this module must then also build its client from -`@cipherstash/stack/wasm-inline` (which runs on Node perfectly well, just with -the WASM engine rather than the native one) and must be ESM. +Author it against whichever entry the schema module's own runtime needs — the +wasm-inline entry if that module is itself imported by the Edge Function — and +pass the result to either client. -If a project genuinely needs the native client on the server *and* the WASM -client on the edge, keep two schema modules and treat their agreement as -something to test, not something the type system will enforce for you. Column -names and domains must match exactly — they are what the database and the -stored payload's `i` identifier are keyed by. +> **On older `@cipherstash/stack` versions this did not typecheck.** The two +> entries shipped separately-emitted declarations of the column classes, and +> those classes carry `private` fields, which TypeScript compares **nominally** — +> so each entry rejected the other's schema in both directions: +> +> ```text +> Type 'EncryptedTextSearchColumn' is not assignable to type 'AnyEncryptedV3Column'. +> Types have separate declarations of a private property 'columnName'. +> ``` +> +> The runtime was never affected, which was the trap: `as never` / `as any` on +> the schema looked like the fix while silencing a signal that would matter after +> a genuine schema mismatch. If you see this diagnostic, upgrade rather than +> assert — or, on a version you cannot move off, author the schema module against +> exactly one entry and build only that entry's client from it. -### The exception: `@cipherstash/stack-supabase/wasm-inline` +What still does **not** cross is the *client* surface: the two entries' clients +differ in the ways listed above (the `bulkDecryptModels` signature, config +shape). A helper written against one client's signatures will not compile against +the other, so keep wrapper code entry-specific even though the schema is shared. + +### `@cipherstash/stack-supabase/wasm-inline` The Supabase adapter's edge entry runs the WASM engine but types its `schemas` -option from `@cipherstash/stack/eql/v3` — the same declaration its native -entry uses. So a Supabase edge project authors its schema module from -`eql/v3`, **not** from `@cipherstash/stack/wasm-inline`: +option from `@cipherstash/stack/eql/v3`. That used to make it a special case — +authoring the schema from `@cipherstash/stack/wasm-inline` was rejected there, +reported one level up as `schemas` not assignable to `AnyV3Table`. It is no +longer: every entry now resolves one declaration of the column classes, so +either import works and both examples below are correct. ```ts -// The engine is still WASM. Only the schema's declaration site differs. +// Both of these compile. The engine is WASM either way. import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' ``` -Get this one backwards and you hit the same nominal-private-field rejection, -reported one level up — `schemas` not assignable to `AnyV3Table`, because the -column classes inside it carry a private `columnName` from the other entry's -declarations. Which way round it goes is a property of the client type, so -check what consumes the schema before you pick the import. `stash-supabase` -and `stash-managed-platforms` carry the full edge call shape. +```ts +// The same, authored from the edge entry — useful when this module is also +// imported by the Edge Function, so one table definition serves both sides. +import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' +import { encryptedTable, types } from '@cipherstash/stack/wasm-inline' +``` + +On a version predating that fix the old rule still applies: author from +`eql/v3` for this adapter. `stash-supabase` and `stash-managed-platforms` +carry the full edge call shape. ## Querying from the Edge diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 7bd477031..d06b4390b 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -522,8 +522,10 @@ for (const item of decrypted.data) { > [!IMPORTANT] > The `client` below is a **different client** from the one used everywhere else in this skill. The edge entry has its own `Encryption` factory — the native `Encryption` client's `bulkEncrypt` takes `(plaintexts, { table, column })` and will fail at runtime if given the per-item shape below. Construct the WASM client explicitly: -> [!IMPORTANT] -> **The schema is not shareable between entries either.** Note that `encryptedTable` and `types` are imported *from the WASM entry* below, not from `@cipherstash/stack/eql/v3`. The entries ship independent type bundles whose column classes carry private fields, so TypeScript compares them **nominally**: a schema authored on one entry is rejected by the other's client, in both directions (`Types have separate declarations of a private property 'columnName'`). It works at runtime, which makes `as any` the tempting fix — don't. Author the shared schema module against exactly one entry and build that entry's client from it. See the `stash-edge` skill. +> [!NOTE] +> **The schema, unlike the client, IS shareable between entries.** `encryptedTable` and `types` are imported from the WASM entry below only because this example's own runtime is the edge; a table authored from `@cipherstash/stack/eql/v3` or `@cipherstash/stack/v3` works here just as well, and in the other direction too. One `schema.ts` can serve a Node server and an Edge Function. +> +> On older `@cipherstash/stack` versions it could not: the entries shipped separately-emitted declarations of the column classes, which carry private fields, so TypeScript compared them **nominally** and each entry rejected the other's schema (`Types have separate declarations of a private property 'columnName'`). It always worked at runtime, which made `as any` the tempting fix — if you hit that diagnostic, upgrade rather than assert. See the `stash-edge` skill. ```typescript // Deno / Workers / Supabase Edge Functions — note the import path diff --git a/skills/stash-managed-platforms/SKILL.md b/skills/stash-managed-platforms/SKILL.md index 799d2f08e..264940598 100644 --- a/skills/stash-managed-platforms/SKILL.md +++ b/skills/stash-managed-platforms/SKILL.md @@ -14,7 +14,7 @@ If you are on Lovable, v0, Bolt, Replit, or a similar hosted builder, and you ha An agent working on one of these platforms previously spent a full turn concluding CipherStash was impossible in a Lovable project before finding `stash` and the WASM entry. Nothing about that conclusion was true. Everything below is the rest of what that turn had to discover. -The `stash-edge` skill is the full guide to the WASM entry (per-runtime import specifiers, the client surface, why schema modules cannot be shared across entries). This page covers what is specific to *managed* platforms. +The `stash-edge` skill is the full guide to the WASM entry (per-runtime import specifiers, the client surface, how one schema module is shared across both entries). This page covers what is specific to *managed* platforms. ## When to Use This Skill @@ -156,9 +156,9 @@ By default `encryptedSupabase` derives every column's encryption config by intro ```typescript import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' -// Schemas come from `eql/v3`, NOT `@cipherstash/stack/wasm-inline` — the -// adapter types `schemas` from that entry and the two entries' column classes -// do not cross. The engine is still WASM. +// Either entry may author these: `@cipherstash/stack/eql/v3` and +// `@cipherstash/stack/wasm-inline` resolve one declaration of the column +// classes, so `schemas` accepts both. The engine is WASM either way. import { encryptedTable, types } from '@cipherstash/stack/eql/v3' const users = encryptedTable('users', { @@ -176,7 +176,7 @@ const supabase = await encryptedSupabase(supabaseClient, { 1. **The entry.** Import from `@cipherstash/stack-supabase/wasm-inline`, not the package root. The root statically imports the native engine, which loads on import whether or not you encrypt anything. 2. **The schemas.** Without them the wrapper still wants a connection. -3. **Where the schemas come from.** `encryptedTable` and `types` for the adapter come from `@cipherstash/stack/eql/v3` on **both** its entries — the adapter types `schemas` from that entry, and `@cipherstash/stack/wasm-inline` re-declares the same column classes with private fields TypeScript compares nominally. Author them from the WASM entry and `tsc` rejects the `schemas` object ("separate declarations of a private property `columnName`") while the code runs fine, so nothing but a typecheck tells you. Only a **raw** `Encryption` client from `@cipherstash/stack/wasm-inline` wants tables authored from that entry. +3. **Where the schemas come from.** `encryptedTable` and `types` may come from either `@cipherstash/stack/eql/v3` or `@cipherstash/stack/wasm-inline` — both entries resolve one declaration of the column classes, so the adapter's `schemas` accepts tables from either. On a version of `@cipherstash/stack` predating that fix they were separate declarations, and `tsc` rejected a wasm-inline-authored table with "separate declarations of a private property `columnName`" while the code ran fine; if you see that diagnostic, upgrade, or author from `eql/v3` on that version. What declared mode gives up: diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 55c1c785a..81969d385 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -373,6 +373,20 @@ const es = await encryptedSupabase(supabaseUrl, supabaseKey, { await es.from("users").select("id, email").eq("email", "a@b.com") ``` +**Either entry may author the table.** `encryptedTable`/`types` from +`@cipherstash/stack/wasm-inline` produce tables `schemas` accepts exactly as the +`@cipherstash/stack/eql/v3` ones above do — same types, same runtime. Use the +wasm-inline entry when the schema module is shared with an Edge Function, so one +table definition serves both sides instead of two that can drift. + +> If `tsc` rejects a wasm-inline-authored table with *"Types have separate +> declarations of a private property 'columnName'"*, the installed +> `@cipherstash/stack` predates this fix — its two entries shipped +> separately-emitted declarations of the column class, and TypeScript compares +> classes with `private` members by declaration origin. Upgrade, or author the +> table from `@cipherstash/stack/eql/v3` on that version. The runtime was never +> affected. + Four differences from the native entry, three of them enforced by the type checker: @@ -996,12 +1010,28 @@ Passing a v2 table in `schemas` is rejected by name: ``` [supabase v3]: schemas entry "users" is an EQL v2 table — it has no -buildColumnKeyMap(), the marker every v3 table carries. +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`. ``` A v2 `encryptedTable` is structurally identical to a v3 one apart from that -marker, so TypeScript alone will not always catch the swap — re-author the table -with `encryptedTable`/`types` from `@cipherstash/stack/eql/v3`. +marker, so TypeScript alone will not always catch the swap. + +A related error names a single column rather than the table: + +``` +[supabase v3]: column "email" on table "users" is not a recognised EQL v3 +column builder. Its filter operands would otherwise be sent to PostgREST +unencrypted, so construction is refused. +``` + +That one means the table itself looks like v3 but one of its column builders +does not present the v3 surface — a v2 `encryptedColumn(...)` left behind in an +otherwise-migrated table, or a hand-rolled stub. The adapter **fails closed** +and throws at construction rather than treating the column as plaintext, because +an unrecognised column would send its filter operands to PostgREST in the clear. +Re-author that column with the matching `types.*` factory. Existing v2 deployments should add an `eql_v3_*` twin column and run the rollout in "Migrating an Existing Column to Encrypted" above. Current `stash` releases