From 2eef07edbe78610813acdf76e32bb3637023fff9 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 24 Jul 2026 21:12:59 +1000 Subject: [PATCH 01/13] 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 000000000..5776ed1d7 --- /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/13] 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 000000000..dc0bcc1c2 --- /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 3ba2f19ce..3fd949157 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/13] 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 e3afd5929..82cc1f978 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 5a47a930a..39b8ff907 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 3fd949157..248aaad41 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/13] 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 16ba56d2e..87da15873 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 b5e1be918..600a62d63 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 a2e1ea848..635572de3 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 2ed37577b..44743a1d3 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 8167c0edf..489a78100 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 f97c1d29f..6a0e0f418 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 8e6eecb38..5fac50151 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 bb14447bd..edf2b1df2 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 915108cc7..cdf5c930c 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 a889328b0..af687818c 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 45342f91b..10d615c7c 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 3953d0a8b..f69519e81 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 4f1f42096..1e2b5fd94 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 981845eb6..2162b359a 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 bc99fcb4f..d65e791ed 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 54d9f1396..894592878 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 ea55f4213..34e3eb2b1 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 cf4b967cb..9a578c014 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 82222377a..8bc084982 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 df9c40da4..c92f4595e 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 e7456b947..0024902d6 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 5009a4a74..6f4bfa709 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 dd66d412b..0a54d708a 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 95fb70115..7b2543627 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 0ef71beea..e9123ae54 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 03d9fcd0d..8358d4c2d 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 3532cdcbc..2fb113887 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 a8c8992de..152b94801 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 a3b31b3a5..80e60e647 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/13] 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 849feafbf..a731c6121 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 06cf30efb..dd10ac340 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 b14343caa..c96f2f9db 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 c15f05b52..e03df244c 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 2d7deebad..f4c512f65 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 6ed7b4a76..aa5ff0a07 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/13] 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 000000000..2fa9082bb --- /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 000000000..4960a2212 --- /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 de4b34cdf..a50a1f0cb 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 000000000..7f835a6fa --- /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 2f569b550..bcc012cfc 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 3c780c1cb..1ddab9b84 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/13] 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 aa5ff0a07..f7c6b2dd0 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/13] 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 5808d215e..350dc68d8 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 5776ed1d7..c8c1cdb93 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 dd5ba0de7..c0e22ba21 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 6755b8b56..e19f685e9 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 1ddab9b84..185ee801b 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/13] 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 350dc68d8..5808d215e 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/13] 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 000000000..1fce605e7 --- /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 a731c6121..414156919 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 000000000..69e727c9c --- /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 000000000..c90fd7527 --- /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 000000000..4ec0246f1 --- /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 32a167b60..24580eb22 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 30c46ce81..d6094110c 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 f7c6b2dd0..ff8873f3c 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/13] 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 c90fd7527..a8b0ac83d 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 ddc4ca84c..7b8c05f90 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/13] 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 000000000..a4b90ccfe --- /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 9d06679f0..16d312f2f 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 82cc1f978..7bbd1cff6 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 1b29b4359..b9357193d 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 e2e93f0ce..2ec1d0c5c 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 39b8ff907..a0a3f0c4e 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 } From 124048de9bac963c8826b28a29e407d82d9efbf1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 27 Jul 2026 10:42:47 +1000 Subject: [PATCH 13/13] fix(stack): close the init wire-version back door, unbreak generic schemas, stop shipping stale skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review remediation for #782. Six agents cross-checked the review findings; three of them were overturned and three defects the PR itself introduced were found in the process. Each fix has a regression test proven to fail without it. Defects introduced by this PR ----------------------------- `init` on the typed v3 client was a way around the guard A-8 had just added. `EncryptionClient.init` takes its own `eqlVersion`, forwards `undefined` to the FFI — whose default is EQL v2 — and never consults `resolveEqlVersion`. So the A-7 passthrough let a typed v3 client be re-initialised into v2 wire while keeping its v3 surface: `eql_v2_encrypted` payloads into `eql_v3_*` columns, the same contradiction refused at construction, one method call later. The existing parity test could not see it — it stubs `init` out and asserts only that it was called. `init` now pins `eqlVersion: 3`, refuses an explicit `2` with a failure `Result` (its declared shape), and resolves to the typed client so `client = (await client.init(cfg)).data` does not silently drop to nominal. `NonEmptyV3` on the `schemas` property broke every caller generic over its schemas — a shape that compiled before A-4, and the one the `EncryptionClientFor` docs point generic code at. A type parameter is assignable to a deferred conditional only if it is assignable to both branches, and one branch is `never`. Deleting the guard is not the fix (overload 1 then accepts `[]`); the v3 signature is split in two, with the tuple overload carrying non-emptiness in its constraint, where a generic `S` can satisfy it. `outputs: ["dist/**"]` could publish stale skills. `cli` and `wizard` copy the repo-root `skills/` into `dist/skills`, outside their own input scope, so a skills-only edit never invalidated the build — and once outputs were declared, a cache hit began actively restoring the previous copy. Since `stash init` installs those into customer repos, an edited skill could ship with its pre-edit text while the source on disk was correct and CI green. Reproduced end to end before fixing. Review findings --------------- Nine comments described the `eqlVersion: 2` escape hatch as still permitted, including two documenting live guards: the `isV3Only && eqlVersion === 3` branch in `Encryption`, and the `V3ClientConfig` JSDoc the prisma-next texts paraphrase. `skills/stash-encryption` claimed an empty array is a compile error. That holds only when the argument's type has a statically known length of 0 — a spread of an empty array is a literal at the call site and still compiles. Corrected, and the runtime half now has coverage it never had. Four typecheck gates compiled their own `dist/` alongside source, so they compiled a different program in CI than locally, and `migrate`'s file set depended on CI step order. `wizard` and `examples/basic` had the same defect. The declaration gate reached `dist/` by relative path under bundler resolution, so it never consulted the `exports` map: the `.d.cts` set and `wasm-inline.d.ts` — each with its own inlined copy of the column class — were ungated. The wasm entry is the documented target for Workers, Deno, Bun and Supabase Edge. `__domain` was reachable from consumer code and appeared in autocomplete on every column, typed `D | undefined` and always `undefined`; there is no `stripInternal`, so `@internal` was documentation, not enforcement. Now keyed by a non-exported `unique symbol` — as inferrable, unnameable. New tests --------- - `scripts/lint-typecheck-scope.mjs` + self-tests + CI step: a gated tsconfig must scope away its build output. - `dist-types/node16/`: `.cts`/`.mts`/wasm probes resolving by package name under node16, so the conditions a customer walks are the ones under test. - `turbo-skills-inputs.test.mjs`: a build that copies `skills/` must declare it. - `empty-schemas-boundary.test.ts`, `typed-client-init-wire-version.test.ts`. Left alone ---------- `eqlVersion: 3` over an all-v2 schema set is still accepted, writing v3 payloads into `eql_v2_encrypted` columns — the mirror of what A-8 refuses. It predates this branch and `resolve-eql-version.test.ts:115` pins it deliberately, so closing it is a product decision rather than review remediation. --- .../cli-v2-cutover-prompt-correction.md | 13 ++ .changeset/domain-carrier-not-public.md | 18 ++ .changeset/encryption-schema-arrays.md | 16 ++ .changeset/prisma-next-v3-client-config.md | 6 +- .changeset/skills-ship-current-copy.md | 18 ++ .changeset/stack-audit-on-decrypt.md | 2 +- .changeset/typecheck-gates-scope-source.md | 20 +++ .changeset/typed-client-init-parity.md | 14 +- .github/workflows/tests.yml | 8 + examples/basic/tsconfig.json | 7 +- package.json | 1 + packages/migrate/tsconfig.json | 7 +- packages/nextjs/package.json | 2 +- packages/nextjs/tsconfig.json | 7 +- .../prisma-next/src/stack/from-stack-v3.ts | 4 +- .../__tests__/supabase-v3-factory.test.ts | 6 +- .../dynamodb/client-compat.test-d.ts | 19 +- .../__tests__/empty-schemas-boundary.test.ts | 100 +++++++++++ .../__tests__/encryption-overloads.test-d.ts | 54 ++++++ .../typed-client-init-wire-version.test.ts | 96 ++++++++++ .../stack/dist-types/node16/encrypt-query.cts | 67 +++++++ .../stack/dist-types/node16/encrypt-query.mts | 66 +++++++ .../stack/dist-types/node16/tsconfig.json | 26 +++ .../stack/dist-types/node16/wasm-inline.mts | 33 ++++ packages/stack/dist-types/tsconfig.json | 4 + packages/stack/package.json | 2 +- packages/stack/src/dynamodb/index.ts | 8 +- packages/stack/src/encryption/index.ts | 42 +++-- packages/stack/src/encryption/v3.ts | 52 +++++- packages/stack/src/eql/v3/columns.ts | 15 +- packages/stack/src/types.ts | 8 +- packages/wizard/tsconfig.json | 7 +- .../excludes-dist/package.json | 4 + .../excludes-dist/tsconfig.json | 4 + .../has-include/package.json | 4 + .../has-include/tsconfig.json | 4 + .../jsonc-paths/package.json | 4 + .../jsonc-paths/tsconfig.json | 12 ++ .../lint-typecheck-scope/no-gate/package.json | 4 + .../no-gate/tsconfig.json | 3 + .../unscoped-too/package.json | 4 + .../unscoped-too/tsconfig.json | 4 + .../unscoped/package.json | 4 + .../unscoped/tsconfig.json | 3 + .../__tests__/lint-typecheck-scope.test.mjs | 87 +++++++++ .../__tests__/turbo-skills-inputs.test.mjs | 119 +++++++++++++ scripts/lint-typecheck-scope.mjs | 165 ++++++++++++++++++ skills/stash-encryption/SKILL.md | 6 +- turbo.json | 19 ++ 49 files changed, 1146 insertions(+), 52 deletions(-) create mode 100644 .changeset/cli-v2-cutover-prompt-correction.md create mode 100644 .changeset/domain-carrier-not-public.md create mode 100644 .changeset/skills-ship-current-copy.md create mode 100644 .changeset/typecheck-gates-scope-source.md create mode 100644 packages/stack/__tests__/empty-schemas-boundary.test.ts create mode 100644 packages/stack/__tests__/typed-client-init-wire-version.test.ts create mode 100644 packages/stack/dist-types/node16/encrypt-query.cts create mode 100644 packages/stack/dist-types/node16/encrypt-query.mts create mode 100644 packages/stack/dist-types/node16/tsconfig.json create mode 100644 packages/stack/dist-types/node16/wasm-inline.mts create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json create mode 100644 scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json create mode 100644 scripts/__tests__/lint-typecheck-scope.test.mjs create mode 100644 scripts/__tests__/turbo-skills-inputs.test.mjs create mode 100644 scripts/lint-typecheck-scope.mjs diff --git a/.changeset/cli-v2-cutover-prompt-correction.md b/.changeset/cli-v2-cutover-prompt-correction.md new file mode 100644 index 000000000..eb917fe2d --- /dev/null +++ b/.changeset/cli-v2-cutover-prompt-correction.md @@ -0,0 +1,13 @@ +--- +'stash': patch +--- + +`stash init`'s setup prompt no longer tells agents to declare an EQL v2 cutover +column with a `types.*` domain. + +The `types.*` factories are EQL v3 only — a v2 column is an `eql_v2_encrypted` +composite — so an agent following the old step-4 guidance would author a schema +that cannot describe the column it just cut over to. The prompt now says +explicitly not to use `types.*` for a v2 column, and points at the deprecated +`@cipherstash/stack/schema` builders with decryption through +`@cipherstash/stack`, which is the actual read path for legacy v2 rows. diff --git a/.changeset/domain-carrier-not-public.md b/.changeset/domain-carrier-not-public.md new file mode 100644 index 000000000..202284dbb --- /dev/null +++ b/.changeset/domain-carrier-not-public.md @@ -0,0 +1,18 @@ +--- +'@cipherstash/stack': patch +--- + +The phantom domain carrier on encrypted v3 columns is no longer part of the +public surface. + +Recovering a column's domain after declaration emit requires the type parameter +to appear bare somewhere in the instance type, which is what makes typed +`encryptQuery` callable against the published package. That carrier was a +plainly named `__domain` property, and this build has no `stripInternal`, so +`@internal` was documentation rather than enforcement: it appeared in +autocomplete on every column and could be read from consumer code, typed +`D | undefined` while being `undefined` at runtime in every case. + +It is now keyed by a `unique symbol` that is not exported, so it is unnameable +outside the package while remaining exactly as inferrable. Nothing is emitted at +runtime and no call site changes. diff --git a/.changeset/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md index 2fa9082bb..f5a125303 100644 --- a/.changeset/encryption-schema-arrays.md +++ b/.changeset/encryption-schema-arrays.md @@ -34,3 +34,19 @@ its schemas — an adapter that builds a table per request, say — can now writ If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to satisfy the old signature, that narrowing is no longer needed. + +A wrapper that is itself generic over its schemas keeps working too: + +```ts +async function makeClient( + schemas: S, +) { + return await Encryption({ schemas }) +} +``` + +Note the non-empty tuple constraint. A wrapper generic over a loose +`readonly AnyV3Table[]` is still rejected — that type admits `readonly []`, so +the wrapper cannot promise what `Encryption` requires. Constrain it as above, or +take `EncryptionClientFor` as a parameter instead of +building the client inside the generic function. diff --git a/.changeset/prisma-next-v3-client-config.md b/.changeset/prisma-next-v3-client-config.md index 8d17b9a54..000bb7d41 100644 --- a/.changeset/prisma-next-v3-client-config.md +++ b/.changeset/prisma-next-v3-client-config.md @@ -16,9 +16,9 @@ const cipherstash = await cipherstashFromStack({ ``` The option never did what it looked like it did. This package is EQL v3 only, -and `eqlVersion: 2` selects `Encryption`'s nominal (untyped) overload at -runtime — not the `TypedEncryptionClient` that `cipherstashFromStack` returns as -`encryptionClient`. The field disagreed with the client you got back. +and `cipherstashFromStack` always builds from an all-v3 schema set, over which +`eqlVersion: 2` is refused at setup — v2 payloads cannot satisfy the columns' +`eql_v3_*` domains. The field promised a client it could never return. **Migration:** drop the `eqlVersion` field. Every other `ClientConfig` option (`workspaceCrn`, `clientId`, `clientKey`, `accessKey`, `authStrategy`, logging, diff --git a/.changeset/skills-ship-current-copy.md b/.changeset/skills-ship-current-copy.md new file mode 100644 index 000000000..3aa96a880 --- /dev/null +++ b/.changeset/skills-ship-current-copy.md @@ -0,0 +1,18 @@ +--- +'stash': patch +'@cipherstash/wizard': patch +--- + +Fixed: a change to `skills/` could ship a stale copy of that skill. + +Both CLIs copy the repo-root `skills/` into their bundle at build time +(`dist/skills`), which `stash init` then installs into a customer's +`.claude/skills/` or `.codex/skills/`. That directory sits outside the package, +so it was not part of the build's declared inputs — a skills-only edit did not +invalidate the cached build. Once the build began declaring its output +directory, a cache hit stopped being merely stale and started actively restoring +the previous `dist/skills` over the tree, so an edited skill could be published +with the pre-edit text while the source file on disk was correct and CI green. + +The two builds now declare the skills directory as an input, and a test pins +that coupling so it cannot come undone. diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md index a50a1f0cb..48b32a60e 100644 --- a/.changeset/stack-audit-on-decrypt.md +++ b/.changeset/stack-audit-on-decrypt.md @@ -37,7 +37,7 @@ strings needs updating. 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. +nominal overload — though over an all-v3 schema set that call now throws at setup. `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/typecheck-gates-scope-source.md b/.changeset/typecheck-gates-scope-source.md new file mode 100644 index 000000000..33879a9b5 --- /dev/null +++ b/.changeset/typecheck-gates-scope-source.md @@ -0,0 +1,20 @@ +--- +'@cipherstash/stack': patch +--- + +The declaration gate now covers all three emitted type artifacts, not just one. + +`packages/stack/dist-types` typechecks what `tsc` emits rather than what the +source says — the gap that let typed `encryptQuery` ship uncallable for every +column through the whole rc series. It resolved `../dist/*.js` by relative path +under bundler resolution, which never consults the `exports` map, so it only ever +exercised the ESM `.d.ts` set. `tsup` emits the CJS `.d.cts` set in a separate +pass and `wasm-inline.d.ts` in a third, each with its own inlined copy of the +column class whose domain parameter the bug was about. + +A second suite now resolves `@cipherstash/stack/*` **by package name** under +`moduleResolution: node16`, with the file extension selecting the condition +(`.cts` → `require` → `.d.cts`, `.mts` → `import` → `.d.ts`), plus a probe for +the `wasm-inline` entry — the documented target for Workers, Deno, Bun and +Supabase Edge, and previously the one artifact nothing typechecked. Removing the +phantom domain carrier makes all three fail, so none of them passes vacuously. diff --git a/.changeset/typed-client-init-parity.md b/.changeset/typed-client-init-parity.md index 7f835a6fa..ecc41b3b5 100644 --- a/.changeset/typed-client-init-parity.md +++ b/.changeset/typed-client-init-parity.md @@ -18,7 +18,19 @@ const client = await Encryption({ schemas: [users], config }) ``` `init` was the only member of `EncryptionClient` missing from the typed client, -so any call through the declared type crashed. It is now delegated. +so any call through the declared type crashed. It is now present — and pins the +wire format rather than delegating verbatim. + +That distinction matters. `EncryptionClient.init` takes its own `eqlVersion` and +forwards `undefined` to the FFI, whose default is EQL **v2**, and it never +consults the check `Encryption` runs at construction. A bare passthrough would +therefore have let a typed v3 client be re-initialised into v2 wire while keeping +its v3 surface — writing `eql_v2_encrypted` payloads into `eql_v3_*` columns, the +same contradiction refused at construction, one method call later. The typed +`init` pins `eqlVersion: 3`, refuses an explicit `2` with a failure `Result`, and +resolves to the **typed** client, so the common +`client = (await client.init(cfg)).data` does not silently swap the typed surface +for the nominal one. 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 414156919..369eecd61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -196,6 +196,14 @@ jobs: - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners + # The gates above only mean something if they compile source. A tsconfig + # with no `include` globs `**/*`, which sweeps the package's own `dist/` + # into the program — and whether `dist/` exists during a gate depends on + # what an earlier step built transitively, so the gate compiles a + # different program in CI than it does locally. + - name: Lint — typecheck gates compile source, not build output + run: pnpm run lint:typecheck-scope + # Deleting or renaming a package leaves `packages/` references # dangling in docs and CI config. Nothing else catches it (#760 review). - name: Lint — no references to deleted package directories diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index 238655f2c..28110041f 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -23,5 +23,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/package.json b/package.json index 13cfd258b..f25fe0c29 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "code:check": "biome check", "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", + "lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs", "lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs", "release": "pnpm run build && changeset publish", "test": "turbo test --filter './packages/*'", diff --git a/packages/migrate/tsconfig.json b/packages/migrate/tsconfig.json index 982c07850..f479fb74d 100644 --- a/packages/migrate/tsconfig.json +++ b/packages/migrate/tsconfig.json @@ -19,5 +19,10 @@ "paths": { "@/*": ["./src/*"] } - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index f4c512f65..df6e8a461 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -33,7 +33,7 @@ }, "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "release": "tsup" }, diff --git a/packages/nextjs/tsconfig.json b/packages/nextjs/tsconfig.json index 6da81c927..ad991f9c5 100644 --- a/packages/nextjs/tsconfig.json +++ b/packages/nextjs/tsconfig.json @@ -24,5 +24,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index bcc012cfc..071446b4d 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -59,8 +59,8 @@ export interface CipherstashFromStackV3Options { * Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). * * `V3ClientConfig`, not `ClientConfig`: this package is EQL v3 only, and the - * legacy `eqlVersion: 2` escape hatch returns the nominal (untyped) client at - * runtime, which is not what this entry point hands back. + * legacy `eqlVersion: 2` escape hatch throws at setup over the all-v3 schema + * set this entry point derives. */ readonly encryptionConfig?: V3ClientConfig } diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index b9cebc982..083af15e7 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -259,9 +259,9 @@ describe('encryptedSupabaseV3 factory', () => { }) }) - // `eqlVersion` is forced, not defaulted. A caller who passes `eqlVersion: 2` - // against v3 domains would otherwise get a v2 encryption client and fail at - // runtime with a 23514 CHECK violation, far from the cause. + // `eqlVersion` is forced, not defaulted. Without the force a caller's + // `eqlVersion: 2` would now make `Encryption` throw at setup, against the + // all-v3 schema set introspection synthesizes. it('forces eqlVersion 3 over a caller-supplied config, passing other keys through', async () => { await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x', diff --git a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts index 971b641a2..b8995a67f 100644 --- a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts +++ b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts @@ -15,7 +15,7 @@ import { describe, expectTypeOf, it } from 'vitest' import type { EncryptedAttributes, EncryptedDynamoDBInstance } from '@/dynamodb' import { encryptedDynamoDB } from '@/dynamodb' import type { EncryptionClient } from '@/encryption' -import type { EncryptionV3 } from '@/encryption/v3' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' @@ -42,12 +42,17 @@ const searchV3 = encryptedTableV3('search_v3', { type V3Model = { pk: string; email?: string; age?: number; role?: string } -// The two client shapes. `EncryptionV3` returns a `TypedEncryptionClient` -// parameterised by its own schema tuple; `Encryption` returns the nominal -// `EncryptionClient`. Both must be accepted by the factory WITHOUT a cast. -declare const typedClient: Awaited< - ReturnType> -> +// The two client shapes. A v3 schema tuple yields a `TypedEncryptionClient` +// parameterised by it; a v2 set yields the nominal `EncryptionClient`. Both must +// be accepted by the factory WITHOUT a cast. +// +// Named with `EncryptionClientFor`, not `Awaited>` +// — `ReturnType` reads the LAST overload, so that idiom resolves to the nominal +// client whatever schemas you pass. Supplying an explicit type argument happens +// to dodge it today, but only because it filters the non-generic nominal +// overload out of the instantiation; give that overload a type parameter and +// this assertion would silently start checking the wrong client. +declare const typedClient: EncryptionClientFor declare const nominalClient: EncryptionClient describe('encryptedDynamoDB accepts both client shapes without a cast', () => { diff --git a/packages/stack/__tests__/empty-schemas-boundary.test.ts b/packages/stack/__tests__/empty-schemas-boundary.test.ts new file mode 100644 index 000000000..7d67ab2b2 --- /dev/null +++ b/packages/stack/__tests__/empty-schemas-boundary.test.ts @@ -0,0 +1,100 @@ +/** + * Where the compile-time non-emptiness guard stops, and the runtime one starts. + * + * A-4 widened `Encryption`'s v3 overload from a non-empty TUPLE to any array, + * moving the guard onto the `schemas` property via + * `NonEmptyV3 = S['length'] extends 0 ? never : S`. That guard fires only + * when the argument's TYPE has a statically known length of `0`. Once the type + * widens to `AnyV3Table[]` — a shared module export, a push-built array, a + * `.filter()` result, or a spread of any of those — `S['length']` is `number`, + * `number extends 0` is false, and the call compiles no matter how many tables + * are actually in it. + * + * So the widening deliberately traded a compile error for a runtime one on + * exactly the forms it exists to enable. `skills/stash-encryption` documents + * that boundary, and this pins the runtime half of it: the guard must reject an + * empty set BEFORE any FFI client is constructed, on every entry point. + * + * The compile-time half is pinned in `encryption-overloads.test-d.ts`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AnyV3Table } from '@/eql/v3' +import { Encryption } from '@/index' +import { encryptedColumn, encryptedTable } from '@/schema' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' + +const EMPTY_SCHEMAS = /At least one encryptedTable must be provided/ + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('empty schema sets are refused at runtime', () => { + // The forms below all COMPILE — that is the point. Each is a shape the A-4 + // widening admits, and each is empty at runtime. + it('rejects a shared array annotated AnyV3Table[] that is empty', async () => { + const shared: AnyV3Table[] = [] + + await expect(Encryption({ schemas: shared })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('rejects a ReadonlyArray that is empty', async () => { + const frozen: ReadonlyArray = [] + + await expect(Encryption({ schemas: frozen })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('rejects a spread of an empty array — a literal at the call site', async () => { + // Spreading erases the tuple length, so even an array literal here carries + // no compile-time non-emptiness. This is the form most likely to be read as + // "a literal, therefore checked". + const src: AnyV3Table[] = [] + + await expect(Encryption({ schemas: [...src] })).rejects.toThrow( + EMPTY_SCHEMAS, + ) + }) + + it('rejects an array emptied by a filter', async () => { + const all: AnyV3Table[] = [] + + await expect( + Encryption({ schemas: all.filter(() => false) }), + ).rejects.toThrow(EMPTY_SCHEMAS) + }) + + // The v2 builders reach the same guard — it predates the v3 typed client and + // is not part of the overload machinery. + it('rejects an empty v2 schema set', async () => { + const v2: Array> = [] + + await expect(Encryption({ schemas: v2 })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('refuses before constructing the FFI client, so no credentials are needed', async () => { + const shared: AnyV3Table[] = [] + + await expect(Encryption({ schemas: shared })).rejects.toThrow(EMPTY_SCHEMAS) + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + // Control: the same call shape with one table gets past the guard, proving the + // assertions above fail on emptiness rather than on the array form itself. + it('accepts a non-empty array of the same widened type', async () => { + const shared: AnyV3Table[] = [] + shared.push( + encryptedTable('users', { + email: encryptedColumn('email'), + }) as unknown as AnyV3Table, + ) + + await expect(Encryption({ schemas: shared })).resolves.toBeDefined() + expect(ffi.newClient).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/stack/__tests__/encryption-overloads.test-d.ts b/packages/stack/__tests__/encryption-overloads.test-d.ts index 7bbd1cff6..aae0fc789 100644 --- a/packages/stack/__tests__/encryption-overloads.test-d.ts +++ b/packages/stack/__tests__/encryption-overloads.test-d.ts @@ -49,6 +49,60 @@ describe('overload selection', () => { Encryption({ schemas: [] }) }) + // ...but only when the ARGUMENT'S TYPE has a statically known length of 0. + // `NonEmptyV3` keys off `S['length'] extends 0`, so once the type widens to + // `AnyV3Table[]` the length is `number` and emptiness stops being visible — + // including for a literal at the call site, because a spread erases the tuple. + // These compile ON PURPOSE: rejecting them is what the A-4 tuple constraint + // did, and it broke every non-literal caller. The runtime guard is what + // catches them; `empty-schemas-boundary.test.ts` pins that half, and + // `skills/stash-encryption` documents the split for users. + // A generic passthrough is the shape `EncryptionClientFor` and the skill both + // advertise ("code that is generic over its schemas"), and it is the one form + // a WIDENING change must not break. It compiled before A-4 and must still. + // + // A deferred conditional on the property is what broke it: `S` is assignable + // to `NonEmptyV3` only if it is assignable to BOTH branches, and one branch + // is `never`. The emptiness rejection does not need it — overload 2's + // `AtLeastOneCsTable` rejects `[]` on its own. + it('accepts schemas from a generic wrapper function', async () => { + async function makeTypedClient< + S extends readonly [AnyV3Table, ...AnyV3Table[]], + >(schemas: S) { + return await Encryption({ schemas }) + } + + expectTypeOf(await makeTypedClient([users])).toEqualTypeOf< + TypedEncryptionClient + >() + + // A wrapper generic over a LOOSE `readonly AnyV3Table[]` is still rejected, + // here and on the pre-A-4 signature alike. That is the honest answer rather + // than a gap: such an `S` admits `readonly []`, so the wrapper cannot + // promise what `Encryption` requires. Constrain it to a non-empty tuple, as + // above, and it compiles. + async function makeLooseClient( + schemas: S, + ) { + // @ts-expect-error - a loose `S` cannot prove it is non-empty + return await Encryption({ schemas }) + } + void makeLooseClient + }) + + it('accepts arrays whose type cannot prove non-emptiness', () => { + const shared: AnyV3Table[] = [] + expectTypeOf(Encryption).toBeCallableWith({ schemas: shared }) + + const frozen: ReadonlyArray = [] + expectTypeOf(Encryption).toBeCallableWith({ schemas: frozen }) + + expectTypeOf(Encryption).toBeCallableWith({ schemas: [...shared] }) + expectTypeOf(Encryption).toBeCallableWith({ + schemas: shared.filter(() => false), + }) + }) + // 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 diff --git a/packages/stack/__tests__/typed-client-init-wire-version.test.ts b/packages/stack/__tests__/typed-client-init-wire-version.test.ts new file mode 100644 index 000000000..e535cc001 --- /dev/null +++ b/packages/stack/__tests__/typed-client-init-wire-version.test.ts @@ -0,0 +1,96 @@ +/** + * The typed client's `init` passthrough must not reopen the door A-8 closed. + * + * A-7 added `init` to the typed EQL v3 client so that a caller holding it + * through its declared `EncryptionClient` type could not hit + * `TypeError: init is not a function`. But `EncryptionClient.init` takes its own + * `eqlVersion?: 2 | 3`, and forwards `undefined` straight to `newClient`, where + * the FFI's default is EQL **v2**. `resolveEqlVersion` — which refuses exactly + * that combination — is only ever consulted by `Encryption`, never by `init`. + * + * So a bare passthrough lets a v3 client be silently re-initialised into v2 wire + * while keeping the typed v3 surface: the same contradiction A-8 rejects at + * construction, reachable one method call later. + * + * const client = await Encryption({ schemas: [users] }) // eqlVersion: 3 + * await client.init({ encryptConfig }) // eqlVersion: undefined -> v2 + * + * `typed-client-nominal-parity.test.ts` cannot catch this: it stubs `init` out + * entirely and asserts only that it was called. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/encryption/v3' +import { Encryption } from '@/index' +import { buildEncryptConfig } from '@/schema' + +const users = encryptedTable('users', { email: types.TextSearch('email') }) + +// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args +const newClientCalls = () => (ffi.newClient as any).mock.calls +const lastEqlVersion = () => newClientCalls().at(-1)[0].eqlVersion + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('typed client init keeps the v3 wire format', () => { + it('constructs at eqlVersion 3', async () => { + await Encryption({ schemas: [users] }) + + expect(lastEqlVersion()).toBe(3) + }) + + it('stays at eqlVersion 3 when re-initialised without one', async () => { + const client = await Encryption({ schemas: [users] }) + expect(lastEqlVersion()).toBe(3) + + await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + }) + + // Without pinning, this is `undefined` — the FFI's v2 default — leaving a + // typed v3 surface over a client emitting v2 payloads into eql_v3_* columns. + expect(newClientCalls()).toHaveLength(2) + expect(lastEqlVersion()).toBe(3) + }) + + it('refuses an explicit eqlVersion 2 on re-init, as construction does', async () => { + const client = await Encryption({ schemas: [users] }) + + // `Encryption` throws for this combination, but `init` declares + // `Promise>` — the Result shape is contract, so this refuses with + // a failure rather than rejecting. Either way it never reaches the FFI. + const result = await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + eqlVersion: 2, + }) + + expect(result.failure?.message).toMatch(/eqlVersion 2|eql_v3_\* domains/) + expect(newClientCalls()).toHaveLength(1) + }) + + it('returns the TYPED client, not the bare nominal one', async () => { + const client = await Encryption({ schemas: [users] }) + + const result = await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + }) + + // `EncryptionClient.init` resolves `{ data: this }` — the nominal client. + // Reassigning from it (`client = (await client.init(c)).data`) is the + // natural idiom, and it must not silently drop the typed surface. + if (result.failure) throw new Error(result.failure.message) + expect( + typeof (result.data as { encryptQuery?: unknown }).encryptQuery, + ).toBe('function') + expect(result.data).toBe(client) + }) +}) diff --git a/packages/stack/dist-types/node16/encrypt-query.cts b/packages/stack/dist-types/node16/encrypt-query.cts new file mode 100644 index 000000000..32b48e7b1 --- /dev/null +++ b/packages/stack/dist-types/node16/encrypt-query.cts @@ -0,0 +1,67 @@ +/** + * The CommonJS declaration surface — `require` → `dist/**\/*.d.cts`. + * + * The sibling `../encrypt-query.ts` gate reaches into `../dist/*.js` by relative + * path under `moduleResolution: bundler`, so it never consults the `exports` + * map and only ever sees the ESM `.d.ts` set. tsup emits the `.d.cts` set in a + * separate pass, and `package.json` routes `require` at it. A defect confined to + * that pass would ship to every CJS consumer with nothing here to catch it. + * + * This file is `.cts`, so TypeScript treats it as CommonJS and resolves + * `@cipherstash/stack/v3` through the `require` condition. Importing BY PACKAGE + * NAME (Node self-reference, which works because `package.json` has both a + * `name` and an `exports` map) is the point: it exercises the conditions a + * customer's resolver walks, not a path we happen to know. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/eql/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// The two helpers, on the concrete column class, straight from the emitted +// `.d.cts`. These collapse to `never` / a wide union if the domain parameter is +// not recoverable after declaration emit. +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 assertions above pass + // vacuously. Each directive sits on the exact line the error is reported at. + 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/dist-types/node16/encrypt-query.mts b/packages/stack/dist-types/node16/encrypt-query.mts new file mode 100644 index 000000000..ba8ac246f --- /dev/null +++ b/packages/stack/dist-types/node16/encrypt-query.mts @@ -0,0 +1,66 @@ +/** + * The ESM declaration surface under NODE16 resolution — `import` → `*.d.ts`. + * + * `../encrypt-query.ts` already covers the `.d.ts` set, but through a relative + * path under `moduleResolution: bundler`. That combination cannot catch a broken + * `exports` map, a missing `types` condition, or a subpath that resolves under a + * bundler but not under Node's own algorithm — the resolution mode most server + * consumers actually use. + * + * Same assertions as the `.cts` twin; the only difference is which condition the + * resolver takes, which is exactly what is under test. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/eql/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +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'), +}) + +// The domain carrier that makes the inference above possible must not be part +// of the public surface. There is no `stripInternal` in this build, so a plainly +// named property would be reachable here — typed `D | undefined`, and always +// `undefined` at runtime. Keyed by a non-exported `unique symbol`, it is not. +// @ts-expect-error - the phantom domain carrier is not nameable by consumers +void users.email.__domain + +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', + }) + + 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/dist-types/node16/tsconfig.json b/packages/stack/dist-types/node16/tsconfig.json new file mode 100644 index 000000000..034b0ae3f --- /dev/null +++ b/packages/stack/dist-types/node16/tsconfig.json @@ -0,0 +1,26 @@ +{ + // The sibling gate resolves `../dist/*.js` by RELATIVE path under + // `moduleResolution: bundler`, which never consults the `exports` map. This + // one resolves `@cipherstash/stack/*` BY PACKAGE NAME under `node16` — the + // way a customer does — so the conditions in `exports` select the artifact. + // + // The package is `"type": "module"`, so the file extension picks the mode: + // `.cts` is CommonJS and takes the `require` branch (`*.d.cts`), `.mts` is + // ESM and takes the `import` branch (`*.d.ts`). Both must be probed: tsup + // emits the two declaration sets in separate passes, and a third for + // `wasm-inline`. + // + // No `customConditions` here: `node` is implicit under node16, and this + // package's own `exports` map does not branch on it. + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["**/*.cts", "**/*.mts"] +} diff --git a/packages/stack/dist-types/node16/wasm-inline.mts b/packages/stack/dist-types/node16/wasm-inline.mts new file mode 100644 index 000000000..947e2b4ca --- /dev/null +++ b/packages/stack/dist-types/node16/wasm-inline.mts @@ -0,0 +1,33 @@ +/** + * The THIRD declaration artifact: `dist/wasm-inline.d.ts`. + * + * `tsup.config.ts` runs a second, independent DTS pass for the wasm-inline + * entry, which inlines its own copy of `EncryptedV3Column` and the helpers that + * invert its domain parameter. Neither the bundler gate nor the `.cts`/`.mts` + * probes above reach it — they resolve `./v3` and `./eql/v3`, which come from + * the first pass. So the entry documented for Workers, Deno, Bun and Supabase + * Edge — the runtimes with the least margin for a broken type — was the one + * artifact nothing typechecked. + * + * `./wasm-inline` is ESM-only in the `exports` map (no `require` branch, by + * design: the inlined WASM blob cannot be `require`d), hence `.mts` and no + * `.cts` twin. + * + * `encryptQuery` is deliberately NOT column-narrowed on this entry + * (`WasmEncryptQueryOptions.column` is wide), so this asserts the helpers + * directly rather than through a call — they are what carries the domain, and + * what collapses if the phantom carrier is lost on emit. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/wasm-inline' + +const plaintext: string = + null as unknown as PlaintextForColumn +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn +void plaintext +void queryTypes diff --git a/packages/stack/dist-types/tsconfig.json b/packages/stack/dist-types/tsconfig.json index 4ec0246f1..4e52e3b05 100644 --- a/packages/stack/dist-types/tsconfig.json +++ b/packages/stack/dist-types/tsconfig.json @@ -10,5 +10,9 @@ "skipLibCheck": true, "noEmit": true }, + // `node16/` is the same contract checked under Node's own resolution, by + // package name rather than relative path. It needs `module: node16`, so it + // carries its own tsconfig and must not be swept up by this one. + "exclude": ["node16"], "include": ["**/*.ts"] } diff --git a/packages/stack/package.json b/packages/stack/package.json index 24580eb22..1f3b5b0c5 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -198,7 +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", + "test:types:dist": "tsc --noEmit -p dist-types/tsconfig.json && tsc --noEmit -p dist-types/node16/tsconfig.json", "release": "tsup", "test:integration": "vitest run --config integration/vitest.config.ts" }, diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index a950c8f7c..a19f1a691 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -31,10 +31,10 @@ import type { * hand: the operation methods below, when a table is supplied. `encryptedDynamoDB` * itself receives no table, so it cannot check earlier. * - * RESIDUAL GAP: a client explicitly forced to `eqlVersion: 2` over a v3 schema - * set (a deliberate migration path) DOES register the table yet emits v2 wire; - * that is not detectable without a wire-version accessor the client does not - * provide, so it is out of scope here. + * The one case this could not detect — a client forced to `eqlVersion: 2` while + * registering a v3 table, emitting v2 wire for an `eql_v3_*` domain — is now + * refused by `resolveEqlVersion` at client construction, so no such client can + * reach here. */ function assertClientTableVersionMatch( encryptionClient: EncryptedDynamoDBConfig['encryptionClient'], diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index a0a3f0c4e..079ac2b28 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -89,9 +89,10 @@ export const noClientError = () => * columns and the v3 tables target `eql_v3` domains, so no single wire * format serves both. Split them across two clients. * - * An explicit `config.eqlVersion` bypasses version detection (the wire format - * is then unambiguous — e.g. writing v2 wire from a v3 schema set during a - * migration), but mixed schemas and legacy v2 SteVec schemas still throw. + * An explicit `config.eqlVersion` bypasses DETECTION, not validation: mixed + * schemas and legacy v2 SteVec schemas still throw, and so does an explicit `2` + * over an all-v3 set. What survives is `2` over a v2 schema set — minting v2 + * wire during a migration. * * @internal exported for unit-test coverage of the detection matrix. */ @@ -911,6 +912,27 @@ type NonEmptyV3 = S['length'] extends 0 // `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. +// +// It also cannot be the ONLY v3 signature, which is why there are two. A type +// parameter is assignable to a deferred conditional only if it is assignable to +// BOTH branches, and one branch here is `never` — so `NonEmptyV3` rejects +// every caller that is itself generic over its schemas: +// +// async function make(schemas: S) { +// return await Encryption({ schemas }) // TS2769 against `NonEmptyV3` +// } +// +// That shape compiled before A-4 and is the one the `EncryptionClientFor` docs +// point generic code at, so it must keep compiling. The overload below carries +// the non-emptiness in its CONSTRAINT instead of a conditional, which a generic +// `S` can satisfy; the widened one after it then only has to serve arrays that +// are concrete at the call site, where the conditional resolves eagerly. +export function Encryption< + const S extends readonly [AnyV3Table, ...AnyV3Table[]], +>(config: { + schemas: S + config?: V3ClientConfig +}): Promise> export function Encryption(config: { schemas: NonEmptyV3 config?: V3ClientConfig @@ -963,9 +985,10 @@ export async function Encryption( const isV3Only = schemas.every(hasBuildColumnKeyMap) // Resolve the wire format: an explicit `config.eqlVersion` wins (the retained - // migration escape hatch — e.g. deliberately writing v2 wire from a v3 schema - // set), otherwise it is auto-detected (v3 tables → 3, v2 tables → the FFI's v2 - // default). A mixed v2 + v3 schema set throws inside `resolveEqlVersion`. + // migration escape hatch — `2` over a v2 schema set), otherwise it is + // auto-detected (v3 tables → 3, v2 tables → the FFI's v2 default). A mixed + // v2 + v3 set, and an explicit `2` over an all-v3 set, throw inside + // `resolveEqlVersion`. const eqlVersion = resolveEqlVersion(schemas, clientConfig?.eqlVersion) const result = await client.init({ @@ -980,10 +1003,9 @@ export async function Encryption( } // Return the typed client only when the client is genuinely in EQL v3 mode: an - // all-v3 schema set that resolved to the v3 wire format. A caller that forced - // `eqlVersion: 2` over v3 schemas gets the nominal client for that deliberate, - // low-level v2-wire migration path (the typed client cannot encrypt v3 columns - // in v2 mode anyway). + // all-v3 schema set that resolved to the v3 wire format. The `eqlVersion === 3` + // conjunct is now belt-and-braces: `resolveEqlVersion` refuses an explicit `2` + // over an all-v3 set, so an all-v3 set cannot reach here in v2 mode. if (isV3Only && eqlVersion === 3) { // biome-ignore lint/plugin: the runtime `isV3Only` guard (every schema has // buildColumnKeyMap) proves these are AnyV3Table — the compiler can't see it. diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 248aaad41..cc79cb306 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,3 +1,4 @@ +import type { Result } from '@byteslice/result' import type { AnyV3Table, ColumnsOf, @@ -152,7 +153,7 @@ export interface TypedEncryptionClient { getEncryptConfig(): ReturnType /** - * Re-initialize the underlying client. + * Re-initialize the underlying client, staying on EQL v3. * * @internal Present for runtime parity with {@link EncryptionClient}, not as * part of the typed authoring surface. `Encryption` picks its return value by @@ -161,10 +162,25 @@ export interface TypedEncryptionClient { * 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`. + * + * This is NOT a bare passthrough, because `EncryptionClient.init` would + * otherwise be a way around the guard `Encryption` applies at construction. + * `init` takes its own `eqlVersion`, forwards `undefined` to the FFI (whose + * default is **v2**), and never consults `resolveEqlVersion` — so delegating + * verbatim would let a typed v3 client be re-initialized into v2 wire while + * keeping this v3 surface, the exact contradiction `resolveEqlVersion` + * refuses. The wire version is pinned to `3` and an explicit `2` is rejected. + * + * It also resolves to the TYPED client rather than the underlying nominal one, + * so `client = (await client.init(cfg)).data` — the natural idiom, since + * `EncryptionClient.init` resolves `{ data: this }` — keeps the typed surface + * instead of silently degrading to the nominal one. */ init( - config: Parameters[0], - ): ReturnType + config: Omit[0], 'eqlVersion'> & { + eqlVersion?: 3 + }, + ): Promise, EncryptionError>> } /** @@ -368,7 +384,11 @@ export function typedClient( ) as never } - return { + // Annotated rather than `satisfies`, because `init` resolves to `typed` + // itself and a self-referencing initializer has no inferrable type. The + // annotation checks the same shape the `satisfies` did, and the function's + // declared return type is this type anyway, so nothing is widened. + const typed: TypedEncryptionClient = { encrypt: (plaintext, opts) => client.encrypt(plaintext as never, opts as never), encryptQuery, @@ -382,8 +402,28 @@ 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 + // See the interface member for why this is not `client.init(config)`: + // `init` bypasses `resolveEqlVersion` and defaults the FFI to v2 wire, so a + // verbatim delegation would reopen the contradiction A-8 closes. Pin v3, + // refuse an explicit 2, and resolve to the typed client so the surface + // survives the round trip. + init: async (config) => { + if (config.eqlVersion !== undefined && config.eqlVersion !== 3) { + return { + failure: { + type: EncryptionErrorTypes.EncryptionError, + message: + "[eql/v3]: cannot re-initialize a typed EQL v3 client at eqlVersion 2 — the payloads would not match the columns' eql_v3_* domains. Build a separate client from the EQL v2 schema you want to write.", + }, + } + } + + const result = await client.init({ ...config, eqlVersion: 3 }) + return result.failure ? result : { data: typed } + }, + } + + return typed } /** diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index d6094110c..faf4a0560 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -431,6 +431,12 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { * `EncryptedDateColumn`, both storage-only) are NOT mutually assignable. This * nominality is what keeps plaintext inference precise. */ +/** + * Key for {@link EncryptedV3Column}'s phantom domain carrier. Declared, never + * defined — it exists only in the type layer, and is deliberately not exported. + */ +declare const domainCarrier: unique symbol + export class EncryptedV3Column { /** * Phantom carrier for `D`, and the reason this class is usable from the @@ -453,10 +459,17 @@ export class EncryptedV3Column { * 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. * + * Keyed by a non-exported `unique symbol` rather than a plain name. There is + * no `stripInternal` in this build, so `@internal` is documentation only — a + * `__domain` property would be reachable from customer code and show up in + * autocomplete on every column, typed `D | undefined` while being `undefined` + * always. The symbol is not exported, so the carrier is unnameable outside + * this module while remaining exactly as inferrable. + * * @internal Not for consumption; read the domain via `getEqlType()` / * `getQueryCapabilities()`. */ - declare readonly __domain?: D + declare readonly [domainCarrier]?: D constructor( private readonly columnName: string, diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 7cca68207..7195c4f7c 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -208,10 +208,10 @@ export type ClientConfig = { * minus the legacy `eqlVersion: 2` escape hatch. * * `Encryption` accepts this (not the full `ClientConfig`) alongside an all-v3 - * schema set. Forcing v2 wire over v3 schemas returns the NOMINAL client at - * runtime, so admitting `2` there typed the call as `TypedEncryptionClient` - * while handing back a client that silently ignores the typed client's extra - * `decryptModel` arguments. Adapters that are v3-only (`@cipherstash/prisma-next`, + * schema set. Forcing v2 wire over v3 schemas THROWS at setup — v2 payloads + * cannot satisfy an `eql_v3_*` domain — so admitting `2` there typed the call + * as `TypedEncryptionClient` for a call that returns no client at all. + * Adapters that are v3-only (`@cipherstash/prisma-next`, * `@cipherstash/stack-drizzle`) should take this type for their pass-through * config for the same reason. */ diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json index d88a9b847..7cc1059cb 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -30,5 +30,10 @@ "paths": { "@/*": ["./src/*"] } - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json new file mode 100644 index 000000000..c35a0d5ac --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/excludes-dist", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json new file mode 100644 index 000000000..0aecccec4 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json new file mode 100644 index 000000000..46a9e7c53 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/has-include", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json new file mode 100644 index 000000000..e0ba56aa8 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json new file mode 100644 index 000000000..f6c511632 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/jsonc-paths", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json new file mode 100644 index 000000000..f384a6d17 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + /* A block comment, and below a path mapping whose KEY contains `/*` — a + naive strip eats from inside that string to the next comment close. */ + "paths": { + "@/*": ["./src/*"] + }, + "strict": true + }, + // A trailing comma follows, which `JSON.parse` also rejects. + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json new file mode 100644 index 000000000..24edb2530 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/no-gate", + "scripts": { "build": "tsup" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json new file mode 100644 index 000000000..98865a3ea --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json new file mode 100644 index 000000000..f1126d4b3 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/unscoped-too", + "scripts": { "typecheck": "tsc --project tsconfig.json --noEmit" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json new file mode 100644 index 000000000..636f8a894 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "exclude": ["node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json new file mode 100644 index 000000000..abd692cf5 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/unscoped", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json new file mode 100644 index 000000000..98865a3ea --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true } +} diff --git a/scripts/__tests__/lint-typecheck-scope.test.mjs b/scripts/__tests__/lint-typecheck-scope.test.mjs new file mode 100644 index 000000000..093f9cf0a --- /dev/null +++ b/scripts/__tests__/lint-typecheck-scope.test.mjs @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-typecheck-scope.mjs', +) + +function run(...targets) { + try { + execFileSync('node', [SCRIPT, ...targets], { encoding: 'utf8' }) + return { exitCode: 0, output: '' } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +const fx = (name) => `scripts/__tests__/fixtures/lint-typecheck-scope/${name}` + +describe('lint-typecheck-scope', () => { + it('passes on the repo as it stands', () => { + const r = run() + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + }) + + it('fails a gated package whose tsconfig scopes nothing', () => { + const r = run(fx('unscoped')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/@fixture\/unscoped/) + expect(r.output).toMatch(/compiles its own build output/) + }) + + it('names the offending tsconfig and the gate command', () => { + const r = run(fx('unscoped')) + expect(r.output).toMatch(/unscoped\/tsconfig\.json/) + expect(r.output).toMatch(/tsc --noEmit -p tsconfig\.json/) + }) + + it('passes when `exclude` covers dist', () => { + expect(run(fx('excludes-dist')).exitCode).toBe(0) + }) + + it('passes when an explicit `include` scopes the program', () => { + expect(run(fx('has-include')).exitCode).toBe(0) + }) + + it('ignores a package with no typecheck gate', () => { + // An unscoped tsconfig nothing runs is an editor setting, not a CI + // contract — flagging it would be noise. + expect(run(fx('no-gate')).exitCode).toBe(0) + }) + + it('parses a tsconfig with comments, block comments and a `/*` inside a path key', () => { + // Regression: the first cut stripped block comments with a regex, which ate + // from the `/*` inside the `"@/*"` mapping key to the next comment close and + // reported four real tsconfigs as unparseable. + const r = run(fx('jsonc-paths')) + expect(r.output).not.toMatch(/could not be parsed/) + expect(r.exitCode).toBe(0) + }) + + it('counts only the offenders among the targets it was given', () => { + const r = run(fx('unscoped'), fx('no-gate'), fx('excludes-dist')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/Found 1 typecheck gate\(s\)/) + }) + + it('reports every offender, not just the first', () => { + const r = run(fx('unscoped'), fx('unscoped-too')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/Found 2 typecheck gate\(s\)/) + expect(r.output).toMatch(/@fixture\/unscoped\b/) + expect(r.output).toMatch(/@fixture\/unscoped-too/) + }) + + it('explains the fix, including that `exclude` replaces the default', () => { + const r = run(fx('unscoped')) + expect(r.output).toMatch(/"exclude": \["dist", "node_modules"\]/) + expect(r.output).toMatch(/REPLACES the default/) + }) +}) diff --git a/scripts/__tests__/turbo-skills-inputs.test.mjs b/scripts/__tests__/turbo-skills-inputs.test.mjs new file mode 100644 index 000000000..7706ca767 --- /dev/null +++ b/scripts/__tests__/turbo-skills-inputs.test.mjs @@ -0,0 +1,119 @@ +/** + * A build that copies `skills/` must declare `skills/` as an input. + * + * `packages/cli` and `packages/wizard` both `cpSync('../../skills', + * 'dist/skills')` — they consume a directory outside their own package, which + * turbo's `$TURBO_DEFAULT$` does not cover. On its own that only meant a stale + * cache entry stayed valid; once `build` declared `outputs: ["dist/**"]`, a + * cache hit began actively RESTORING the previous `dist/skills` over the tree. + * + * `skills/` ships inside the `stash` and `@cipherstash/wizard` tarballs and + * `stash init` copies it into customer repos, so the failure mode is publishing + * guidance that was edited but never rebuilt — invisible, because the build is + * green and the source file on disk is correct. + * + * Verified by hand at the time of writing: edit a `SKILL.md`, run + * `turbo run build --filter=stash`, observe FULL TURBO and a `dist/skills` copy + * without the edit. This pins the fix so it cannot silently come undone. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** Strip comments so `JSON.parse` accepts turbo.json (it is JSONC). */ +function readJsonc(path) { + const raw = readFileSync(path, 'utf8') + let out = '' + let inString = false + let escaped = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (inString) { + out += ch + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && raw[i + 1] === '/') { + while (i < raw.length && raw[i] !== '\n') i++ + out += '\n' + continue + } + if (ch === '/' && raw[i + 1] === '*') { + i += 2 + while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++ + i++ + continue + } + out += ch + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) +} + +/** Packages whose tsup config copies the repo-root `skills/` into `dist/`. */ +function packagesCopyingSkills() { + const pkgsDir = join(REPO_ROOT, 'packages') + const found = [] + for (const entry of readdirSync(pkgsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const tsup = join(pkgsDir, entry.name, 'tsup.config.ts') + const pkgJson = join(pkgsDir, entry.name, 'package.json') + if (!existsSync(tsup) || !existsSync(pkgJson)) continue + if ( + !/cpSync\(\s*['"]\.\.\/\.\.\/skills['"]/.test(readFileSync(tsup, 'utf8')) + ) + continue + found.push(JSON.parse(readFileSync(pkgJson, 'utf8')).name) + } + return found +} + +describe('turbo build inputs cover the skills directory', () => { + const turbo = readJsonc(join(REPO_ROOT, 'turbo.json')) + const copiers = packagesCopyingSkills() + + it('finds the packages that copy skills into their bundle', () => { + // If this drops to zero the rest of the suite silently passes, so pin it. + expect(copiers).toEqual( + expect.arrayContaining(['stash', '@cipherstash/wizard']), + ) + }) + + it.each( + packagesCopyingSkills().map((name) => [name]), + )('%s#build declares skills as an input', (name) => { + const task = turbo.tasks[`${name}#build`] + + expect( + task, + `turbo.json has no "${name}#build" task, so it inherits the generic ` + + '`build` inputs, which do not include the repo-root skills/ directory', + ).toBeDefined() + + const inputs = task.inputs ?? [] + expect( + inputs.some((i) => i.includes('skills')), + `"${name}#build".inputs must name the repo-root skills/ directory ` + + '(e.g. "$TURBO_ROOT$/skills/**") or a skills-only edit will not ' + + 'invalidate the build, and `outputs: ["dist/**"]` will restore a stale copy', + ).toBe(true) + }) + + it('keeps the generic build outputs on the overrides', () => { + // An override replaces the generic task wholesale — dropping `outputs` + // would stop the cache restoring dist at all for these two packages. + for (const name of copiers) { + expect(turbo.tasks[`${name}#build`].outputs).toEqual(['dist/**']) + } + }) +}) diff --git a/scripts/lint-typecheck-scope.mjs b/scripts/lint-typecheck-scope.mjs new file mode 100644 index 000000000..51198fb02 --- /dev/null +++ b/scripts/lint-typecheck-scope.mjs @@ -0,0 +1,165 @@ +/** + * A `typecheck` gate must compile SOURCE, and only source. + * + * TypeScript's default `exclude` is `["node_modules", "bower_components", + * "jspm_packages"]` plus `outDir` — it does NOT cover `dist/`. So a tsconfig + * with no `include`, no `exclude` and no `outDir` globs `**\/*`, which sweeps the + * package's own build output into the program alongside its source. + * + * That makes a CI gate non-deterministic in a way that is invisible when it + * passes. `turbo run typecheck --filter ` only builds the package's + * DEPENDENCIES (`dependsOn: ["^build"]`), not the package itself, so whether + * `dist/` exists during the gate depends on what an unrelated earlier CI step + * happened to build transitively. Reorder the steps and the gate silently + * compiles a different set of files. Locally, after a full build, it compiles a + * different set again. + * + * `skipLibCheck` hides most of the consequences — a stale `.d.ts` importing a + * deleted package still exits 0 — so this cannot be left to "CI is green". + * + * Fix by scoping the tsconfig: either an explicit `include` naming the source + * roots, or an `exclude` listing `dist`. Note that specifying `exclude` REPLACES + * the default list, so `node_modules` must be re-listed alongside `dist`. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +// Roots holding workspace members, mirroring `pnpm-workspace.yaml`. Override +// with argv[2..] for tests / ad-hoc checks (each arg is a package directory). +const WORKSPACE_ROOTS = ['packages', 'examples'] + +/** Every directory that looks like a workspace member. */ +function discoverPackages() { + const found = [] + for (const root of WORKSPACE_ROOTS) { + const abs = resolve(REPO_ROOT, root) + if (!existsSync(abs)) continue + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + found.push(join(abs, entry.name)) + } + } + const e2e = resolve(REPO_ROOT, 'e2e') + if (existsSync(e2e)) found.push(e2e) + return found +} + +const targets = process.argv.slice(2).length + ? process.argv.slice(2).map((t) => resolve(REPO_ROOT, t)) + : discoverPackages() + +/** + * Strip comments and trailing commas so `JSON.parse` accepts a tsconfig. + * + * Scans character by character tracking string state rather than pattern + * matching: these tsconfigs map `"@/*": ["../stack/src/*"]`, and a regex for + * block comments happily eats from the `/*` inside that key to the next `*\/`, + * silently destroying the file it was meant to read. + */ +function readJsonc(path) { + const raw = readFileSync(path, 'utf8') + let out = '' + let inString = false + let escaped = false + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + + if (inString) { + out += ch + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && raw[i + 1] === '/') { + while (i < raw.length && raw[i] !== '\n') i++ + out += '\n' + continue + } + if (ch === '/' && raw[i + 1] === '*') { + i += 2 + while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++ + i++ + continue + } + out += ch + } + + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) +} + +const offenders = [] + +for (const pkgDir of targets) { + const pkgJsonPath = join(pkgDir, 'package.json') + const tsconfigPath = join(pkgDir, 'tsconfig.json') + if (!existsSync(pkgJsonPath) || !existsSync(tsconfigPath)) continue + + let pkgJson + let tsconfig + try { + pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + tsconfig = readJsonc(tsconfigPath) + } catch (err) { + offenders.push( + `${relative(REPO_ROOT, tsconfigPath)}: could not be parsed — ${err.message}`, + ) + continue + } + + // Only packages wired as a gate. A tsconfig nothing runs is an editor + // setting, not a CI contract. + const scripts = pkgJson.scripts ?? {} + const gate = + scripts.typecheck ?? + (scripts.build === 'tsc --noEmit' ? scripts.build : undefined) + if (gate === undefined) continue + + // An explicit `include` scopes the program by itself. + if (Array.isArray(tsconfig.include) && tsconfig.include.length > 0) continue + + // Otherwise `exclude` has to carry it. `outDir` also excludes by default, but + // these gates all set `noEmit`, so relying on it would be a trap. + const exclude = Array.isArray(tsconfig.exclude) ? tsconfig.exclude : [] + const excludesDist = exclude.some( + (e) => typeof e === 'string' && /(^|\/)dist(\/|$|\*)/.test(e), + ) + if (excludesDist) continue + + offenders.push( + `${relative(REPO_ROOT, tsconfigPath)}: \`${pkgJson.name}\` runs a typecheck gate ` + + `(\`${gate}\`) but the tsconfig declares neither an \`include\` nor an ` + + '`exclude` covering `dist`, so the gate compiles its own build output', + ) +} + +if (offenders.length > 0) { + console.error( + `Found ${offenders.length} typecheck gate(s) that compile build output as well as source:\n`, + ) + for (const o of offenders) console.error(` ${o}`) + console.error( + "\nTypeScript's default `exclude` does not cover `dist/`, so a tsconfig with\n" + + "no `include` globs `**/*` and sweeps the package's own emitted `.d.ts`\n" + + 'and `.js` into the gate. Whether `dist/` exists during CI depends on what\n' + + 'an earlier, unrelated step built transitively — so the gate compiles a\n' + + 'different program in CI than it does locally, and silently changes if the\n' + + 'steps are reordered.\n\n' + + 'Add to the tsconfig:\n\n' + + ' "exclude": ["dist", "node_modules"]\n\n' + + '(`exclude` REPLACES the default list, so `node_modules` must be re-listed.)\n' + + 'Or give it an explicit `include` naming the source roots, as `e2e` and\n' + + '`examples/prisma` do.', + ) + process.exit(1) +} diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 185ee801b..d766079df 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -312,12 +312,11 @@ 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. +- `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. Writing `Encryption({ schemas: [] })` is a compile error, but an array typed `AnyV3Table[]` that is empty at runtime compiles and throws on init instead. - **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" +import { type AnyV3Table, Encryption, type EncryptionClientFor, encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email") }) @@ -329,7 +328,6 @@ client = await Encryption({ schemas: [users] }) function withClient(c: EncryptionClientFor) { /* … */ } ``` - ```typescript // Error handling try { diff --git a/turbo.json b/turbo.json index ff8873f3c..f86fa0a04 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,25 @@ "dependsOn": ["^build"], "outputs": [] }, + // These two builds `cpSync('../../skills', 'dist/skills')` — they consume a + // directory OUTSIDE their own package, which `$TURBO_DEFAULT$` does not + // cover. Without naming it here a skills-only edit does not invalidate the + // build, and since `build` now declares `outputs: ["dist/**"]`, the cache + // hit actively RESTORES the previous `dist/skills`. `skills/` ships inside + // the `stash` and `@cipherstash/wizard` tarballs and is copied into + // customer repos by `stash init`, so that silently publishes stale guidance. + "stash#build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], + "env": ["STASH_POSTHOG_KEY"] + }, + "@cipherstash/wizard#build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], + "env": ["STASH_POSTHOG_KEY"] + }, // Typechecks `packages/stack/dist-types` against the BUILT declarations, so // it must run after `build` — unlike `test:types`, which reads source. "test:types:dist": {