From 2eef07edbe78610813acdf76e32bb3637023fff9 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:12:59 +1000 Subject: [PATCH 01/12] docs(specs): design for the Encryption signature fixes and typecheck gates Covers A-4, A-6, A-7 and C-1 from the EQL v2 removal verification, and records what is deliberately excluded: the single generic signature and ClientFor machinery belong with the v2 removal (#637), not here. --- ...on-signature-and-typecheck-gates-design.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md diff --git a/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md new file mode 100644 index 00000000..5776ed1d --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md @@ -0,0 +1,275 @@ +# `Encryption` signature fixes and typecheck gates + +**Date:** 2026-07-24 +**Branch:** `fix/encryption-signature-and-typecheck-gates` (off `8b3f0b15`) +**Addresses:** A-4, A-6, A-7, C-1 from `.work/2026-07-24-eql-v2-removal-verification.md` +**Issue:** #778 (review remediation for #772) + +## Why these four, and why not more + +A-6 and A-7 exist because `Encryption` is overloaded, and it is overloaded because there +are two kinds of client: the typed EQL v3 one and the nominal one that EQL v2 and loose +introspection-derived schemas need. Two overloads means two discriminators that can +disagree — overload resolution reads the `config` argument, the runtime reads the +`schemas` argument (`encryption/index.ts:947`). Remove EQL v2 and there is one client, +one signature, and both defects delete themselves. + +So this change deliberately does **not** attempt the type-level repair the verification +doc explores (a single generic signature returning `ClientFor`, a discriminated-union +`WireConfig`, `ConfigFor`). That machinery exists only to make two clients coexist +safely, and it would be deleted by the v2 removal that motivates it. It is issue #637 and +it belongs with PRs 9–12. + +What is left is the subset that is either independent of the v2 removal (A-4, C-1) or +cheap and non-throwaway (A-6, A-7). + +## A-4 — non-tuple schema arrays are rejected + +### Problem + +`Encryption({ schemas })` accepts only an array *literal*. Every indirect form fails with +`TS2769`: + +```ts +export const all: AnyV3Table[] = [users, orders] // shared module +await Encryption({ schemas: all }) // TS2769 +``` + +Also broken: `ReadonlyArray` (the type `prisma-next` exposes publicly), +push-built arrays, spreads, and unannotated `const all = [...]`. + +The constraint at `encryption/index.ts:872-877` is a non-empty *tuple*: + +```ts +export function Encryption< + const S extends readonly [AnyV3Table, ...AnyV3Table[]], +>(config: { schemas: S; config?: V3ClientConfig }): Promise> +``` + +It was narrowed to a tuple by `7e0092f3` for one reason: `readonly AnyV3Table[]` admits +`readonly []`, so `Encryption({ schemas: [] })` type-checked and then threw at runtime. + +This is a live break on a released surface. `prisma-next` already had to work around it +with a destructure-and-respread through three coupled edits in `from-stack-v3.ts`; any +customer with schema-building indirection hits the same wall. + +### Design + +Widen the type parameter to the array and move the non-emptiness check to the property, so +`[]` is rejected without the tuple constraining every other form: + +```ts +type NonEmptyV3 = S['length'] extends 0 ? never : S + +export function Encryption(config: { + schemas: NonEmptyV3 + config?: V3ClientConfig +}): Promise> +``` + +`schemas` must resolve through `NonEmptyV3` rather than being wrapped in a conditional +alias applied to the whole config — wrapping defeats `const` inference and degrades the +tuple to an array, losing per-column typing on the literal path. + +The runtime throw at `encryption/index.ts:891` stays as the backstop for JavaScript callers. + +### Acceptance + +A `.test-d.ts` probe pinning both directions: + +- compile: inline literal, shared `AnyV3Table[]`, `ReadonlyArray`, push-built, + spread, unannotated `const`; +- still error: `{ schemas: [] }`, wrong plaintext type for a column's domain, a table that + is not a member of the registered tuple. + +## A-6 — `ReturnType` resolves to the nominal client + +### Problem + +TypeScript's `ReturnType` reads the *last* overload, which is the nominal one. So +`Awaited>` yields `EncryptionClient` even for an all-v3 +schema set, and assigning the real client to it fails: + +``` +Type 'TypedEncryptionClient<…>' is missing the following properties +from type 'EncryptionClient': client, encryptConfig, init +``` + +Overload order cannot fix this — whichever signature is last wins, so one of the two forms +is always mis-resolved. Reordering additionally destroys the typed client, because a v3 +table structurally satisfies `BuildableTable` and so matches the nominal overload. + +This is a regression from the released surface, not a pre-existing wart: all 15 instances +were introduced by `d7ff8471`, which turned a single-signature `EncryptionV3` into an alias +of an overloaded `Encryption`. `packages/bench/src/drizzle/setup.ts:38-45` already carries a +hand-rolled workaround. + +15 sites, none visible to CI: + +| Package | Sites | +|---|---| +| `packages/stack` | `__tests__/dynamodb/encrypted-dynamodb-v3.test.ts` (×3), `__tests__/encrypt-lock-context-guards.test.ts`, `__tests__/encrypt-query-searchable-json.test.ts`, `__tests__/encrypt-query-stevec.test.ts`, `integration/shared/{matrix-crypto,matrix-sql,schema-pg,schema-v3-client}.integration.test.ts` | +| `packages/stack-drizzle` | `integration/{adapter,json-adapter}.ts`, `integration/{lock-context,null-persistence,relational}.integration.test.ts` | + +### Design + +No signature change. `EncryptionClientFor` (`encryption/v3.ts:399-402`) already exists and +is the correct idiom — the prior-art survey in the verification doc found that no library +dispatches a schema-dependent result type through `ReturnType`; they all expose a named +extraction type (`z.infer`, `typeof x.infer`, hono's `Client`). Convert the call sites to +it and document it as *the* way to name the client. + +**`EncryptionClientFor` must be widened in step with A-4.** It carries the same narrow tuple +guard: + +```ts +S extends readonly [AnyV3Table, ...AnyV3Table[]] ? TypedEncryptionClient : EncryptionClient +``` + +Left alone, `EncryptionClientFor` falls through to `EncryptionClient` — +so the type A-6 tells callers to use would silently hand back the nominal client for exactly +the non-tuple schemas A-4 just enabled. It becomes: + +```ts +export type EncryptionClientFor = + S extends readonly AnyV3Table[] + ? S['length'] extends 0 + ? EncryptionClient + : TypedEncryptionClient + : EncryptionClient +``` + +The `readonly []` arm must be checked *inside* the v3 branch and before the tuple is used: +`never extends X` is true, so an empty tuple otherwise satisfies "all elements are v3". + +Erased sites that pass `[schema as never]` (the generic `stack-drizzle` integration adapters) +are declared `EncryptionClientFor`, which resolves to +`TypedEncryptionClient` and accepts `TypedEncryptionClient` +by method bivariance. + +`encryption-overloads.test-d.ts:84-88` currently asserts the defect as expected behaviour and +is green in CI. It is rewritten to assert `EncryptionClientFor` resolves correctly instead. + +## A-7 — the type says nominal, the runtime hands back typed + +### Problem + +Overload resolution matches on the `config` argument; the runtime picks its client by +inspecting the `schemas` (`encryption/index.ts:947`, `isV3Only && eqlVersion === 3`). They +disagree whenever a config is hoisted into a `ClientConfig`-typed variable: + +```ts +const cfg: ClientConfig = {} // not assignable to V3ClientConfig +const c = await Encryption({ schemas: [users], config: cfg }) +// type: EncryptionClient runtime: TypedEncryptionClient +c.init(...) // TypeError: init is not a function +``` + +`ClientConfig.eqlVersion` is `2 | 3`; the v3 overload requires `eqlVersion?: 3`. So the +variable form selects the nominal overload while the runtime still returns the typed client. +This bites whenever the variable's runtime `eqlVersion` is anything but `2` — `{}`, +`{ keyset }`, `{ authStrategy }`: the common case. + +Measured blast radius: `init` is the **only** member on `EncryptionClient.prototype` absent +from the typed client at runtime. The other ten are all present. + +### Design + +Add an `init` passthrough to the object `typedClient()` returns, declared `@internal` on +`TypedEncryptionClient` so `satisfies` still checks the shape: + +```ts +init: (config) => client.init(config), +``` + +This reduces the runtime gap to zero. What remains is a silent capability downgrade — the +type says nominal, so the caller loses the typed surface with no diagnostic. No type-level +design closes that: the runtime inspects values while the type inspects an erasable static +type. It ends when v2 removal collapses the two clients into one. + +### Acceptance + +A unit test reproducing the exact shape (config declared `ClientConfig`, v3 schemas, +`EncryptionClient.prototype.init` stubbed so no credentials are needed) that fails with +`TypeError: client.init is not a function` before the change and passes after. + +## C-1 — typecheck gates + +### Problem + +The root `typecheck` gate for `examples/*` already exists (`.github/workflows/tests.yml:155-161`, +added by `5fab1cf6`) — the verification doc refutes the original framing. The remaining gap is +the surfaces nothing typechecks at all. + +Measured after a full `pnpm run build` (most apparent failures were unbuilt workspace deps +resolving to `dist/*.d.ts`, not real errors): + +| Surface | Errors | Has script | +|---|---|---| +| `packages/bench` | 0 | no | +| `packages/migrate` | 0 | no | +| `packages/prisma-next` | 0 | `typecheck` | +| `packages/test-kit` | 0 | `test:types` | +| `packages/wizard` | 0 | `typecheck` | +| `examples/basic` | 0 | `typecheck` (gated) | +| `examples/prisma` | 0 | `typecheck` (never invoked) | +| `e2e` | 0 | no | +| `packages/nextjs` | 2 | no | +| `packages/stack-supabase` | 11 | `test:types` (narrower config) | +| `packages/cli` | 21 | no | +| `packages/stack-drizzle` | 69 | `test:types` (narrower config) | +| `packages/stack` | 168 | `test:types` (narrower config) | + +The three `test:types` scripts run `vitest --typecheck.only` against a `tsconfig.typecheck.json` +whose `include` is `__tests__/**/*.test-d.ts` — so the package's real `tsconfig.json`, which +covers `src`, `__tests__/**/*.test.ts` and `integration/**`, is never checked. + +### Design + +Gate what is green, and record what is not with its count rather than silently skipping it. + +1. Add `typecheck` scripts (`tsc --noEmit -p tsconfig.json`) to `bench`, `migrate`, `nextjs`, + `e2e`; keep the existing ones on `prisma-next`, `wizard`, `examples/*`. +2. One CI job running the eight green surfaces plus `nextjs`, after `pnpm run build` (they + resolve workspace deps through `dist/*.d.ts`). +3. Fix `packages/nextjs`'s 2 errors: `vi.Mock` is used as a *type* in + `__tests__/nextjs.test.ts:68,81`; it needs `import type { Mock } from 'vitest'`. +4. Add `"outputs": ["dist/**"]` to `turbo.json`'s `build` task. It currently declares none, + so a cached build restores nothing and the `examples/basic` gate — which typechecks + against `packages/stack/dist/*.d.ts` — holds today only because fresh CI runners have no + cache. +5. Record `stack-supabase` (11), `cli` (21), `stack-drizzle` (69), `stack` (168) as + documented follow-ups. + +### Deliberately out of scope + +Making `stack`, `stack-drizzle`, `stack-supabase` and `cli` green. 51 of `stack-drizzle`'s 69 +and all 11 of `stack-supabase`'s are one root cause — `spec.indexes.unique` / `.ore` / `.ope` +against `V3_MATRIX` in `packages/test-kit`'s catalog, where `typedEntries` collapses the spec +to a union whose members do not all carry those keys. A single fix in `test-kit` likely clears +~62 of the ~80 errors across those two packages. That is the highest-leverage next step and it +is its own change. + +## Interactions and ordering + +A-4 and A-6 must land together: widening `Encryption` without widening `EncryptionClientFor` +leaves the documented idiom resolving to the wrong client. + +A-7 is independent but shares the same files, and it removes `init` from A-6's `TS2739` +message (leaving only the private `client` / `encryptConfig` fields), so it lands first to +keep the A-6 diffs legible. + +C-1 is independent of all three. It is sequenced last because the A-6 conversions remove 15 +of the errors in the two packages it reports counts for. + +## Other deliverables + +- Changeset: `@cipherstash/stack` **minor** — A-4 widens a released signature and A-7 adds a + member to `TypedEncryptionClient`. `@cipherstash/stack-drizzle` patch for the integration + adapter conversions. +- Delete the migration paragraph in `.changeset/stack-audit-on-decrypt.md` telling callers to + narrow `AnyV3Table[]` to `readonly [AnyV3Table, ...AnyV3Table[]]` — A-4 makes that advice + obsolete, and it was never correct for `ReadonlyArray` anyway. +- Skills sweep: `skills/stash-encryption/SKILL.md` for any `ReturnType` + guidance and for schema-array examples that the widening now permits. +- `packages/stack/README.md` and `packages/prisma-next` docs for the same. From 3d925be70a5cd6a882c12f0b72f6b24ec78e466e Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:16:48 +1000 Subject: [PATCH 02/12] fix(stack): give the typed client an init passthrough (A-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Encryption` picks its return value with two discriminators that can disagree — overload resolution reads the `config` argument, the runtime reads the `schemas` argument. Hoisting a config into a `ClientConfig`-typed variable splits them: `ClientConfig.eqlVersion` is `2 | 3`, which the v3 overload's `eqlVersion?: 3` rejects, so the call resolves to the nominal client while the runtime still returns the typed one. `init` was the only member of `EncryptionClient` absent from the typed client, so anything holding that client through its declared type hit `TypeError: client.init is not a function`. Delegate it, and assert the parity so a future member cannot reopen the hole. The remaining half — the silent loss of the typed surface — is not closable at the type level (the runtime inspects values, the type inspects an erasable static type). It ends with the EQL v2 removal (#637). --- .../typed-client-nominal-parity.test.ts | 82 +++++++++++++++++++ packages/stack/src/encryption/v3.ts | 16 ++++ 2 files changed, 98 insertions(+) create mode 100644 packages/stack/__tests__/typed-client-nominal-parity.test.ts diff --git a/packages/stack/__tests__/typed-client-nominal-parity.test.ts b/packages/stack/__tests__/typed-client-nominal-parity.test.ts new file mode 100644 index 00000000..dc0bcc1c --- /dev/null +++ b/packages/stack/__tests__/typed-client-nominal-parity.test.ts @@ -0,0 +1,82 @@ +/** + * Runtime parity between the two clients `Encryption` can return. + * + * `Encryption` decides its return value with two discriminators that can + * disagree: overload resolution reads the `config` argument, while the runtime + * reads the `schemas` argument (`encryption/index.ts`, the `isV3Only && + * eqlVersion === 3` branch). Hoisting a config into a `ClientConfig`-typed + * variable is enough to split them — `ClientConfig.eqlVersion` is `2 | 3`, which + * is not assignable to the v3 overload's `eqlVersion?: 3`, so the call selects + * the NOMINAL overload while the runtime still hands back the TYPED client. + * + * No type-level design closes that: the runtime inspects values, the type + * inspects a static type that can be widened away. It ends when the EQL v2 + * removal collapses the two clients into one (#637). Until then the mismatch + * must not be able to crash, which means every member of `EncryptionClient` has + * to exist on the typed client at runtime. + */ +import { describe, expect, it, vi } from 'vitest' +import { Encryption, EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/encryption/v3' +import type { ClientConfig } from '@/types' + +const users = encryptedTable('users', { email: types.TextSearch('email') }) + +/** + * Build a client without credentials by stubbing `init` to resolve to itself — + * `Encryption` only needs a successful `Result` to reach its return branch. + */ +async function buildWithStubbedInit(config: ClientConfig) { + const spy = vi + .spyOn(EncryptionClient.prototype, 'init') + .mockImplementation(async function (this: EncryptionClient) { + return { data: this } + } as never) + try { + return await Encryption({ schemas: [users], config }) + } finally { + spy.mockRestore() + } +} + +describe('typed client / nominal client runtime parity', () => { + it('a ClientConfig-typed variable types as nominal but returns the typed client', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + // The static type here is `EncryptionClient`. The runtime disagrees. + expect('encryptQuery' in client).toBe(true) + expect(client).not.toBeInstanceOf(EncryptionClient) + }) + + it('exposes every EncryptionClient member, so the mismatch cannot crash', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + const nominalMembers = Object.getOwnPropertyNames( + EncryptionClient.prototype, + ).filter((name) => name !== 'constructor') + + // `init` was the only member missing, which turned the type/runtime + // mismatch above into `TypeError: client.init is not a function` for + // anything holding the client through its declared `EncryptionClient` type. + const missing = nominalMembers.filter( + (name) => typeof (client as Record)[name] !== 'function', + ) + expect(missing).toEqual([]) + }) + + it('delegates init to the underlying client', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + const spy = vi + .spyOn(EncryptionClient.prototype, 'init') + .mockResolvedValue({ data: {} } as never) + + await (client as unknown as EncryptionClient).init({} as never) + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockRestore() + }) +}) diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 3ba2f19c..3fd94915 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -150,6 +150,21 @@ export interface TypedEncryptionClient { ): BulkEncryptOperation bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation getEncryptConfig(): ReturnType + + /** + * Re-initialize the underlying client. + * + * @internal Present for runtime parity with {@link EncryptionClient}, not as + * part of the typed authoring surface. `Encryption` picks its return value by + * inspecting the *schemas* while overload resolution inspects the *config*, so + * the two can disagree: a config hoisted into a `ClientConfig`-typed variable + * selects the nominal overload but still yields this client at runtime. Every + * other `EncryptionClient` method already existed here; without `init` that + * mismatch turned into `TypeError: client.init is not a function`. + */ + init( + config: Parameters[0], + ): ReturnType } /** @@ -367,6 +382,7 @@ export function typedClient( bulkEncrypt: (plaintexts, opts) => client.bulkEncrypt(plaintexts, opts), bulkDecrypt: (payloads) => client.bulkDecrypt(payloads), getEncryptConfig: () => client.getEncryptConfig(), + init: (config) => client.init(config), } satisfies TypedEncryptionClient } From 41ce9c5eed42bf6df01b41d4769f28eb77080980 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:17:59 +1000 Subject: [PATCH 03/12] fix(stack)!: accept schema arrays that are not literals (A-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Encryption({ schemas })` only accepted an array LITERAL. Every indirect form failed with TS2769: a shared `export const all: AnyV3Table[]`, the `ReadonlyArray` prisma-next exposes publicly, anything push-built or spread, an unannotated `const`. The cause was how the empty-schema hole was closed — by constraining the type parameter to a non-empty TUPLE, which also excluded every non-literal. Move non-emptiness onto the `schemas` property via `NonEmptyV3` and let the parameter be the array. `Encryption({ schemas: [] })` is still a compile error, and the literal path keeps its per-column plaintext typing — the guard has to sit on the property, since wrapping the config in a conditional alias defeats `const` inference. `EncryptionClientFor` carried the same tuple guard and is widened in step. Left alone it fell through to the nominal client for a loose `AnyV3Table[]` — handing the wrong type to exactly the callers this enables. Prisma-next's destructure-and-respread workaround can now be removed. --- .../__tests__/encryption-overloads.test-d.ts | 84 ++++++++++++++++--- packages/stack/src/encryption/index.ts | 32 +++++-- packages/stack/src/encryption/v3.ts | 47 +++++++---- 3 files changed, 128 insertions(+), 35 deletions(-) diff --git a/packages/stack/__tests__/encryption-overloads.test-d.ts b/packages/stack/__tests__/encryption-overloads.test-d.ts index e3afd592..82cc1f97 100644 --- a/packages/stack/__tests__/encryption-overloads.test-d.ts +++ b/packages/stack/__tests__/encryption-overloads.test-d.ts @@ -16,7 +16,7 @@ import { encryptedTable, type TypedEncryptionClient, } from '@/encryption/v3' -import { types } from '@/eql/v3' +import { type AnyV3Table, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' const users = encryptedTable('users', { @@ -49,6 +49,49 @@ describe('overload selection', () => { Encryption({ schemas: [] }) }) + // A-4: closing S-6 with a non-empty TUPLE constraint rejected every schema + // array that is not a literal, which is most real code — a shared module + // export, anything built from introspection, anything `readonly`. The + // non-emptiness check moved to the `schemas` property so these compile again + // while `[]` above still does not. One case per form that was broken. + it('accepts schema arrays that are not literals', async () => { + const shared: AnyV3Table[] = [users] + expectTypeOf(await Encryption({ schemas: shared })).toEqualTypeOf< + TypedEncryptionClient + >() + + // `ReadonlyArray` is the form `@cipherstash/prisma-next` exposes publicly. + const frozen: ReadonlyArray = [users] + expectTypeOf(await Encryption({ schemas: frozen })).toEqualTypeOf< + TypedEncryptionClient + >() + + const built: AnyV3Table[] = [] + built.push(users) + expectTypeOf(await Encryption({ schemas: built })).toEqualTypeOf< + TypedEncryptionClient + >() + + expectTypeOf(await Encryption({ schemas: [...shared] })).toEqualTypeOf< + TypedEncryptionClient + >() + }) + + // The widening must not cost the literal path its precision — that typing is + // the entire reason the typed client exists. `const` inference has to survive. + it('keeps per-column typing on the literal path', async () => { + const client = await Encryption({ schemas: [users] }) + + expectTypeOf(client.encrypt).toBeCallableWith('a@b.com', { + table: users, + column: users.email, + }) + // @ts-expect-error - `email` is a text domain, not a number + client.encrypt(123, { table: users, column: users.email }) + // @ts-expect-error - `createdAt` is a timestamp domain, not a string + client.encrypt('2020-01-01', { table: users, column: users.createdAt }) + }) + // S-4: forcing v2 wire over v3 schemas returns the NOMINAL client at runtime // (the typed client cannot author v3 columns in v2 mode). The types used to // claim the typed client, so `decryptModel(row, table, lockContext)` compiled @@ -77,16 +120,6 @@ describe('overload selection', () => { }) describe('naming the client type', () => { - // S-2: `ReturnType` reads the LAST overload, so this idiom resolves to the - // nominal client no matter what schemas you pass. Pinned rather than fixed — - // overload order cannot satisfy both forms — so the surprise is documented and - // cannot change silently. - it('ReturnType resolves to the NOMINAL client', () => { - expectTypeOf< - Awaited> - >().toEqualTypeOf() - }) - it('EncryptionClientFor names the typed client for a v3 tuple', async () => { const client: EncryptionClientFor = await Encryption({ schemas: [users] }) @@ -95,10 +128,39 @@ describe('naming the client type', () => { >() }) + // A-6: the form generic code needs — an integration adapter that builds its + // table per test family cannot name a tuple. This must track `Encryption`'s + // own constraint: while `EncryptionClientFor` still required a non-empty + // TUPLE it fell through to the nominal client here, silently handing the + // wrong type to exactly the callers the widening above exists to serve. + it('EncryptionClientFor names the typed client for a loose v3 array', () => { + expectTypeOf>().toEqualTypeOf< + TypedEncryptionClient + >() + }) + it('EncryptionClientFor falls back to the nominal client', () => { expectTypeOf< EncryptionClientFor >().toEqualTypeOf() + + // `never extends X` is true, so an empty tuple satisfies "every element is + // a v3 table" — the emptiness arm has to be checked inside the v3 branch. + expectTypeOf< + EncryptionClientFor + >().toEqualTypeOf() + }) + + // S-2: `ReturnType` reads the LAST overload, so this idiom resolves to the + // nominal client no matter what schemas you pass. Overload order cannot + // satisfy both forms — putting the nominal signature first mis-resolves v3 + // schemas instead, because a v3 table structurally satisfies `BuildableTable`. + // `EncryptionClientFor` above is the supported idiom; this pins the trap so it + // cannot start silently resolving differently. + it('ReturnType resolves to the NOMINAL client', () => { + expectTypeOf< + Awaited> + >().toEqualTypeOf() }) }) diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 5a47a930..39b8ff90 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -862,17 +862,35 @@ export function __resetStrategyDeprecationWarningForTests(): void { * @see {@link ClientConfig.authStrategy} for the auth strategy field. * @see {@link EncryptionClient} for available methods after initialization. */ +/** + * The schema-tuple guard for {@link Encryption}'s v3 overload. + * + * Resolves to `S` for any non-empty array of v3 tables and to `never` for + * `readonly []`, so `Encryption({ schemas: [] })` stays a compile error while + * every non-literal form (a shared `AnyV3Table[]`, a `ReadonlyArray`, a + * push-built or spread array) still selects this overload. + */ +type NonEmptyV3 = S['length'] extends 0 + ? never + : S + // Overload 1 — v3-typed: an array literal of concrete EQL v3 tables (from // `@cipherstash/stack/v3`) yields the strongly-typed {@link TypedEncryptionClient}, // the collapse of the former `EncryptionV3`. The wire format is forced to v3. // -// The schema tuple is constrained NON-EMPTY: `readonly AnyV3Table[]` admits -// `readonly []`, so `Encryption({ schemas: [] })` type-checked and then threw at -// runtime. The nominal overload has always required at least one table. -export function Encryption< - const S extends readonly [AnyV3Table, ...AnyV3Table[]], ->(config: { - schemas: S +// `S` is the ARRAY, not a non-empty tuple. Constraining the type parameter to +// `readonly [AnyV3Table, ...AnyV3Table[]]` — which is how the `[]` case was +// first closed — rejected every form that is not an array literal: a shared +// `export const all: AnyV3Table[]`, the `ReadonlyArray` prisma-next exposes, +// anything push-built or spread. Non-emptiness is enforced on the PROPERTY via +// {@link NonEmptyV3} instead, which leaves `Encryption({ schemas: [] })` a +// compile error without constraining the rest. +// +// `NonEmptyV3` must sit on `schemas` and nowhere else: wrapping the whole +// config in a conditional alias defeats `const` inference, degrading the tuple +// to an array and erasing per-column plaintext typing on the literal path. +export function Encryption(config: { + schemas: NonEmptyV3 config?: V3ClientConfig }): Promise> // Overload 2 — nominal: loose/dynamic schemas (introspection-derived, e.g. diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 3fd94915..248aaad4 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -389,32 +389,45 @@ export function typedClient( /** * The client type {@link Encryption} resolves to for the schema tuple `S`. * - * **Use this instead of `Awaited>`.** `Encryption` - * is overloaded, and TypeScript's `ReturnType` reads the LAST overload — the - * nominal one — so that expression yields `EncryptionClient` even for an all-v3 - * schema set, and assigning the real (typed) client to it is an error: - * - * ``` - * Type 'TypedEncryptionClient<…>' is missing the following properties - * from type 'EncryptionClient': client, encryptConfig, init - * ``` - * - * Overload order cannot fix that — whichever signature is last wins, so one of - * the two forms is always mis-resolved. Name the schema tuple instead: + * This is **the** way to name the client — reach for it whenever you need to + * declare a variable, field or return type before the `await` that produces it: * * ```typescript * const users = encryptedTable("users", { email: types.TextSearch("email") }) + * * let client: EncryptionClientFor * client = await Encryption({ schemas: [users] }) * ``` * - * The equivalent inline workaround — inferring through a single-signature - * helper, `Awaited>` — also works, and is what - * `packages/bench` does. + * For code that is generic over its schemas — integration adapters that build a + * table per test family, say — name the loose array and keep the typed surface: + * + * ```typescript + * let client: EncryptionClientFor + * ``` + * + * **Do not use `Awaited>`.** `Encryption` is + * overloaded, and TypeScript's `ReturnType` reads the LAST overload — the + * nominal one — so that expression yields `EncryptionClient` even for an all-v3 + * schema set, and assigning the real client to it is an error: + * + * ``` + * Type 'TypedEncryptionClient<…>' is missing the following properties + * from type 'EncryptionClient': client, encryptConfig + * ``` + * + * Overload order cannot fix that — whichever signature is last wins, so one of + * the two forms is always mis-resolved, and putting the nominal signature first + * mis-resolves v3 schemas instead (a v3 table structurally satisfies + * `BuildableTable`). A named extraction type is what every comparable library + * does for the same reason: `z.infer`, arktype's `typeof T.infer`, hono's + * `Client`. */ export type EncryptionClientFor = - S extends readonly [AnyV3Table, ...AnyV3Table[]] - ? TypedEncryptionClient + S extends readonly AnyV3Table[] + ? S['length'] extends 0 + ? EncryptionClient + : TypedEncryptionClient : EncryptionClient /** From 16de1420b0858dea6a882359ca43efcc4e55e3f8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:34:28 +1000 Subject: [PATCH 04/12] fix(stack,stack-drizzle,bench): name the client with EncryptionClientFor (A-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReturnType` reads the LAST overload, which is the nominal one, so `Awaited>` yielded `EncryptionClient` even for an all-v3 schema set and every assignment of the real client to it failed with TS2739. 15 sites carried that error — 10 in stack, 5 in stack-drizzle — and none was visible to CI, because the only typecheck step in either package is scoped to `__tests__/**/*.test-d.ts`. Overload order cannot fix it (putting the nominal signature first mis-resolves v3 schemas instead, since a v3 table structurally satisfies BuildableTable), so name the client instead — which is what every comparable library does: z.infer, arktype's typeof T.infer, hono's Client. Sweeps the idiom out entirely, not just the erroring sites: nominal callers now say `EncryptionClient`, v3 callers `EncryptionClientFor`. packages/bench drops the single-signature helper it wrapped Encryption in for exactly this reason. Typing these clients correctly unmasked call sites the wrong type had been hiding. Most were `as never` casts that are simply no longer needed, and dropping them means the calls are now genuinely checked. Two are not: encrypt-query-stevec and encrypt-query-searchable-json exercise `encryptQuery` query types the typed client mis-models — it derives the plaintext from the column's domain, so every query type on a types.Json() column is typed JsonDocument, but the SteVec types take a JSONPath string, a { path, value } pair, or a bare scalar. Those two suites hold the client through the nominal surface with the gap named at the cast, rather than casting at every call and hiding it. packages/stack tsconfig: 168 -> 147 errors, none new. packages/stack-drizzle tsconfig: 69 -> 63 errors, none new. --- packages/bench/src/drizzle/setup.ts | 20 +++++----------- .../__tests__/operators.test-d.ts | 4 ++-- packages/stack-drizzle/integration/adapter.ts | 8 +++++-- .../stack-drizzle/integration/json-adapter.ts | 8 +++++-- .../lock-context.integration.test.ts | 8 +++++-- .../null-persistence.integration.test.ts | 8 +++++-- .../relational.integration.test.ts | 8 +++++-- packages/stack/__tests__/audit.test.ts | 3 ++- .../stack/__tests__/backward-compat.test.ts | 3 ++- .../stack/__tests__/basic-protect.test.ts | 3 ++- packages/stack/__tests__/bulk-protect.test.ts | 3 ++- .../decrypt-audit-forwarding.test.ts | 3 ++- .../dynamodb/encrypted-dynamodb-v3.test.ts | 23 +++++++++---------- .../encrypt-lock-context-guards.test.ts | 15 ++++++++---- .../encrypt-query-searchable-json.test.ts | 14 ++++++++--- .../__tests__/encrypt-query-stevec.test.ts | 15 +++++++++--- packages/stack/__tests__/json-protect.test.ts | 3 ++- .../__tests__/lock-context-wiring.test.ts | 3 ++- .../stack/__tests__/number-protect.test.ts | 3 ++- packages/stack/__tests__/protect-ops.test.ts | 3 ++- .../v3-matrix/matrix-lock-context.test.ts | 3 ++- .../matrix-identity.integration.test.ts | 5 ++-- .../shared/json-crypto.integration.test.ts | 3 ++- .../shared/match-bloom.integration.test.ts | 4 +++- .../shared/matrix-bulk.integration.test.ts | 3 ++- .../shared/matrix-crypto.integration.test.ts | 9 ++++++-- .../shared/matrix-sql.integration.test.ts | 17 ++++++++++---- .../shared/schema-pg.integration.test.ts | 8 ++++--- .../schema-v3-client.integration.test.ts | 4 ++-- 29 files changed, 140 insertions(+), 74 deletions(-) diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 16ba56d2..87da1587 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -1,4 +1,4 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle' import { drizzle } from 'drizzle-orm/node-postgres' import { pgTable, serial } from 'drizzle-orm/pg-core' @@ -34,19 +34,9 @@ export type BenchPlaintextRow = { enc_jsonb: { idx: number; group: number } } -/** - * Build the typed EQL v3 client this bench drives. Wrapped in a - * single-signature helper because `EncryptionV3` is now overloaded (typed v3 - * vs. nominal) — `ReturnType` resolves to the *last* - * (nominal) overload, so we infer the return type through this helper instead. - */ -function makeEncryptionClient() { - return EncryptionV3({ schemas: [encryptionBenchTable] }) -} - /** The typed EQL v3 client this bench drives. */ -export type BenchEncryptionClient = Awaited< - ReturnType +export type BenchEncryptionClient = EncryptionClientFor< + readonly [typeof encryptionBenchTable] > export type BenchHandle = { @@ -69,7 +59,9 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await makeEncryptionClient() + const encryptionClient = await EncryptionV3({ + schemas: [encryptionBenchTable], + }) return { pgClient, pool, db, encryptionClient } } diff --git a/packages/stack-drizzle/__tests__/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts index b5e1be91..600a62d6 100644 --- a/packages/stack-drizzle/__tests__/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -4,7 +4,7 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedQueryResult } from '@cipherstash/stack/types' -import type { EncryptionV3 } from '@cipherstash/stack/v3' +import type { AnyV3Table, EncryptionClientFor } from '@cipherstash/stack/v3' import { describe, expectTypeOf, it } from 'vitest' import { createEncryptionOperators } from '../src/index.js' @@ -21,7 +21,7 @@ import { createEncryptionOperators } from '../src/index.js' * existing typecheck scope without dragging the loose-typed runtime suites in. */ describe('createEncryptionOperators - client parameter (M1)', () => { - type V3Client = Awaited> + type V3Client = EncryptionClientFor // A query operation resolving `Result` — the surface the factory drives. type QueryOp = { diff --git a/packages/stack-drizzle/integration/adapter.ts b/packages/stack-drizzle/integration/adapter.ts index a2e1ea84..635572de 100644 --- a/packages/stack-drizzle/integration/adapter.ts +++ b/packages/stack-drizzle/integration/adapter.ts @@ -1,4 +1,8 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, type IntegrationAdapter, @@ -57,7 +61,7 @@ type AnyTable = any export function makeDrizzleAdapter(): IntegrationAdapter { let sqlClient: postgres.Sql let db: ReturnType - let client: Awaited> + let client: EncryptionClientFor let ops: ReturnType let table: AnyTable let schema: ReturnType diff --git a/packages/stack-drizzle/integration/json-adapter.ts b/packages/stack-drizzle/integration/json-adapter.ts index 2ed37577..44743a1d 100644 --- a/packages/stack-drizzle/integration/json-adapter.ts +++ b/packages/stack-drizzle/integration/json-adapter.ts @@ -1,4 +1,8 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, type JsonIntegrationAdapter, @@ -25,7 +29,7 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { let db: ReturnType let tableName: string let table: AnyTable - let client: Awaited> + let client: EncryptionClientFor let ops: ReturnType const rowsFor = async ( diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index 8167c0ed..489a7810 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -33,7 +33,11 @@ * control. */ import { OidcFederationStrategy } from '@cipherstash/stack' -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, unwrapResult, V3_MATRIX } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' @@ -71,7 +75,7 @@ const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } -let client: Awaited> +let client: EncryptionClientFor let ops: ReturnType let db: ReturnType diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index f97c1d29..6a0e0f41 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -13,7 +13,11 @@ * as SQL NULL, and the present cell still decrypts to its plaintext. */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' @@ -84,7 +88,7 @@ const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } -let client: Awaited> +let client: EncryptionClientFor let ops: ReturnType let db: ReturnType diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index 8e6eecb3..5fac5015 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -19,7 +19,11 @@ * `stash eql install`. This suite throws rather than skips when unconfigured. */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { type DomainSpec, databaseUrl, @@ -136,7 +140,7 @@ type RowKey = (typeof ROWS)[number] type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType -type Client = Awaited> +type Client = EncryptionClientFor type Ops = ReturnType type ComparisonOperator = 'gt' | 'gte' | 'lt' | 'lte' diff --git a/packages/stack/__tests__/audit.test.ts b/packages/stack/__tests__/audit.test.ts index bb14447b..edf2b1df 100644 --- a/packages/stack/__tests__/audit.test.ts +++ b/packages/stack/__tests__/audit.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -20,7 +21,7 @@ type User = { number?: number } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/backward-compat.test.ts b/packages/stack/__tests__/backward-compat.test.ts index 915108cc..cdf5c930 100644 --- a/packages/stack/__tests__/backward-compat.test.ts +++ b/packages/stack/__tests__/backward-compat.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -8,7 +9,7 @@ const users = encryptedTable('users', { }) describe('k-field discriminator (EQL v2.3)', () => { - let protectClient: Awaited> + let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) diff --git a/packages/stack/__tests__/basic-protect.test.ts b/packages/stack/__tests__/basic-protect.test.ts index a889328b..af687818 100644 --- a/packages/stack/__tests__/basic-protect.test.ts +++ b/packages/stack/__tests__/basic-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -9,7 +10,7 @@ const users = encryptedTable('users', { json: encryptedColumn('json').dataType('json'), }) -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/bulk-protect.test.ts b/packages/stack/__tests__/bulk-protect.test.ts index 45342f91..10d615c7 100644 --- a/packages/stack/__tests__/bulk-protect.test.ts +++ b/packages/stack/__tests__/bulk-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -19,7 +20,7 @@ type User = { number?: number } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts index 3953d0a8..f69519e8 100644 --- a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -40,6 +40,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClientFor } from '@/encryption/v3' // Imported after the mock so the v3 table builder is available; `Encryption` // returns the typed client for an all-v3 schema set. import { encryptedTable, types } from '@/encryption/v3' @@ -66,7 +67,7 @@ const lastDecryptOpts = () => (ffi.decryptBulk as any).mock.calls.at(-1)[1] const lastCiphertextLockContext = () => lastDecryptOpts().ciphertexts[0]?.lockContext -let client: Awaited>> +let client: EncryptionClientFor let prevWorkspaceCrn: string | undefined beforeEach(async () => { diff --git a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts index 4f1f4209..1e2b5fd9 100644 --- a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts @@ -21,9 +21,8 @@ import { beforeAll, describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { toItemWithEqlPayloads } from '@/dynamodb/helpers' import type { EncryptedDynamoDBInstance } from '@/dynamodb/types' -import type { EncryptionClient } from '@/encryption' -import { EncryptionV3 } from '@/encryption/v3' -import type { JsonValue } from '@/eql/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@/encryption/v3' +import type { AnyV3Table, JsonValue } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -66,7 +65,7 @@ type User = { /** The typed client from `EncryptionV3` — the documented v3 entry point. */ let typedDynamo: EncryptedDynamoDBInstance /** The nominal chainable client, forced into v3 mode. */ -let nominalClient: EncryptionClient +let nominalClient: EncryptionClientFor let nominalDynamo: EncryptedDynamoDBInstance beforeAll(async () => { @@ -362,7 +361,7 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { } let nestedDynamo: EncryptedDynamoDBInstance - let nestedClient: EncryptionClient + let nestedClient: EncryptionClientFor beforeAll(async () => { nestedClient = await Encryption({ @@ -432,8 +431,8 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { if (stored.failure) throw new Error(stored.failure.message) const term = await nestedClient.encryptQuery(ssn, { - table: nested as never, - column: nested['profile.ssn'] as never, + table: nested, + column: nested['profile.ssn'], }) if (term.failure) throw new Error(term.failure.message) @@ -477,7 +476,7 @@ describe( }) let renamedDynamo: EncryptedDynamoDBInstance - let renamedClient: EncryptionClient + let renamedClient: EncryptionClientFor beforeAll(async () => { renamedClient = await Encryption({ @@ -517,8 +516,8 @@ describe( expect(decrypted.data).toEqual(original) const term = await renamedClient.encryptQuery('c@d.com', { - table: renamed as never, - column: renamed.emailAddress as never, + table: renamed, + column: renamed.emailAddress, }) if (term.failure) throw new Error(term.failure.message) @@ -580,8 +579,8 @@ describe( if (stored.failure) throw new Error(stored.failure.message) const term = await nominalClient.encryptQuery(email, { - table: users as never, - column: users.email as never, + table: users, + column: users.email, }) if (term.failure) throw new Error(term.failure.message) diff --git a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts index 981845eb..2162b359 100644 --- a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts +++ b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts @@ -19,6 +19,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' @@ -53,8 +55,8 @@ const usersV3 = encryptedTableV3('users_v3', { // biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union const failure = (result: any) => result.failure -let clientV2: Awaited> -let clientV3: Awaited> +let clientV2: EncryptionClient +let clientV3: EncryptionClientFor beforeEach(async () => { vi.clearAllMocks() @@ -66,8 +68,13 @@ beforeEach(async () => { clientV3 = await Encryption({ schemas: [usersV3] }) }) -const clientFor = (variant: 'v2' | 'v3') => - variant === 'v2' ? clientV2 : clientV3 +// The two clients have different `encrypt` signatures — `clientV3` pins the +// plaintext to each column's domain — so the shared `describe.each` call below +// cannot resolve against their union. The guards under test are runtime value +// checks that predate both surfaces and behave identically on each, so the +// dispatch (and only the dispatch) erases to the nominal shape. +const clientFor = (variant: 'v2' | 'v3'): EncryptionClient => + variant === 'v2' ? clientV2 : (clientV3 as unknown as EncryptionClient) describe.each([ ['v2', { column: users.score, table: users }], diff --git a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts index bc99fcb4..d65e791e 100644 --- a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts +++ b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts @@ -1,11 +1,10 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { createMockLockContext, expectFailure, unwrapResult } from './fixtures' -type EncryptionClient = Awaited> - const documents = encryptedTable('documents', { metadata: types.Json('metadata'), }) @@ -21,10 +20,19 @@ function expectContainment(value: unknown): void { } describe('encryptQuery with searchableJson', () => { + // NOTE: this suite holds the client through the NOMINAL surface on purpose. + // The typed client derives `encryptQuery`'s plaintext from the column's domain, + // so every query type on a `types.Json()` column is typed `JsonDocument` — but + // the searchable-JSON query types take a JSONPath string, a `{ path, value }` + // pair, or a bare scalar. This file exercises exactly those, so typing it + // against the typed client would mean casting away the argument type at every + // call and hiding the gap. Cast once, here, where it is visible and explained. let client: EncryptionClient beforeAll(async () => { - client = await Encryption({ schemas: [documents] }) + client = (await Encryption({ + schemas: [documents], + })) as unknown as EncryptionClient }) it('infers a selector hash for string plaintext', async () => { diff --git a/packages/stack/__tests__/encrypt-query-stevec.test.ts b/packages/stack/__tests__/encrypt-query-stevec.test.ts index 54d9f139..89459287 100644 --- a/packages/stack/__tests__/encrypt-query-stevec.test.ts +++ b/packages/stack/__tests__/encrypt-query-stevec.test.ts @@ -1,11 +1,10 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { expectFailure, unwrapResult } from './fixtures' -type EncryptionClient = Awaited> - const documents = encryptedTable('documents', { metadata: types.Json('metadata'), }) @@ -27,10 +26,20 @@ function expectOrderingTerm(value: unknown): void { } describe('encryptQuery with protect-ffi 0.30 SteVec operations', () => { + // NOTE: this suite holds the client through the NOMINAL surface on purpose. + // The typed client derives `encryptQuery`'s plaintext from the column's domain, + // so every query type on a `types.Json()` column is typed `JsonDocument` — but + // the SteVec query types take a JSONPath string (`steVecSelector`), a + // `{ path, value }` pair (`steVecValueSelector`) or a bare scalar + // (`steVecTerm`). This file exercises exactly those, so typing it against the + // typed client would mean casting away the argument type at every call and + // hiding the gap. Cast once, here, where it is visible and explained. let client: EncryptionClient beforeAll(async () => { - client = await Encryption({ schemas: [documents, plain] }) + client = (await Encryption({ + schemas: [documents, plain], + })) as unknown as EncryptionClient }) it('returns a bare selector hash for a JSONPath', async () => { diff --git a/packages/stack/__tests__/json-protect.test.ts b/packages/stack/__tests__/json-protect.test.ts index ea55f421..34e3eb2b 100644 --- a/packages/stack/__tests__/json-protect.test.ts +++ b/packages/stack/__tests__/json-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' @@ -33,7 +34,7 @@ type User = { } } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/lock-context-wiring.test.ts b/packages/stack/__tests__/lock-context-wiring.test.ts index cf4b967c..9a578c01 100644 --- a/packages/stack/__tests__/lock-context-wiring.test.ts +++ b/packages/stack/__tests__/lock-context-wiring.test.ts @@ -44,6 +44,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClient } from '@/encryption' const users = encryptedTable('users', { email: encryptedColumn('email').equality(), @@ -78,7 +79,7 @@ function unwrap(result: any) { // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] -let client: Awaited> +let client: EncryptionClient beforeEach(async () => { vi.clearAllMocks() diff --git a/packages/stack/__tests__/number-protect.test.ts b/packages/stack/__tests__/number-protect.test.ts index 82222377..8bc08498 100644 --- a/packages/stack/__tests__/number-protect.test.ts +++ b/packages/stack/__tests__/number-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it, test } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' @@ -29,7 +30,7 @@ type User = { } } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/protect-ops.test.ts b/packages/stack/__tests__/protect-ops.test.ts index df9c40da..c92f4595 100644 --- a/packages/stack/__tests__/protect-ops.test.ts +++ b/packages/stack/__tests__/protect-ops.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -18,7 +19,7 @@ type User = { number?: number } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts index e7456b94..0024902d 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts @@ -37,6 +37,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('users', { @@ -70,7 +71,7 @@ const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] // `Encryption` returns the typed client directly for an all-v3 schema set (the // collapse of `EncryptionV3`), so there is no separate `typedClient` wrap here. -let typed: Awaited>> +let typed: EncryptionClientFor let prevWorkspaceCrn: string | undefined beforeEach(async () => { diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index 5009a4a7..6f4bfa70 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -18,6 +18,7 @@ import { OidcFederationStrategy } from '@cipherstash/auth' import { unwrapResult } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('v3_identity_live_users', { @@ -35,7 +36,7 @@ const INFRA_FAULT = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|timed? ?out|network error/i describe('v3 typed client identity-aware operations (live)', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { const crn = process.env.CS_WORKSPACE_CRN @@ -114,7 +115,7 @@ describe('v3 typed client identity-aware operations (live)', () => { // test on a Clerk outage or a rotated `CLERK_MACHINE_TOKEN` — for behaviour it // never exercises. describe('v3 typed client audit metadata (live)', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { client = await EncryptionV3({ schemas: [users] }) diff --git a/packages/stack/integration/shared/json-crypto.integration.test.ts b/packages/stack/integration/shared/json-crypto.integration.test.ts index dd66d412..0a54d708 100644 --- a/packages/stack/integration/shared/json-crypto.integration.test.ts +++ b/packages/stack/integration/shared/json-crypto.integration.test.ts @@ -7,6 +7,7 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const docs = encryptedTable('v3_json_docs', { @@ -14,7 +15,7 @@ const docs = encryptedTable('v3_json_docs', { }) describe('v3 typed client — encrypted JSONB round-trip', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { client = await EncryptionV3({ schemas: [docs] }) diff --git a/packages/stack/integration/shared/match-bloom.integration.test.ts b/packages/stack/integration/shared/match-bloom.integration.test.ts index 95fb7011..7b254362 100644 --- a/packages/stack/integration/shared/match-bloom.integration.test.ts +++ b/packages/stack/integration/shared/match-bloom.integration.test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3 } from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' /** @@ -36,7 +38,7 @@ const docs = encryptedTable('match_bloom_probe', { const HAYSTACK = 'ada@example.com' async function bloomOf( - client: Awaited>, + client: EncryptionClientFor, value: string, ) { const result = await client.encrypt(value, { column: docs.bio, table: docs }) diff --git a/packages/stack/integration/shared/matrix-bulk.integration.test.ts b/packages/stack/integration/shared/matrix-bulk.integration.test.ts index 0ef71bee..e9123ae5 100644 --- a/packages/stack/integration/shared/matrix-bulk.integration.test.ts +++ b/packages/stack/integration/shared/matrix-bulk.integration.test.ts @@ -7,6 +7,7 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const people = encryptedTable('v3_bulk_people', { @@ -15,7 +16,7 @@ const people = encryptedTable('v3_bulk_people', { }) describe('v3 typed client bulk-at-scale (live)', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { client = await EncryptionV3({ schemas: [people] }) diff --git a/packages/stack/integration/shared/matrix-crypto.integration.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts index 03d9fcd0..8358d4c2 100644 --- a/packages/stack/integration/shared/matrix-crypto.integration.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -26,7 +26,12 @@ import { V3_MATRIX, } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' +import { + type EncryptionClientFor, + EncryptionV3, + encryptedTable, +} from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' // `as const satisfies Record<...>` gives `V3_MATRIX` a narrower type than // `Record` (rows that omit the optional @@ -70,7 +75,7 @@ const errorCases = domains.flatMap(([t, spec]) => ) describe('v3 matrix live round-trip (all domains × samples)', () => { - let client: Awaited> + let client: EncryptionClientFor let encrypted: Array> let decrypted: Array> diff --git a/packages/stack/integration/shared/matrix-sql.integration.test.ts b/packages/stack/integration/shared/matrix-sql.integration.test.ts index 3532cdcb..2fb11388 100644 --- a/packages/stack/integration/shared/matrix-sql.integration.test.ts +++ b/packages/stack/integration/shared/matrix-sql.integration.test.ts @@ -67,7 +67,12 @@ import { } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' +import { + type EncryptionClientFor, + EncryptionV3, + encryptedTable, +} from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' // Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed // with `PostgresError: invalid input syntax for type json` on the dynamic @@ -203,7 +208,7 @@ const comparePlaintext = (a: unknown, b: unknown): number => { type Row = { id: number } -let client: Awaited> +let client: EncryptionClientFor let idA: number let idB: number // Separate run id for the multi-row ordering rows. Kept DISTINCT from @@ -399,7 +404,9 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { const col = slug(eqlType) const column = (table as unknown as Record)[col] as never const encrypted = unwrapResult( - await client.encrypt('', { + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { table: table as never, column, }), @@ -416,7 +423,9 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { const col = slug(eqlType) const column = (table as unknown as Record)[col] as never const encrypted = unwrapResult( - await client.encrypt('', { + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { table: table as never, column, }), diff --git a/packages/stack/integration/shared/schema-pg.integration.test.ts b/packages/stack/integration/shared/schema-pg.integration.test.ts index a8c8992d..152b9480 100644 --- a/packages/stack/integration/shared/schema-pg.integration.test.ts +++ b/packages/stack/integration/shared/schema-pg.integration.test.ts @@ -1,7 +1,7 @@ import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import type { Encrypted } from '@/types' @@ -28,7 +28,9 @@ type InsertedRow = { type EncryptionPayload = postgres.JSONValue -let protectClient: EncryptionClient +let protectClient: EncryptionClientFor< + readonly [typeof table, typeof typedTable] +> async function encryptValue(value: string): Promise { return unwrapResult( @@ -48,7 +50,7 @@ async function encryptValue(value: string): Promise { // term, so which SQL function you call selects which term is compared. async function encryptOperand( value: unknown, - opts: Parameters[1], + opts: Parameters[1], ): Promise { return unwrapResult( await protectClient.encrypt(value as never, opts), diff --git a/packages/stack/integration/shared/schema-v3-client.integration.test.ts b/packages/stack/integration/shared/schema-v3-client.integration.test.ts index a3b31b3a..80e60e64 100644 --- a/packages/stack/integration/shared/schema-v3-client.integration.test.ts +++ b/packages/stack/integration/shared/schema-v3-client.integration.test.ts @@ -1,6 +1,6 @@ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -19,7 +19,7 @@ const users = encryptedTable('schema_v3_client_users', { }) describe('eql_v3 client integration', () => { - let protectClient: EncryptionClient + let protectClient: EncryptionClientFor beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) From 74bac7fa5a59cbcf9fa42f0a627830919b700acc Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:36:55 +1000 Subject: [PATCH 05/12] ci: typecheck the surfaces that compiled nowhere, and cache dist (C-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four packages had a `tsconfig.json` covering their src and tests that no CI step ever ran, and were already clean — gate them before they drift: migrate, nextjs, examples/prisma and e2e. `packages/nextjs` needed two fixes first: `vi.Mock` was used as a TYPE, which needs `import type { Mock }`. Also declare `outputs: ["dist/**"]` on turbo's `build`. It declared none, so a cache hit replayed logs and restored no files — and the examples/basic gate added by 5fab1cf6 typechecks against `packages/stack/dist/*.d.ts`. It held only because fresh CI runners have no cache. Verified by deleting packages/stack/dist and re-running the gate: cache hit, dist restored, still green. Deliberately NOT gated, with counts recorded in the workflow so the gap is visible rather than silent: @cipherstash/stack (147), stack-drizzle (63), stash (21), stack-supabase (11). Their `test:types` scripts are scoped to `__tests__/**/*.test-d.ts`, so src, the runtime suites and integration/** compile nowhere. Most of the drizzle and supabase count is a single root cause in @cipherstash/test-kit's V3_MATRIX. --- .github/workflows/tests.yml | 25 ++++++++++++++++++++++++ e2e/package.json | 3 ++- packages/migrate/package.json | 1 + packages/nextjs/__tests__/nextjs.test.ts | 6 +++--- packages/nextjs/package.json | 1 + turbo.json | 1 + 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 849feafb..a731c612 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -160,6 +160,31 @@ jobs: - name: Typecheck (examples/basic — guards the v3 stack/stack-drizzle importers) run: pnpm exec turbo run typecheck --filter @cipherstash/basic-example + # The rest of the surfaces that were compiling nowhere. Each of these has + # a `tsconfig.json` that covers its `src` and tests, and each was already + # clean — so they are gated now, before they drift. They resolve their + # workspace dependencies through `dist/*.d.ts`, hence the turbo filters + # (`^build` builds the dependencies first). + # + # NOT gated yet, and deliberately: `@cipherstash/stack` (147 errors under + # its own tsconfig), `@cipherstash/stack-drizzle` (63), `stash` (21) and + # `@cipherstash/stack-supabase` (11). Their `test:types` scripts only + # cover `__tests__/**/*.test-d.ts`, so `src`, the runtime test suites and + # `integration/**` compile nowhere. Most of the drizzle and supabase count + # is one root cause — `V3_MATRIX`'s `indexes` union in + # `@cipherstash/test-kit` — see #778. + - name: Typecheck (migrate) + run: pnpm exec turbo run typecheck --filter @cipherstash/migrate + + - name: Typecheck (nextjs) + run: pnpm exec turbo run typecheck --filter @cipherstash/nextjs + + - name: Typecheck (examples/prisma — guards the prisma-next importers) + run: pnpm exec turbo run typecheck --filter @cipherstash/prisma-next-example + + - name: Typecheck (e2e) + run: pnpm exec turbo run typecheck --filter @cipherstash/e2e + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners diff --git a/e2e/package.json b/e2e/package.json index 06cf30ef..dd10ac34 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,8 @@ "description": "End-to-end tests that exercise built CipherStash binaries and cross-package behaviour.", "type": "module", "scripts": { - "test:e2e": "vitest run" + "test:e2e": "vitest run", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "stash": "workspace:*", diff --git a/packages/migrate/package.json b/packages/migrate/package.json index b14343ca..c96f2f9d 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -42,6 +42,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "test": "vitest run", "lint": "biome check ." diff --git a/packages/nextjs/__tests__/nextjs.test.ts b/packages/nextjs/__tests__/nextjs.test.ts index c15f05b5..e03df244 100644 --- a/packages/nextjs/__tests__/nextjs.test.ts +++ b/packages/nextjs/__tests__/nextjs.test.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' // cts.test.ts -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' // --------------------------------------------- // 1) Mock next/headers before importing it @@ -65,7 +65,7 @@ describe('getCtsToken', () => { accessToken: 'fake_token', expiry: 999999, } - ;(cookies as unknown as vi.Mock).mockReturnValueOnce({ + ;(cookies as unknown as Mock).mockReturnValueOnce({ get: vi.fn().mockReturnValue({ value: JSON.stringify(mockCookieValue) }), }) @@ -78,7 +78,7 @@ describe('getCtsToken', () => { }) it('should return null if the cookie is not present', async () => { - ;(cookies as unknown as vi.Mock).mockReturnValueOnce({ + ;(cookies as unknown as Mock).mockReturnValueOnce({ get: vi.fn().mockReturnValue(undefined), }) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 2d7deeba..f4c512f6 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -33,6 +33,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "release": "tsup" }, diff --git a/turbo.json b/turbo.json index 6ed7b4a7..aa5ff0a0 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], + "outputs": ["dist/**"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "env": ["STASH_POSTHOG_KEY"] }, From 7b6e050673738db79baa9ed6d822e1bd22911878 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:39:31 +1000 Subject: [PATCH 06/12] docs(changesets,skills,prisma-next): record the signature changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Two new changesets: the schema-array widening (stack minor, prisma-next patch) and the typed-client init parity (stack patch). - stack-audit-on-decrypt.md dropped the paragraph telling callers to narrow AnyV3Table[] to readonly [AnyV3Table, ...AnyV3Table[]]. That advice is obsolete, and it never worked for ReadonlyArray — the type prisma-next exposes publicly — since readonly also failed the old tuple constraint. - prisma-next drops its destructure-and-respread workaround. Its comment claimed 'Encryption requires a non-empty schema tuple', which is no longer true; the runtime emptiness check and its error message stay. - skills/stash-encryption documents EncryptionClientFor as the way to name the client, and that schemas need not be an array literal. --- .changeset/encryption-schema-arrays.md | 36 +++++++++++++++++++ .changeset/skills-encryption-client-naming.md | 8 +++++ .changeset/stack-audit-on-decrypt.md | 10 +++--- .changeset/typed-client-init-parity.md | 25 +++++++++++++ .../prisma-next/src/stack/from-stack-v3.ts | 22 ++++-------- skills/stash-encryption/SKILL.md | 17 +++++++++ 6 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 .changeset/encryption-schema-arrays.md create mode 100644 .changeset/skills-encryption-client-naming.md create mode 100644 .changeset/typed-client-init-parity.md diff --git a/.changeset/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md new file mode 100644 index 00000000..2fa9082b --- /dev/null +++ b/.changeset/encryption-schema-arrays.md @@ -0,0 +1,36 @@ +--- +'@cipherstash/stack': minor +'@cipherstash/prisma-next': patch +--- + +`Encryption({ schemas })` now accepts any non-empty array of EQL v3 tables, not +only an array literal. + +Previously the v3 signature required a non-empty *tuple*, so every indirect form +was rejected at compile time even though it worked at runtime: + +```ts +// all of these used to fail with TS2769 +export const schemas: AnyV3Table[] = [users, orders] +await Encryption({ schemas }) + +const readonlySchemas: ReadonlyArray = [users, orders] +await Encryption({ schemas: readonlySchemas }) + +const built: AnyV3Table[] = [] +built.push(users) +await Encryption({ schemas: built }) +``` + +They all compile now. `Encryption({ schemas: [] })` is still a compile error, and +an array literal still gets full per-column typing — passing the wrong plaintext +for a column's domain, or a table the client was not built with, is still +rejected. + +`EncryptionClientFor` is widened to match, so it names the typed client for a +loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic over +its schemas — an adapter that builds a table per request, say — can now write +`EncryptionClientFor` and keep the typed surface. + +If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to +satisfy the old signature, that narrowing is no longer needed. diff --git a/.changeset/skills-encryption-client-naming.md b/.changeset/skills-encryption-client-naming.md new file mode 100644 index 00000000..4960a221 --- /dev/null +++ b/.changeset/skills-encryption-client-naming.md @@ -0,0 +1,8 @@ +--- +'stash': patch +--- + +`skills/stash-encryption` now documents how to name the client's type +(`EncryptionClientFor`, not `Awaited>`, which +always resolves to the untyped nominal client) and states that `schemas` accepts +any non-empty array of v3 tables rather than only an array literal. diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md index de4b34cd..a50a1f0c 100644 --- a/.changeset/stack-audit-on-decrypt.md +++ b/.changeset/stack-audit-on-decrypt.md @@ -34,12 +34,10 @@ return type changes, and the two-argument form reconstructs `Date` columns from `cast_as` instead of leaving them as ISO strings. Code that read those columns as strings needs updating. -The v3 overload takes a non-empty tuple of tables and a `V3ClientConfig` — -`ClientConfig` without the deprecated `eqlVersion` escape hatch. So -`Encryption({ schemas: [] })` no longer type-checks (it used to compile and then -throw), and `config: { eqlVersion: 2 }` selects the nominal overload, which is -the client you actually get back. Callers passing a plain `AnyV3Table[]` rather -than an array literal must narrow it to `readonly [AnyV3Table, ...AnyV3Table[]]`. +The v3 overload takes a `V3ClientConfig` — `ClientConfig` without the deprecated +`eqlVersion` escape hatch. So `Encryption({ schemas: [] })` no longer type-checks +(it used to compile and then throw), and `config: { eqlVersion: 2 }` selects the +nominal overload, which is the client you actually get back. `Awaited>` names the nominal client whatever you pass, because `ReturnType` reads the last overload; use the exported `EncryptionClientFor` to name the client for a schema tuple. diff --git a/.changeset/typed-client-init-parity.md b/.changeset/typed-client-init-parity.md new file mode 100644 index 00000000..7f835a6f --- /dev/null +++ b/.changeset/typed-client-init-parity.md @@ -0,0 +1,25 @@ +--- +'@cipherstash/stack': patch +--- + +The typed EQL v3 client no longer throws `TypeError: init is not a function`. + +`Encryption` selects its overload from the `config` argument but selects the +client it actually returns by inspecting the `schemas`, so the two can disagree. +Hoisting a config into a `ClientConfig`-typed variable is enough to split them — +`ClientConfig.eqlVersion` is `2 | 3`, which the v3 overload's `eqlVersion?: 3` +rejects — so the call types as the nominal `EncryptionClient` while the runtime +still returns the typed client: + +```ts +const config: ClientConfig = { keyset } +const client = await Encryption({ schemas: [users], config }) +// type: EncryptionClient · runtime: TypedEncryptionClient +``` + +`init` was the only member of `EncryptionClient` missing from the typed client, +so any call through the declared type crashed. It is now delegated. + +The type still under-reports in this case: you lose the typed surface with no +diagnostic. Pass the config inline, or type the variable as `V3ClientConfig`, to +keep it. diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index 2f569b55..bcc012cf 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -89,11 +89,8 @@ export async function cipherstashFromStack( ) } - // Destructured rather than length-checked so the non-emptiness survives into - // the type layer: `Encryption` requires a non-empty schema tuple (an empty one - // used to type-check and then throw at runtime). - const [firstDerived, ...restDerived] = deriveStackSchemasV3(opts.contractJson) - if (firstDerived === undefined) { + const derived = deriveStackSchemasV3(opts.contractJson) + if (derived.length === 0) { throw new Error( 'cipherstashFromStack: no v3 cipherstash columns found in contract.json. ' + 'Declare at least one v3 `cipherstash.*()` column (e.g. `cipherstash.TextSearch()`) in prisma/schema.prisma ' + @@ -101,10 +98,7 @@ export async function cipherstashFromStack( ) } - const schemas = resolveV3Schemas( - [firstDerived, ...restDerived], - opts.schemasV3, - ) + const schemas = resolveV3Schemas(derived, opts.schemasV3) const encryptionClient = await EncryptionV3({ schemas, @@ -144,18 +138,15 @@ function collectNonV3CipherstashCodecIds( return [...ids].sort() } -/** A schema list carrying its non-emptiness in the type, as `Encryption` wants. */ -type NonEmptyV3Schemas = readonly [AnyV3Table, ...AnyV3Table[]] - /** * Validate contract-declared tables against their overrides (exact * domain identity) and append override-only tables — same merge * semantics as the v2 `resolveSchemas`. */ function resolveV3Schemas( - derived: NonEmptyV3Schemas, + derived: ReadonlyArray, override: ReadonlyArray | undefined, -): NonEmptyV3Schemas { +): ReadonlyArray { if (override === undefined || override.length === 0) return derived const derivedByName = new Map(derived.map((t) => [t.tableName, t])) @@ -168,8 +159,7 @@ function resolveV3Schemas( } return [ - derived[0], - ...derived.slice(1), + ...derived, ...override.filter((t) => !derivedByName.has(t.tableName)), ] } diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 3c780c1c..1ddab9b8 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -312,6 +312,23 @@ const client = await Encryption({ schemas: [users] }) - `EncryptionV3` (from `@cipherstash/stack/v3`) is a **deprecated, type-identical alias** of `Encryption`, kept for backwards compatibility. New code should use `Encryption`. - `typedClient(client, ...schemas)` (exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface, if you built one via a lower-level path. - **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. +- `schemas` takes any non-empty array of v3 tables — a shared `export const schemas: AnyV3Table[]`, a `ReadonlyArray`, one built at runtime. It does not have to be an array literal. An empty array is a compile error. +- **To name the client's type, use `EncryptionClientFor`** (from `@cipherstash/stack/v3`), not `Awaited>`. `Encryption` is overloaded and `ReturnType` reads the last overload, so that idiom always resolves to the untyped nominal client: + +```typescript +import { Encryption, type EncryptionClientFor, encryptedTable, types } from "@cipherstash/stack/v3" +import type { AnyV3Table } from "@cipherstash/stack/eql/v3" + +const users = encryptedTable("users", { email: types.TextSearch("email") }) + +// A named schema tuple keeps per-column typing. +let client: EncryptionClientFor +client = await Encryption({ schemas: [users] }) + +// Code that is generic over its schemas keeps the typed surface too. +function withClient(c: EncryptionClientFor) { /* … */ } +``` + ```typescript // Error handling From d33cece58dbb30d33221014f22199fd00e2ac2bb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:41:49 +1000 Subject: [PATCH 07/12] ci: exempt bench from the dist outputs cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/bench`'s build is `tsc --noEmit` — a typecheck, not a bundle — so the repo-wide `outputs: ["dist/**"]` added alongside it warned 'no output files found' on every run. --- turbo.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/turbo.json b/turbo.json index aa5ff0a0..f7c6b2dd 100644 --- a/turbo.json +++ b/turbo.json @@ -20,6 +20,12 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "cache": false }, + // `packages/bench`'s "build" is `tsc --noEmit` — a typecheck, not a bundle — + // so it emits nothing and the repo-wide `dist/**` would warn on every run. + "@cipherstash/bench#build": { + "dependsOn": ["^build"], + "outputs": [] + }, "typecheck": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$"] From 58bff6f040ac3ea794e1e4806ffb855c61d0fb70 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 22:07:06 +1000 Subject: [PATCH 08/12] fix(cli,skills,stack-supabase): correct v2 cutover read guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review of this branch. Two guidance defects that would have shipped into customer repos: - `skills/stash-encryption` claimed reads of the promoted column return "decrypted ciphertext transparently" after `stash encrypt cutover`. Only CipherStash Proxy decrypts on the wire; SDK/ORM reads still return ciphertext, which is what the very next table row says. An agent following the table would return raw `eql_v2_encrypted` composites to end users. - `stash init`'s setup prompt told agents to declare an EQL v2 cutover target with a `types.*` domain. `types.*` is the v3 domain family; a v2 column is an `eql_v2_encrypted` composite. v2 is a read path now, so the branch points at the deprecated `@cipherstash/stack/schema` builders, decrypting through `@cipherstash/stack`. Also: the `EncryptedSingleQueryBuilder` doc comment said a method omitted from the interface "is absent at runtime too". `single()` returns `this` (query-builder.ts:479), so every filter and transform remains a property of the object — the interface narrows the static API only. Skipped three markdownlint findings (MD040 aside): this repo runs no markdownlint, and MD028 on the supabase skill and the code-span whitespace in the rewriter changeset are both correct as written under CommonMark. --- .changeset/skills-v3-lifecycle-honesty.md | 11 +++++++++++ ...encryption-signature-and-typecheck-gates-design.md | 2 +- packages/cli/src/commands/init/lib/setup-prompt.ts | 2 +- packages/stack-supabase/src/types.ts | 6 ++++-- skills/stash-encryption/SKILL.md | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.changeset/skills-v3-lifecycle-honesty.md b/.changeset/skills-v3-lifecycle-honesty.md index 5808d215..350dc68d 100644 --- a/.changeset/skills-v3-lifecycle-honesty.md +++ b/.changeset/skills-v3-lifecycle-honesty.md @@ -22,6 +22,17 @@ case, expect the wrong column to be dropped. ship — on a v3-only database `db push` reports "Nothing to do." and exits 0, and `db activate` errors. The call-outs are now scoped to the EQL v2 + Proxy path. +- The `stash encrypt cutover` row claimed application reads of the promoted + column "return decrypted ciphertext transparently". That holds only through + CipherStash Proxy, and it contradicted the next row, which requires the + decrypt path — an agent following the table would return raw + `eql_v2_encrypted` composites to end users. SDK/ORM reads are now stated to + need the explicit decrypt path. +- `stash init`'s setup prompt told agents to declare an **EQL v2** cutover + target with a `types.*` domain. Those are EQL v3 only; a v2 column is an + `eql_v2_encrypted` composite. The v2 branch now points at the deprecated + `@cipherstash/stack/schema` builders as a read-only path, decrypting through + `@cipherstash/stack`. Skills ship inside the `stash` tarball and are copied into user projects at `stash init`, so this guidance was being installed into customer repos. diff --git a/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md index 5776ed1d..c8c1cdb9 100644 --- a/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md +++ b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md @@ -90,7 +90,7 @@ TypeScript's `ReturnType` reads the *last* overload, which is the nominal one. S `Awaited>` yields `EncryptionClient` even for an all-v3 schema set, and assigning the real client to it fails: -``` +```text Type 'TypedEncryptionClient<…>' is missing the following properties from type 'EncryptionClient': client, encryptConfig, init ``` diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index dd5ba0de..c0e22ba2 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -303,7 +303,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '#### Backfill and switch — after dual-writes are live', '', `3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, - `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2 (legacy data only):** update the schema file to declare the encrypted column under its final name (drop the twin suffix), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`). The adapters no longer author v2 — \`@cipherstash/stack-drizzle\` removed \`encryptedType\` — so declare the column with a \`types.*\` v3 domain and reach v2 rows through \`@cipherstash/stack\`'s decrypt path.`, + `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2 (legacy data only):** update the schema file to declare the encrypted column under its final name (drop the twin suffix), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`). Do **not** declare a v2 column with a \`types.*\` domain — those are EQL v3 only. The adapters no longer author v2 (\`@cipherstash/stack-drizzle\` removed \`encryptedType\`), so a v2 column is a read path: declare it with the deprecated \`@cipherstash/stack/schema\` builders and decrypt through \`@cipherstash/stack\`.`, '5. **Wire the read path through the encryption client.** The read column now holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw encrypted payloads to end users. The integration skill has the exact API.', '6. **Remove the dual-write code.** The plaintext column (still `` on v3; renamed `_plaintext` on v2) is no longer authoritative. Delete the dual-write logic from the persistence layer.', `7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused plaintext column (on v3 it first verifies no rows are still plaintext-only). Apply with the project's normal migration tooling.`, diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index 6755b8b5..e19f685e 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -464,8 +464,10 @@ export interface TypedEncryptedSupabaseInstance { * * What IS carried is exactly: `then` (via `PromiseLike`), `abortSignal`, * `throwOnError`, `returns`, `withLockContext` and `audit`. That is a - * hand-written list, not a passthrough — `single()`/`maybeSingle()` return the - * same builder instance, so a method absent here is absent at runtime too. + * hand-written list, not a passthrough. It narrows the static API only: + * `single()`/`maybeSingle()` return the same builder instance, so a method + * omitted here is still a property of that object at runtime — the type is what + * stops you reaching it, not the implementation. * * It is therefore NOT parity with postgrest-js, which carries a different set: * its `single()` returns a `PostgrestBuilder`, carrying diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 1ddab9b8..185ee801 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -875,7 +875,7 @@ Once dual-writes are recorded as live in `cs_migrations`: |---|---| | `stash encrypt backfill` | Walks the table in keyset-pagination order, encrypts each chunk, writes a single transactional `UPDATE` per chunk plus a `cs_migrations` checkpoint. SIGINT-safe; idempotent re-runs converge. | | Schema rename (**v2 only**) | Update the schema file: drop the `_encrypted` suffix; switch the original column declaration onto the encrypted type. **v3:** there is no rename — leave `_encrypted` under its own name and point the schema/queries at that name instead. | -| `stash encrypt cutover` (**v2 only**) | One transaction: renames `` → `_plaintext`, `_encrypted` → ``, and promotes the `eql_v2_configuration` row `pending` → `active`. Application reads of `` now return decrypted ciphertext transparently. **v3:** cutover does not apply — on a backfilled v3 column it reports "not applicable" and exits 0 without changing anything; skip this row. | +| `stash encrypt cutover` (**v2 only**) | One transaction: renames `` → `_plaintext`, `_encrypted` → ``, and promotes the `eql_v2_configuration` row `pending` → `active`. Reads of `` through **CipherStash Proxy** are decrypted on the wire; SDK/ORM reads still return ciphertext and need the decrypt path in the next row. **v3:** cutover does not apply — on a backfilled v3 column it reports "not applicable" and exits 0 without changing anything; skip this row. | | Wire reads through the encryption client | Read paths must decrypt before returning the value to callers (`decryptModel(row, table)` for Drizzle; the Supabase wrapper for Supabase; `decrypt`/`bulkDecryptModels` otherwise). Without this step, reads return raw EQL payloads to end users (a `public.eql_v3_*` jsonb document on v3; an `eql_v2_encrypted` composite on a legacy v2 column). | | Remove dual-write code | The plaintext column is no longer authoritative — **v2:** it is now `_plaintext` (the cutover renamed it); **v3:** it is still the original ``, since nothing was renamed. Either way, delete the dual-write logic once reads are served from the encrypted column. | | `stash encrypt drop` | Emits a migration that drops the plaintext column — and *which* column that is depends on the generation. **v2** (precondition: phase `cut-over`): a plain `ALTER TABLE … DROP COLUMN "_plaintext"`. **v3** (precondition: phase `backfilled`): it drops the **original ``** — there is no `_plaintext` — and the generated SQL is a `DO` block that takes `ACCESS EXCLUSIVE` on the table, re-counts rows with `` set and `_encrypted` NULL *at apply time*, and raises instead of dropping if any remain. Apply with the project's normal migration tooling. | From 5ea066f802b8c37d3b70e7568ee8ce4ff7d0b6e8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 22:12:48 +1000 Subject: [PATCH 09/12] docs(changesets): drop the redundant lifecycle bullets The existing changeset already states that the bundled skills described the v2 lifecycle as the default and that the guidance ships into customer repos. Enumerating each individual correction adds nothing for a reader of the changelog. --- .changeset/skills-v3-lifecycle-honesty.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.changeset/skills-v3-lifecycle-honesty.md b/.changeset/skills-v3-lifecycle-honesty.md index 350dc68d..5808d215 100644 --- a/.changeset/skills-v3-lifecycle-honesty.md +++ b/.changeset/skills-v3-lifecycle-honesty.md @@ -22,17 +22,6 @@ case, expect the wrong column to be dropped. ship — on a v3-only database `db push` reports "Nothing to do." and exits 0, and `db activate` errors. The call-outs are now scoped to the EQL v2 + Proxy path. -- The `stash encrypt cutover` row claimed application reads of the promoted - column "return decrypted ciphertext transparently". That holds only through - CipherStash Proxy, and it contradicted the next row, which requires the - decrypt path — an agent following the table would return raw - `eql_v2_encrypted` composites to end users. SDK/ORM reads are now stated to - need the explicit decrypt path. -- `stash init`'s setup prompt told agents to declare an **EQL v2** cutover - target with a `types.*` domain. Those are EQL v3 only; a v2 column is an - `eql_v2_encrypted` composite. The v2 branch now points at the deprecated - `@cipherstash/stack/schema` builders as a read-only path, decrypting through - `@cipherstash/stack`. Skills ship inside the `stash` tarball and are copied into user projects at `stash init`, so this guidance was being installed into customer repos. From 1326b7306acb00e54a27f7e4ba8bf5078e89287a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 22:19:21 +1000 Subject: [PATCH 10/12] fix(stack): make typed encryptQuery callable against the built package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client.encryptQuery(value, { table, column })` did not typecheck for ANY column when imported from the published package — every plaintext resolved to `never`. Reproduced identically against the rc.4 tarball on npm, released main @ b48494ce, origin/remove-v2 and this branch, so it predates the v2 removal and shipped in every release carrying the v3 typed client. `PlaintextForColumn` / `QueryTypesForColumn` recover the domain via `C extends EncryptedV3Column`, which needs `D` bare in the instance type. Its only bare site was `private readonly definition: D`, and tsc strips private member types on declaration emit — leaving only `D['eqlType']`, `D['capabilities']` and `QueryableFlag`, none of which TypeScript can invert. So `infer D` fell back to the V3DomainDefinition constraint: QueryTypesForColumn -> never -> QueryableColumnsOf -> never -> plaintext never. `encrypt` escaped it by resolving through `ColumnsOf`. Fix is a type-only `declare readonly __domain?: D`: nothing emitted at runtime, no constructor or call-site change, but it survives declaration emit and restores the inference site. Adds `packages/stack/dist-types` and a `test:types:dist` script that typechecks the EMITTED declarations rather than source, wired into CI. That gap is why this survived the whole rc series — every other type test imports `@/…`. Verified red without the fix (three errors, including the original `never`) and green with it. Found while converting the A-6 call sites: the same tests that would have caught it were holding the client through the broken `ReturnType` idiom. --- .changeset/typed-encrypt-query-dist.md | 34 ++++++++++++ .github/workflows/tests.yml | 8 +++ packages/stack/dist-types/README.md | 15 ++++++ packages/stack/dist-types/encrypt-query.ts | 63 ++++++++++++++++++++++ packages/stack/dist-types/tsconfig.json | 14 +++++ packages/stack/package.json | 1 + packages/stack/src/eql/v3/columns.ts | 26 +++++++++ turbo.json | 6 +++ 8 files changed, 167 insertions(+) create mode 100644 .changeset/typed-encrypt-query-dist.md create mode 100644 packages/stack/dist-types/README.md create mode 100644 packages/stack/dist-types/encrypt-query.ts create mode 100644 packages/stack/dist-types/tsconfig.json diff --git a/.changeset/typed-encrypt-query-dist.md b/.changeset/typed-encrypt-query-dist.md new file mode 100644 index 00000000..1fce605e --- /dev/null +++ b/.changeset/typed-encrypt-query-dist.md @@ -0,0 +1,34 @@ +--- +'@cipherstash/stack': patch +--- + +Fixed: `encryptQuery` on the typed EQL v3 client did not typecheck against the +published package — for any column. + +```ts +const users = encryptedTable('users', { email: types.TextSearch('email') }) +const client = await Encryption({ schemas: [users] }) + +client.encrypt('a@b.com', { table: users, column: users.email }) // fine +client.encryptQuery('a@b.com', { table: users, column: users.email }) +// TS2345: Argument of type '"a@b.com"' is not assignable to parameter of type 'never' +``` + +`PlaintextForColumn` and `QueryTypesForColumn` recover a column's domain with +`C extends EncryptedV3Column`, which needs `D` to appear bare somewhere +in the instance type. Its only bare occurrence was a **private** field, and +`tsc` strips the types of private members on declaration emit — so in the +shipped `.d.ts` the inference fell back to the `V3DomainDefinition` constraint. +`QueryTypesForColumn` collapsed to `never`, which made `QueryableColumnsOf` +`never`, which typed every query plaintext `never`. `encrypt` was unaffected +because it resolves through `ColumnsOf`. + +`EncryptedV3Column` now carries a type-only `declare readonly __domain?: D`. +Nothing is emitted at runtime and no call site changes; the declaration survives +emit and restores the inference site. + +This affected every published release with the v3 typed client, including +`1.0.0-rc.4` — the searchable-query recipes in the `stash-encryption` skill did +not compile in a customer's repo. It was invisible in CI because the type tests +import source rather than `dist/`; a new `test:types:dist` suite now typechecks +the emitted declarations and is wired into CI. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a731c612..41415691 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -185,6 +185,14 @@ jobs: - name: Typecheck (e2e) run: pnpm exec turbo run typecheck --filter @cipherstash/e2e + # Everything else typechecks against SOURCE. This one reads the emitted + # `.d.ts`, which is what customers consume — and where typed `encryptQuery` + # sat broken for every column through the whole rc series, because `tsc` + # strips private member types and that was the only place the column's + # domain parameter appeared bare. + - name: Typecheck (stack — emitted declarations, not source) + run: pnpm exec turbo run test:types:dist --filter @cipherstash/stack + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners diff --git a/packages/stack/dist-types/README.md b/packages/stack/dist-types/README.md new file mode 100644 index 00000000..69e727c9 --- /dev/null +++ b/packages/stack/dist-types/README.md @@ -0,0 +1,15 @@ +# Declaration-emit contract + +These files typecheck against `../dist`, not `../src`. + +Every other type test in this package imports `@/…`, which resolves to source. +That is the right default — it keeps the tests fast and independent of a build — +but it means nothing checked what customers actually consume, and a defect lived +in the published `.d.ts` for the whole rc series because of it: +`EncryptedV3Column`'s domain parameter `D` was recoverable in source and not in +the emitted declarations, so typed `encryptQuery` was uncallable for every column +against the published package. + +Anything whose correctness depends on what `tsc` *emits* — rather than on what +the source says — belongs here. Requires a build first; wired into CI as +`test:types:dist`. diff --git a/packages/stack/dist-types/encrypt-query.ts b/packages/stack/dist-types/encrypt-query.ts new file mode 100644 index 00000000..c90fd752 --- /dev/null +++ b/packages/stack/dist-types/encrypt-query.ts @@ -0,0 +1,63 @@ +/** + * Typed `encryptQuery` must be callable against the BUILT package. + * + * `PlaintextForColumn` / `QueryTypesForColumn` recover a column's domain with + * `C extends EncryptedV3Column`, which needs `D` to appear bare in the + * instance type. Its only bare site in source is a PRIVATE field, and `tsc` + * strips private member types on declaration emit — so in the published `.d.ts` + * the inference fell back to the `V3DomainDefinition` constraint and every + * derived helper collapsed. `QueryTypesForColumn` became `never`, which made + * `QueryableColumnsOf` `never`, which typed the plaintext `never`: + * + * client.encryptQuery('a@b.com', { table: users, column: users.email }) + * // TS2345: Argument of type '"a@b.com"' is not assignable to type 'never' + * + * `encrypt` was unaffected (it resolves through `ColumnsOf`), so a smoke test + * of the built types would have missed it. Assert the query path explicitly. + */ + +import { Encryption, encryptedTable, types } from '../dist/encryption/v3.js' +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '../dist/eql/v3/index.js' + +// The two helpers, on the concrete column class, straight from the emitted types. +const plaintext: string = + null as unknown as PlaintextForColumn +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + // The narrowing must survive emit too, or the gate above passes vacuously. + // @ts-expect-error - `email` is a text domain, not a number + client.encryptQuery(123, { table: users, column: users.email }) + // @ts-expect-error - `searchableJson` is not a capability of a text_search column + client.encryptQuery('x', { + table: users, + column: users.email, + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/tsconfig.json b/packages/stack/dist-types/tsconfig.json new file mode 100644 index 00000000..4ec0246f --- /dev/null +++ b/packages/stack/dist-types/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "customConditions": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/stack/package.json b/packages/stack/package.json index 32a167b6..24580eb2 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -198,6 +198,7 @@ "db:eql-v3:install": "tsx scripts/install-eql-v3.ts", "test": "vitest run", "test:types": "vitest --run --typecheck.only", + "test:types:dist": "tsc --noEmit -p dist-types/tsconfig.json", "release": "tsup", "test:integration": "vitest run --config integration/vitest.config.ts" }, diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index 30c46ce8..d6094110 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -432,6 +432,32 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { * nominality is what keeps plaintext inference precise. */ export class EncryptedV3Column { + /** + * Phantom carrier for `D`, and the reason this class is usable from the + * BUILT package at all. + * + * `PlaintextForColumn` / `QueryTypesForColumn` recover the domain with + * `C extends EncryptedV3Column`. That inference needs `D` to appear + * BARE somewhere in the instance type. In source the only such site is + * `private readonly definition: D` — but `tsc` strips the types of private + * members on declaration emit, so the shipped `.d.ts` says + * `private readonly definition;` and the site disappears. What is left is + * `getEqlType(): D['eqlType']`, `getQueryCapabilities(): D['capabilities']` + * and `isQueryable(): QueryableFlag` — all uninvertible, so `infer D` fell + * back to the `V3DomainDefinition` constraint and every helper collapsed: + * `QueryTypesForColumn` to `never`, `PlaintextForColumn` to the union of + * every domain's plaintext. Typed `encryptQuery` was therefore uncallable for + * ANY column against the published package. + * + * `declare` means type-only: no field is emitted, no runtime cost, no + * constructor change — but the declaration survives emit and gives `infer D` + * the bare site it needs. Optional so no call site has to supply it. + * + * @internal Not for consumption; read the domain via `getEqlType()` / + * `getQueryCapabilities()`. + */ + declare readonly __domain?: D + constructor( private readonly columnName: string, private readonly definition: D, diff --git a/turbo.json b/turbo.json index f7c6b2dd..ff8873f3 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,12 @@ "dependsOn": ["^build"], "outputs": [] }, + // Typechecks `packages/stack/dist-types` against the BUILT declarations, so + // it must run after `build` — unlike `test:types`, which reads source. + "test:types:dist": { + "dependsOn": ["build"], + "inputs": ["$TURBO_DEFAULT$"] + }, "typecheck": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$"] From 74b6755893c15c41f913407fcabacea2fb332e34 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 22:22:41 +1000 Subject: [PATCH 11/12] fix(stack): scope the dist type gate correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the gate added in the previous commit: - Exclude `dist-types` from `packages/stack/tsconfig.json`. That config maps `@/*` and the `@cipherstash/stack/*` subpaths to SOURCE, so checking these files there resolved their imports to the very thing they exist to avoid and reported two failures that said nothing about what ships. - Move the `@ts-expect-error` directives onto the exact lines the errors are reported at. A directive only covers the following line, and the formatter is free to split a call across several — which it did, silently turning one negative into 'unused directive' and letting the other through as a real error. Re-proved red/green by deleting the phantom field and rebuilding: 4 errors without it, including the original `never`; clean with it. --- packages/stack/dist-types/encrypt-query.ts | 14 ++++++++++---- packages/stack/tsconfig.json | 7 ++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/stack/dist-types/encrypt-query.ts b/packages/stack/dist-types/encrypt-query.ts index c90fd752..a8b0ac83 100644 --- a/packages/stack/dist-types/encrypt-query.ts +++ b/packages/stack/dist-types/encrypt-query.ts @@ -51,13 +51,19 @@ export async function callable() { queryType: 'orderAndRange', }) - // The narrowing must survive emit too, or the gate above passes vacuously. - // @ts-expect-error - `email` is a text domain, not a number - client.encryptQuery(123, { table: users, column: users.email }) - // @ts-expect-error - `searchableJson` is not a capability of a text_search column + // The narrowing must survive emit too, or the assertions above pass + // vacuously. Each directive sits on the exact line the error is reported at — + // `@ts-expect-error` only covers the following line, and the formatter is free + // to split these calls across several. + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) client.encryptQuery('x', { table: users, column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search queryType: 'searchableJson', }) } diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index ddc4ca84..7b8c05f9 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -58,5 +58,10 @@ // depend on `build`, so CI can run type tests against an older dist). "@cipherstash/stack/errors": ["./src/errors/index.ts"] } - } + }, + // `dist-types/` deliberately typechecks against the EMITTED declarations, so + // it has its own tsconfig with none of the `@/*` source paths above. Checking + // it here too would resolve its imports differently and report failures that + // say nothing about what ships. Run it with `pnpm test:types:dist`. + "exclude": ["dist-types"] } From ded4921d3c034820e6213086e6d8e671ee6612d8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Sat, 25 Jul 2026 14:23:56 +1000 Subject: [PATCH 12/12] fix(stack): refuse EQL v2 wire over an all-v3 schema set (A-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncryptionV3 used to force `eqlVersion: 3` over whatever the caller passed, and its comment said that override was the whole point: a v2-mode client cannot author v3 concrete-domain columns. Collapsing EncryptionV3 into a deprecated alias of Encryption deleted the override, and nothing else caught the combination — resolveEqlVersion throws for a mixed set and for legacy v2 searchableJson, then returns an explicit version unchanged. So `EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` went from silently auto-corrected-and-working to silently building a v2-wire client, with no diagnostic anywhere. Verified against the FFI boundary: newClient received eqlVersion 2, and the encryptConfig and the args reaching ffi.encrypt were byte-identical to the v3 case — only the flag differed. The failure surfaces later, as an eql_v3_* domain CHECK rejecting the write, or as v2 wire landing in a v3 column wherever the check is looser. Throw instead, keyed on `v3Count === schemas.length` so the hatch survives exactly where it is used: minting v2 wire from a V2 schema set, which is what integration/shared/v2-decrypt-compat.integration.test.ts does. Nothing in the repo mints v2 wire from a v3 table — the unit test that asserted that shape was pure, with a rationale ("for minting v2 wire during a migration") no consumer ever exercised. Retargeted, along with the two init-strategy tests that pinned the same behaviour with comments already hedging about it. Also widens stack-drizzle's typecheck gate to the two integration adapters that this branch made zero-error (A-6). The remaining 63 errors are still ungated and still recorded in the workflow; most are one root cause in test-kit's V3_MATRIX (#778). Verified the widened gate actually fails on an injected error rather than silently including nothing. --- .changeset/reject-v2-wire-over-v3-schemas.md | 30 +++++++++++++ .../stack-drizzle/tsconfig.typecheck.json | 15 ++++++- .../__tests__/encryption-overloads.test-d.ts | 16 ++++--- .../stack/__tests__/init-strategy.test.ts | 44 +++++++++++++------ .../__tests__/resolve-eql-version.test.ts | 37 +++++++++++++++- packages/stack/src/encryption/index.ts | 22 ++++++++++ 6 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 .changeset/reject-v2-wire-over-v3-schemas.md diff --git a/.changeset/reject-v2-wire-over-v3-schemas.md b/.changeset/reject-v2-wire-over-v3-schemas.md new file mode 100644 index 00000000..a4b90ccf --- /dev/null +++ b/.changeset/reject-v2-wire-over-v3-schemas.md @@ -0,0 +1,30 @@ +--- +'@cipherstash/stack': minor +--- + +`Encryption({ schemas, config: { eqlVersion: 2 } })` now throws when every table +in `schemas` is an EQL v3 table, instead of building a client that writes EQL v2 +payloads into `eql_v3_*` columns. + +`EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over whatever the +caller passed — the override's comment said that was why it existed. Collapsing +`EncryptionV3` into a deprecated alias of `Encryption` removed the override, and +nothing else rejected the combination: `resolveEqlVersion` already threw for a +mixed v2/v3 schema set and for legacy v2 `searchableJson()`, but returned an +explicit version unchanged. So a caller upgrading from +`EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — previously +auto-corrected, and working — silently got a v2-wire client instead, with no +diagnostic at any layer. The type surface agreed with the runtime (both say +"nominal client"), so nothing disagreed loudly enough to notice, and the failure +surfaced later as an `eql_v3_*` domain CHECK rejecting the write, or as v2 wire +landing in a v3 column wherever the check is looser. + +The escape hatch itself is unchanged where it is actually used: an explicit +`eqlVersion: 2` over an **EQL v2** schema set still emits v2 wire, which is how +v2 payloads are minted for the read-compatibility suite. Mixed sets still throw +the existing mixing error. Reading v2 payloads is unaffected — `decrypt` and +`decryptModel` continue to read both generations regardless of the client's wire +version. + +If you hit the new error, drop `config.eqlVersion` to emit v3, or build the +client from the EQL v2 schema you actually intend to write. diff --git a/packages/stack-drizzle/tsconfig.typecheck.json b/packages/stack-drizzle/tsconfig.typecheck.json index 9d06679f..16d312f2 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -1,4 +1,17 @@ { + // The typecheck gate CI runs (`test:types` -> vitest typecheck) covers the + // `.test-d.ts` files plus the two `integration/**` adapters that #772's + // review found were erroring in a file nothing compiled. + // + // `src/`, the runtime `__tests__/*.test.ts` and the rest of `integration/**` + // are still ungated: 63 errors under the full `tsconfig.json`, most of them + // one root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit` + // (#778). Widen this `include` as that count comes down; do not widen it to + // the whole project until it is zero, or the gate is useless. "extends": "./tsconfig.json", - "include": ["__tests__/**/*.test-d.ts"] + "include": [ + "__tests__/**/*.test-d.ts", + "integration/adapter.ts", + "integration/json-adapter.ts" + ] } diff --git a/packages/stack/__tests__/encryption-overloads.test-d.ts b/packages/stack/__tests__/encryption-overloads.test-d.ts index 82cc1f97..7bbd1cff 100644 --- a/packages/stack/__tests__/encryption-overloads.test-d.ts +++ b/packages/stack/__tests__/encryption-overloads.test-d.ts @@ -92,11 +92,17 @@ describe('overload selection', () => { client.encrypt('2020-01-01', { table: users, column: users.createdAt }) }) - // S-4: forcing v2 wire over v3 schemas returns the NOMINAL client at runtime - // (the typed client cannot author v3 columns in v2 mode). The types used to - // claim the typed client, so `decryptModel(row, table, lockContext)` compiled - // and then silently dropped `table` and `lockContext`. - it('forcing eqlVersion 2 over v3 schemas yields the nominal client', async () => { + // S-4: `eqlVersion: 2` selects the NOMINAL overload, because the typed client + // cannot author v3 columns in v2 mode. The types used to claim the typed + // client, so `decryptModel(row, table, lockContext)` compiled and then + // silently dropped `table` and `lockContext`. + // + // Over an all-v3 schema set this combination is now REJECTED AT RUNTIME + // (#772 review, finding 8) — v2 wire into `eql_v3_*` columns is a + // contradiction, and `EncryptionV3` used to force `eqlVersion: 3` precisely + // to stop it. This assertion therefore records overload resolution only; the + // call itself throws. `init-strategy.test.ts` pins the throw. + it('forcing eqlVersion 2 selects the nominal overload (and is refused at runtime for v3 schemas)', async () => { const client = await Encryption({ schemas: [users], config: { eqlVersion: 2 }, diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 1b29b435..b9357193 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -228,8 +228,23 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('lets an explicit eqlVersion 2 override v3 auto-detection (migration escape hatch)', async () => { - await Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }) + // #772 review, finding 8. This used to be honoured, and the resulting client + // wrote `eql_v2_encrypted` payloads into `eql_v3_*` columns with no + // diagnostic at any layer — the type surface types it as the nominal client, + // which matches what the runtime returns, so nothing disagreed loudly enough + // to notice. + it('rejects an explicit eqlVersion 2 over a v3 schema set before constructing the FFI client', async () => { + await expect( + Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }), + ).rejects.toThrow(/entirely EQL v3/) + + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + // The escape hatch survives where it is actually used: minting v2 wire from + // a v2 schema set, which is how the v2-read-compat fixtures are produced. + it('still honours an explicit eqlVersion 2 for a v2 schema set', async () => { + await Encryption({ schemas: [users], config: { eqlVersion: 2 } }) expect(lastNewClientOpts().eqlVersion).toBe(2) }) @@ -254,17 +269,20 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('EncryptionV3 is a deprecated alias of Encryption — it honours an explicit eqlVersion identically', async () => { - // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it no - // longer independently pins the wire format: an explicit `eqlVersion: 2` over - // a v3 schema set is honoured exactly as `Encryption` honours it (the - // migration escape hatch above). A v2-mode client still cannot encrypt v3 - // concrete-type columns — this only pins the wire flag reaching the FFI. - await EncryptionV3({ - schemas: [v3Table() as never], - config: { eqlVersion: 2 }, - }) + it('EncryptionV3 rejects an explicit eqlVersion 2 identically', async () => { + // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it + // no longer independently pins the wire format. The invariant it used to + // enforce by forcing `eqlVersion: 3` now lives in `resolveEqlVersion`, so + // the outcome for a caller upgrading from the old `EncryptionV3` is the + // same in the case that matters: the contradiction is refused rather than + // silently building a v2-wire client over v3 concrete domains. + await expect( + EncryptionV3({ + schemas: [v3Table() as never], + config: { eqlVersion: 2 }, + }), + ).rejects.toThrow(/entirely EQL v3/) - expect(lastNewClientOpts().eqlVersion).toBe(2) + expect(ffi.newClient).not.toHaveBeenCalled() }) }) diff --git a/packages/stack/__tests__/resolve-eql-version.test.ts b/packages/stack/__tests__/resolve-eql-version.test.ts index e2e93f0c..2ec1d0c5 100644 --- a/packages/stack/__tests__/resolve-eql-version.test.ts +++ b/packages/stack/__tests__/resolve-eql-version.test.ts @@ -86,14 +86,47 @@ describe('resolveEqlVersion — legacy v2 searchable JSON', () => { }) describe('resolveEqlVersion — explicit config.eqlVersion', () => { - it('honours an explicit 2 over v3 schemas, for minting v2 wire during a migration', () => { - expect(resolveEqlVersion([usersV3], 2)).toBe(2) + // #772 review, finding 8. `EncryptionV3` used to force `eqlVersion: 3` over + // whatever the caller passed, with a comment saying it existed to stop + // exactly this. It is now a bare alias of `Encryption`, so the override is + // gone and nothing rejected the combination — the client built v2-wire over + // v3 concrete domains, which writes an `eql_v2_encrypted` payload into an + // `eql_v3_*` column. `resolveEqlVersion` already throws for a mixed set and + // for v2 SteVec; this was the one contradiction it let through. + it('rejects an explicit 2 over an all-v3 schema set', () => { + expect(() => resolveEqlVersion([usersV3], 2)).toThrow( + /cannot emit EQL v2 wire for a schema set that is entirely EQL v3/, + ) + }) + + it('rejects an explicit 2 over several v3 tables', () => { + expect(() => resolveEqlVersion([usersV3, ordersV3], 2)).toThrow( + /entirely EQL v3/, + ) + }) + + // The escape hatch itself survives, and this is the shape that actually uses + // it: `integration/shared/v2-decrypt-compat.integration.test.ts` mints its v2 + // fixtures from a v2 schema set. Nothing mints v2 wire from a v3 table. + it('still honours an explicit 2 over a v2 schema set', () => { + expect(resolveEqlVersion([usersV2], 2)).toBe(2) }) it('honours an explicit 3 over a v2 schema set', () => { expect(resolveEqlVersion([usersV2], 3)).toBe(3) }) + it('honours an explicit 3 over a v3 schema set', () => { + expect(resolveEqlVersion([usersV3], 3)).toBe(3) + }) + + // An empty set never reaches here through `Encryption` — the caller throws + // first — but the function is exported, so pin that it does not invent a + // wire version for a set with no evidence in it either way. + it('does not reject an explicit 2 for an empty schema set', () => { + expect(resolveEqlVersion([], 2)).toBe(2) + }) + it('does not let an explicit version rescue a mixed schema set', () => { // Mixing is unfixable by declaration: the two generations target different // column types, so no single wire format serves both. diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 39b8ff90..a0a3f0c4 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -124,6 +124,28 @@ export function resolveEqlVersion( ) } + // An explicit 2 over a set that is ENTIRELY v3 is a contradiction, and a + // silent one: the FFI client emits `eql_v2_encrypted` payloads for columns + // whose Postgres domain is `eql_v3_*`, so the write either trips the domain + // CHECK or lands v2 wire wherever the check is looser. + // + // `EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over + // whatever the caller passed — its comment said so explicitly. It is now a + // bare alias of `Encryption`, so a caller upgrading from + // `EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — which + // was silently corrected and worked — now gets a v2-wire client with no + // diagnostic at any layer (#772 review, finding 8). + // + // The escape hatch itself stays: an explicit 2 over a V2 schema set is how + // `integration/shared/v2-decrypt-compat.integration.test.ts` mints the + // fixtures that prove v2 payloads still decrypt. That is the only shape that + // uses it — nothing mints v2 wire from a v3 table. + if (explicit === 2 && schemas.length > 0 && v3Count === schemas.length) { + throw new Error( + "[encryption]: cannot emit EQL v2 wire for a schema set that is entirely EQL v3 — the payloads would not match the columns' eql_v3_* domains. Drop `config.eqlVersion` to emit v3, or build the client from the EQL v2 schema you actually want to write.", + ) + } + if (explicit !== undefined) { return explicit }