From c1bf387150b26fcb587f033e8f26ef1ae6948822 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 30 Jul 2026 14:07:55 +1000 Subject: [PATCH 1/2] fix(stack): emit wasm-inline types from the main config so entries share one class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/stack/tsup.config.ts` ran a second, independent declaration pass for the `wasm-inline` entry. Two rollup runs meant two separately-emitted copies of `EncryptedV3Column`, which carries `private readonly columnName` — and TypeScript compares classes with private members by DECLARATION ORIGIN, not structurally. So a table authored with `encryptedTable`/`types` from `@cipherstash/stack/wasm-inline` was a COMPILE error against every first-party adapter's `schemas` and against the native `Encryption`, and native-authored tables were rejected by the WASM `Encryption` — symmetrically: error TS2322: Types have separate declarations of a private property 'columnName'. The runtime always accepted these tables (`hasBuildColumnKeyMap` / `isV3ColumnLike` probe structurally), so only the compiler refused, which made `as any` the tempting workaround. `wasm-inline` is the entry the edge examples use, so this was the published shape for Workers, Deno, Bun and Supabase Edge. The main config's `dts` now lists `src/wasm-inline.ts` and the wasm-inline config sets `dts: false`, so all entries share one `types-public-*.d.ts` chunk. The dts list is derived from the entry list rather than repeated, so a new subpath cannot lose its types. JS emission is untouched: `./wasm-inline` keeps its ESM-only shape and no `require` branch. Gates, one per resolution mode, both over the built `.d.ts`: - `dist-types/wasm-inline-type-identity.ts` — `moduleResolution: bundler` - `dist-types/node16/wasm-inline.mts` — Node16, by package name via `exports` Both pin all four cross-entry directions. Source-only type tests structurally cannot catch this: they map the subpaths to `src` and compare source to source. `test:types:dist` is already wired into CI and cannot skip. Comment corrections — the previous explanation was too narrow. CJS does not code-split at all, so `dist/adapter-kit.cjs`, `dist/eql/v3/index.cjs` and `dist/encryption/v3.cjs` each define the class independently and a `require()` consumer hit the RUNTIME bug on any subpath, not just via wasm-inline. Verified against the build output. Updated `stack-supabase/src/column-map.ts`, `stack/src/adapter-kit.ts`, the supabase tests, and the pending changeset. The logger comment overclaimed: the `!process.env` guard keeps only that one read safe. `initStackLogger()` runs at module scope and evlog's `detectEnvironment()` and `isDev()` guard `typeof process` but then read `process.env.NODE_ENV`, which throws under `globalThis.process = {}`. Verified in evlog 1.11.0; comment now scoped honestly. `logger-edge-safety.test.ts` turns a skip into a hard failure under CI — a skip is local convenience and a green gate that never ran. Skills: `stash-edge` documented "Schema Modules Do Not Cross Entries" and `stash-encryption` "the schema is not shareable between entries" as permanent behaviour. This fix makes both false, so they now describe the shared-schema story and keep the diagnostic as an upgrade signal. `stash-supabase` documents both authoring entries and the column-level fail-closed error. --- .changeset/wasm-inline-type-identity.md | 18 ++++ .../__tests__/helpers/supabase-mock.ts | 11 ++- .../__tests__/supabase-schema-builder.test.ts | 21 ++-- .../__tests__/supabase-v3-factory.test.ts | 10 +- packages/stack-supabase/src/column-map.ts | 31 ++++-- packages/stack-supabase/src/create.ts | 2 +- .../__tests__/logger-edge-safety.test.ts | 24 ++++- .../stack/dist-types/node16/wasm-inline.mts | 55 +++++++++-- .../dist-types/wasm-inline-type-identity.ts | 98 ++++++++++++++++++ packages/stack/src/adapter-kit.ts | 10 ++ packages/stack/src/utils/logger/index.ts | 17 +++- packages/stack/tsup.config.ts | 59 ++++++++--- skills/stash-edge/SKILL.md | 99 +++++++++---------- skills/stash-encryption/SKILL.md | 6 +- skills/stash-managed-platforms/SKILL.md | 10 +- skills/stash-supabase/SKILL.md | 36 ++++++- 16 files changed, 390 insertions(+), 117 deletions(-) create mode 100644 .changeset/wasm-inline-type-identity.md create mode 100644 packages/stack/dist-types/wasm-inline-type-identity.ts 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..1aec8e872 --- /dev/null +++ b/packages/stack/dist-types/wasm-inline-type-identity.ts @@ -0,0 +1,98 @@ +/** + * 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. + */ +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/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md index 8b84367e4..02e09bf57 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,50 @@ 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. + +> **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. -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. +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. -### The exception: `@cipherstash/stack-supabase/wasm-inline` +### `@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. +// The engine is WASM either way. The schema's declaration site no longer matters. import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +// …or from '@cipherstash/stack/wasm-inline' — same classes, same result. ``` -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. +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 From c034508d747e75548878a8af166f83a1a25fd99b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 2 Sep 2026 15:51:03 +1000 Subject: [PATCH 2/2] test(scripts),docs(skills): retire the guard this fix falsifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs` (#951) failed any shipped document whose snippet paired `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline` with `encryptedTable`/`types` from `@cipherstash/stack/wasm-inline`, on the stated grounds that the pairing "does not compile: ... TS2322, separate declarations of a private property 'columnName'". That sentence is false as of the previous commit. Making the pairing compile is the whole point of it, `dist-types/wasm-inline-type-identity.ts` now asserts it compiles, and `stash-supabase` actively recommends it. The guard passed only because no fence literally showed the pairing — `stash-edge` smuggled it in as a trailing comment, which is a document dodging its own lint. The first person to write the example the skill recommends would have been blocked by a guard citing a compiler error that no longer happens. Deleted rather than reworded. The obvious repurposing — assert the docs carry the version caveat — guards prose phrasing, which drifts by legitimate rewording rather than by defect, and it could only ever fire in a world where the compile property is already broken. `wasm-inline-type-identity.ts` catches that first, in the codebase, by type-checking rather than text-matching. The property the guard protected is now held there, in the correct direction: the adapter types `schemas` as `Record` with `AnyV3Table` from `@cipherstash/stack/eql/v3` (`schema-builder.ts:7`), which is the same declaration the gate asserts assignability to. Recorded in that file's comment so the reason the guard went lives where the property is kept, rather than only in this message. Asserting through `encryptedSupabase` itself was considered and rejected: it would give `packages/stack`'s declaration gate a build-graph dependency on a package that depends on it. `lib/package-readmes.mjs` keeps three other consumers, so nothing is orphaned. `stash-edge` now shows the wasm-inline pairing as a real fence beside the `eql/v3` one, which is what the smuggled comment was avoiding. Refs #963 Claude-Session: https://claude.ai/code/session_01HAonmKdSJMxYZcuRCiZPXE --- .../dist-types/wasm-inline-type-identity.ts | 16 ++ ...skills-supabase-edge-schema-entry.test.mjs | 159 ------------------ skills/stash-edge/SKILL.md | 10 +- 3 files changed, 24 insertions(+), 161 deletions(-) delete mode 100644 scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs diff --git a/packages/stack/dist-types/wasm-inline-type-identity.ts b/packages/stack/dist-types/wasm-inline-type-identity.ts index 1aec8e872..feef593f1 100644 --- a/packages/stack/dist-types/wasm-inline-type-identity.ts +++ b/packages/stack/dist-types/wasm-inline-type-identity.ts @@ -55,6 +55,22 @@ const wasmUsers = wasmEncryptedTable('users', { * 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 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 02e09bf57..81ddbfe6c 100644 --- a/skills/stash-edge/SKILL.md +++ b/skills/stash-edge/SKILL.md @@ -433,10 +433,16 @@ 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 WASM either way. The schema's declaration site no longer matters. +// 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' -// …or from '@cipherstash/stack/wasm-inline' — same classes, same result. +``` + +```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