fix(stack): Encryption signature fixes and typecheck gates (A-4, A-6, A-7, C-1)#782
fix(stack): Encryption signature fixes and typecheck gates (A-4, A-6, A-7, C-1)#782tobyhede wants to merge 11 commits into
Conversation
🦋 Changeset detectedLatest commit: 1fbbd71 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…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<S, C> machinery belong with the v2 removal (#637), not here.
`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).
`Encryption({ schemas })` only accepted an array LITERAL. Every indirect
form failed with TS2769: a shared `export const all: AnyV3Table[]`, the
`ReadonlyArray<AnyV3Table>` 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<S>` 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.
…For (A-6)
`ReturnType` reads the LAST overload, which is the nominal one, so
`Awaited<ReturnType<typeof Encryption>>` 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<T>.
Sweeps the idiom out entirely, not just the erroring sites: nominal
callers now say `EncryptionClient`, v3 callers `EncryptionClientFor<S>`.
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.
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 5fab1cf 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.
- 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<AnyV3Table> — 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.
`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.
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.
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.
`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 @ b48494c, 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<infer D>`, 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<D>`, 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.
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.
fe7938e to
1fbbd71
Compare
Review remediation for #772, addressing A-4, A-6, A-7 and C-1 from the verification pass (#778).
Stacked on #780 (
fix/remove-v2-review-remediation), which is itself stacked onremove-v2(#772). Branched at8b3f0b15; the 11 commits here are this PR's own. Design doc:docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md.Scope, and what is deliberately out of it
A-6 and A-7 exist because
Encryptionis overloaded, and it is overloaded because there are two clients: the typed EQL v3 one and the nominal one that v2 and loose introspection-derived schemas need. Two overloads means two discriminators that can disagree — overload resolution readsconfig, the runtime readsschemas. Remove EQL v2 and both defects delete themselves.So this does not attempt the type-level repair the verification doc explores (a single generic signature returning
ClientFor<S, C>, a discriminated-unionWireConfig). That machinery exists only to let two clients coexist and would be deleted by the v2 removal that motivates it — it is #637, and it belongs with PRs 9–12. What is here is the subset that is either independent of the removal (A-4, C-1) or cheap and non-throwaway (A-6, A-7).A-4 — schema arrays that are not literals
Encryption({ schemas })accepted only an array literal. Every indirect form failedTS2769: a sharedAnyV3Table[]module export,ReadonlyArray<AnyV3Table>(the typeprisma-nextexposes publicly), push-built arrays, spreads, unannotatedconst. Widened to accept any non-empty array.prisma-nextdrops its destructure-and-respread workaround; the runtime emptiness check and its error message stay.A-6 — name the client with
EncryptionClientFor<S>ReturnTypereads the last overload — the nominal one — soAwaited<ReturnType<typeof Encryption>>yieldedEncryptionClienteven for an all-v3 schema set, and every assignment of the real client to it failedTS2739. 15 sites carried that error (10 instack, 5 instack-drizzle), none 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 the client gets a name, which is what comparable libraries do (z.infer, arktype'stypeof T.infer, hono'sClient<T>). The idiom is swept out entirely, not just at the erroring sites.Typing these clients correctly unmasked call sites the wrong type had been hiding. Most were
as nevercasts that are simply no longer needed, so those calls are now genuinely checked. Two are not:encrypt-query-stevecandencrypt-query-searchable-jsonexerciseencryptQueryquery types the typed client mis-models (it derives the plaintext from the column's domain, so every query type on atypes.Json()column is typedJsonDocument, but the SteVec types take a JSONPath string, a{ path, value }pair, or a bare scalar). Those two hold the client through the nominal surface with the gap named at the cast, rather than casting at every call and hiding it.A-7 —
initpassthrough on the typed clientThe two discriminators disagreeing has a concrete crash behind it. Hoisting a config into a
ClientConfig-typed variable is enough to split them —ClientConfig.eqlVersionis2 | 3, which the v3 overload'seqlVersion?: 3rejects — so the call types as the nominalEncryptionClientwhile the runtime still returns the typed one.initwas the only member ofEncryptionClientabsent from the typed client, so anything holding it through its declared type hitTypeError: client.init is not a function. Now delegated, with the parity asserted so a future member cannot reopen the hole.The other half — the silent loss of the typed surface, with no diagnostic — is not closable at the type level (the runtime inspects values, the type inspects an erasable static type). It ends with the v2 removal (#637).
C-1 — typecheck the surfaces that compiled nowhere
Four packages had a
tsconfig.jsoncovering src and tests that no CI step ever ran, and were already clean — now gated before they drift:migrate,nextjs,examples/prisma,e2e.packages/nextjsneededvi.Mockimported as a type first.Also declares
outputs: ["dist/**"]on turbo'sbuild. It declared none, so a cache hit replayed logs and restored no files — and theexamples/basicgate added by 5fab1cf typechecks againstpackages/stack/dist/*.d.ts. It held only because fresh CI runners have no cache. Verified by deletingpackages/stack/distand re-running: 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). Theirtest:typesscripts are scoped to__tests__/**/*.test-d.ts, so src, the runtime suites andintegration/**compile nowhere. Most of the drizzle and supabase count is a single root cause in@cipherstash/test-kit'sV3_MATRIX.Found along the way: typed
encryptQuerywasneveragainst the built packageclient.encryptQuery(value, { table, column })did not typecheck for any column when imported from the published package — every plaintext resolved tonever. Reproduced identically against the rc.4 tarball on npm, releasedmain@ b48494c,origin/remove-v2and this branch, so it predates the v2 removal and shipped in every release carrying the v3 typed client.PlaintextForColumn/QueryTypesForColumnrecover the domain viaC extends EncryptedV3Column<infer D>, which needsDbare in the instance type. Its only bare site wasprivate readonly definition: D, and tsc strips private member types on declaration emit — leaving nothing TypeScript can invert, soinfer Dfell back to the constraint and cascaded tonever.encryptescaped it by resolving throughColumnsOf.Fixed with a type-only
declare readonly __domain?: D— nothing emitted at runtime, no call-site change, but it survives declaration emit. Addspackages/stack/dist-typesand atest:types:distscript 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@/….Review
CodeRabbit reviewed the branch — 6 findings, 0 critical. The two majors were real and are fixed in 35b8858:
skills/stash-encryptionclaimed reads of the promoted column decrypt transparently afterstash encrypt cutover(true only through CipherStash Proxy, and contradicted by the very next table row), andstash init's setup prompt told agents to declare an EQL v2 cutover target with atypes.*domain (v3-only; a v2 column is aneql_v2_encryptedcomposite). Three markdownlint findings were skipped — this repo runs no markdownlint, and two of the three suggestions are wrong under CommonMark.Changesets
encryption-schema-arrays(stack minor, prisma-next patch),typed-client-init-parity(stack patch),typed-encrypt-query-dist(stack patch),skills-encryption-client-naming(stash patch).