Skip to content

Make EncryptionClient generic over the schema tuple; retire the TypedEncryptionClient overlay #637

Description

@coderdan

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 usersawait 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-drizzlecreateEncryptionOperatorsV3 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions