Motivation
Surfaced while reviewing the encryptQuery wrapper in #636 (part of #622). The EQL v3 per-column type precision (PlaintextForColumn<Col>, queryType constrained to a column's capabilities, precise encrypted/decrypted model shapes) lives in a separate typed overlay, TypedEncryptionClient<S>, built by typedClient(client, ...schemas) in packages/stack/src/encryption/v3.ts and returned by EncryptionV3({ schemas }).
That overlay is a delegating façade: every method re-declares precise generic signatures and then forwards to the base EncryptionClient. The forwarding boundary is the sole reason the wrapper needs type-erasing casts (as never on forwarded args, as unknown as before it was refactored). The base EncryptionClient itself is clean — its public API is broad-but-concrete (encrypt(plaintext: Plaintext, opts: EncryptOptions): EncryptOperation) with cast-free implementations. The casts are purely an artifact of re-typing-by-delegation.
Why the casts are structurally forced (not laziness)
- The overlay's single
encryptQuery/encrypt overloads are generic (PlaintextForColumn<Col>).
- To implement overloads with a function, TS checks each public overload signature is compatible with the hidden implementation signature. A generic conditional type like
PlaintextForColumn<Col> is opaque until instantiated, so it is assignable only to unknown — anything narrower fails with TS2394 (confirmed empirically: narrowing the impl params, even widening opts to object, both fail on the plaintext param).
- Once the implementation signature is
unknown, forwarding into the base client's precisely-typed params requires an assertion. It is runtime-sound (the value passes through untouched; the base client does the real routing/validation), but statically unchecked at that seam.
Proposed change
Make the base client generic over the registered schema tuple:
Encryption<S>({ schemas }): Promise<EncryptionClient<S>>
with the per-column precise signatures living directly on EncryptionClient<S>, whose methods construct the operations (return new EncryptQueryOperation(this.client, plaintext, opts)) rather than forwarding to another differently-typed method. No widen→forward→re-narrow seam ⇒ the TypedEncryptionClient overlay and its casts can be retired.
Consumers to keep working (audit before changing)
- Direct SDK users —
await EncryptionV3({ schemas }) is a documented public API (@cipherstash/stack/v3; stash-encryption skill + stack README present it as "the strongly-typed v3 client"). A migration/compat path is needed if EncryptionV3 is folded into a generic Encryption.
@cipherstash/stack-drizzle — createEncryptionOperatorsV3 takes the typed client (its structural OperandEncryptionClient contract). Guarded by the M1 .test-d.ts.
@cipherstash/stack-supabase — deliberately consumes the raw chainable EncryptionClient, not the typed one (stack-supabase/src/index.ts:227). Must stay working unchanged.
- Bulk ops / v2 users of the base client.
Key constraint
EncryptionClient is the shared v2 + v3, schema-agnostic client used across all of the above. Making it generic-over-S is cross-cutting: it touches every call site and the v2 paths, which is why the low-blast-radius overlay was chosen originally. This is a deliberate refactor, not a drive-by.
Caveat (so scope is honest)
Moving precision onto the base client removes the forwarding seam (the dominant cast source). Full cast-freedom additionally requires the operation classes (EncryptQueryOperation, EncryptOperation, …) to carry the precise types too; otherwise the forwarding cast is traded for a smaller construction-boundary cast where PlaintextForColumn<Col> meets an operation constructor's Plaintext param. Still strictly better — one localized, honest boundary instead of a whole delegating layer — and potentially zero-cast if the operations are made generic as well.
Acceptance
TypedEncryptionClient overlay + typedClient delegation retired (or reduced to a thin alias) with no type-erasing casts in the v3 client path.
- All consumers above compile and pass (
test:types incl. the Drizzle M1 guard; Supabase raw-client path unchanged).
- A migration note for the public
EncryptionV3 surface if its shape changes.
Follow-up to #622 / #636.
Motivation
Surfaced while reviewing the
encryptQuerywrapper in #636 (part of #622). The EQL v3 per-column type precision (PlaintextForColumn<Col>,queryTypeconstrained to a column's capabilities, precise encrypted/decrypted model shapes) lives in a separate typed overlay,TypedEncryptionClient<S>, built bytypedClient(client, ...schemas)inpackages/stack/src/encryption/v3.tsand returned byEncryptionV3({ schemas }).That overlay is a delegating façade: every method re-declares precise generic signatures and then forwards to the base
EncryptionClient. The forwarding boundary is the sole reason the wrapper needs type-erasing casts (as neveron forwarded args,as unknown asbefore it was refactored). The baseEncryptionClientitself is clean — its public API is broad-but-concrete (encrypt(plaintext: Plaintext, opts: EncryptOptions): EncryptOperation) with cast-free implementations. The casts are purely an artifact of re-typing-by-delegation.Why the casts are structurally forced (not laziness)
encryptQuery/encryptoverloads are generic (PlaintextForColumn<Col>).PlaintextForColumn<Col>is opaque until instantiated, so it is assignable only tounknown— anything narrower fails with TS2394 (confirmed empirically: narrowing the impl params, even wideningoptstoobject, both fail on the plaintext param).unknown, forwarding into the base client's precisely-typed params requires an assertion. It is runtime-sound (the value passes through untouched; the base client does the real routing/validation), but statically unchecked at that seam.Proposed change
Make the base client generic over the registered schema tuple:
with the per-column precise signatures living directly on
EncryptionClient<S>, whose methods construct the operations (return new EncryptQueryOperation(this.client, plaintext, opts)) rather than forwarding to another differently-typed method. No widen→forward→re-narrow seam ⇒ theTypedEncryptionClientoverlay and its casts can be retired.Consumers to keep working (audit before changing)
await EncryptionV3({ schemas })is a documented public API (@cipherstash/stack/v3;stash-encryptionskill + stack README present it as "the strongly-typed v3 client"). A migration/compat path is needed ifEncryptionV3is folded into a genericEncryption.@cipherstash/stack-drizzle—createEncryptionOperatorsV3takes the typed client (its structuralOperandEncryptionClientcontract). Guarded by the M1.test-d.ts.@cipherstash/stack-supabase— deliberately consumes the raw chainableEncryptionClient, not the typed one (stack-supabase/src/index.ts:227). Must stay working unchanged.Key constraint
EncryptionClientis the shared v2 + v3, schema-agnostic client used across all of the above. Making it generic-over-Sis cross-cutting: it touches every call site and the v2 paths, which is why the low-blast-radius overlay was chosen originally. This is a deliberate refactor, not a drive-by.Caveat (so scope is honest)
Moving precision onto the base client removes the forwarding seam (the dominant cast source). Full cast-freedom additionally requires the operation classes (
EncryptQueryOperation,EncryptOperation, …) to carry the precise types too; otherwise the forwarding cast is traded for a smaller construction-boundary cast wherePlaintextForColumn<Col>meets an operation constructor'sPlaintextparam. Still strictly better — one localized, honest boundary instead of a whole delegating layer — and potentially zero-cast if the operations are made generic as well.Acceptance
TypedEncryptionClientoverlay +typedClientdelegation retired (or reduced to a thin alias) with no type-erasing casts in the v3 client path.test:typesincl. the Drizzle M1 guard; Supabase raw-client path unchanged).EncryptionV3surface if its shape changes.Follow-up to #622 / #636.