Skip to content

Type-safe extractEncryptionSchema: preserve per-column concrete types for Drizzle-extracted schemas #589

Description

@tobyhede

Summary

extractEncryptionSchema (the Drizzle adapter) erases per-column concrete types, returning the widened AnyV3Table. As a result, inserts against an extracted schema are not fully type-safe: InferPlaintext over the widened schema collapses columns to an index-signature shape that doesn't match a concrete row, so plain (non-encrypted) helper columns don't type-match on insert.

This is a type-safety / DX gap only — there is no runtime bug. The runtime already recovers each column's concrete builder correctly, encrypt/insert/select/decrypt all work, and the select → decryptModel typing is already clean. It's the insert-side typing against an extracted schema that widens.

Updated 2026-07-24. Re-verified after the remove-v2 refactor, which renamed extractEncryptionSchemaV3extractEncryptionSchema and moved the Drizzle adapter into its own package. The issue is still valid; paths, the API name, and the acceptance criteria have been corrected below. See "Verification" for the reproduction.

Verification

Reproduced on remove-v2 with a throwaway .test-d.ts running the documented flow through vitest --typecheck:

InferPlaintext<typeof authored>   →  { email: string; age: number }        precise
InferPlaintext<typeof extracted>  →  { [x: string]: …; [x: number]: … }    index signature

The hand-authored encryptedTable({...}) path types precisely; the extracted path collapses to an index signature with no named columns.

It reproduces identically on main: the three files that make up the entire causal chain are unchanged between the branches.

File main vs remove-v2
packages/stack-drizzle/src/column.ts (was src/v3/column.ts) identical
packages/stack-drizzle/src/types.ts (was src/v3/types.ts) identical
packages/stack-drizzle/src/codec.ts (was src/v3/codec.ts) identical
packages/stack/src/eql/v3/** identical
packages/stack-drizzle/src/schema-extraction.ts rename only — no behavioural or type-level change

Impact

Users following the documented Drizzle flow (extractEncryptionSchema(table)Encryption({ schemas })bulkEncryptModels) get widened insert typing rather than precise per-column plaintext types.

Root cause

The v3 core is already built for precise typing:

  • EncryptedTable<T> is generic over the exact per-column map; encryptedTable<T>(name, columns: T) preserves T.
  • InferPlaintext<T> / InferEncrypted<T> map each concrete column to its precise plaintext/envelope type.

But extractEncryptionSchema(table: PgTable): AnyV3Table builds columns: Record<string, AnyEncryptedV3Column> (widened) and returns AnyV3Table, discarding T. makeEqlV3Column<C extends AnyEncryptedV3Column>(builder: C) accepts C but discards it in the return type — it returns a bare customType<{ data: Encrypted; driverData: string | null }>. The builder is stashed at runtime only (writeBuilder, symbol carrier) and recovered by getEqlV3Column, which returns the widened AnyEncryptedV3Column | undefined. There is nothing at the type level for extraction to recover.

Proposed fix (three linked pieces)

  1. makeEqlV3Column carries the concrete builder at the type level (packages/stack-drizzle/src/column.ts) — add a recoverable phantom brand, e.g. the returned column type parameterised by its builder C (EqlV3Column<C> = <drizzle column> & { readonly __v3Builder?: C }).
  2. extractEncryptionSchema becomes generic (packages/stack-drizzle/src/schema-extraction.ts) — <T extends PgTable>(table: T): EncryptedTable<BuildersOf<T>>, where BuildersOf<T> is a mapped type that recovers each column's brand and keeps only the branded (encrypted) ones — the type-level mirror of the existing runtime loop. Runtime is unchanged; only the return type becomes precise.
  3. Adjust core inference types as needed (packages/stack/src/eql/v3/table.ts, packages/stack/src/eql/v3/columns.ts) — make the brand flow cleanly through EncryptedTable / EncryptedV3TableColumn / PlaintextForColumn / InferPlaintext.

Risk / why it needs its own PR

  • Advanced type-level TS: a mapped/conditional type over Drizzle's deeply-generic customType columns that recovers a phantom brand and filters non-encrypted columns, without breaking Drizzle's native insert/select inference.
  • Piece 3 touches v3 core typing, which is load-bearing for every v3 consumer (the whole typed-client surface), not just Drizzle. A regression there affects all v3 users. packages/stack/src/eql/v3 is byte-identical to main, so nothing about the refactor has de-risked this.
  • Should land as a standalone, independently-reviewed PR — not bundled into adapter feature work.

Acceptance criteria

  • extractEncryptionSchema returns a precisely-typed EncryptedTable<...> inferred from the Drizzle table's encrypted columns.
  • .test-d.ts coverage proving both paths stay correct: (a) the extracted-Drizzle insert path, and (b) the existing hand-declared encryptedTable({...}) path. This drops into the existing harness — packages/stack-drizzle/vitest.config.ts already sets typecheck.include: ['__tests__/**/*.test-d.ts'] with a test:types script, and types.test-d.ts / operators.test-d.ts pass today. There is currently no test-d coverage of extraction typing.
  • No runtime behavior change; no regression to non-Drizzle v3 consumers.

References

Surfaced during the EQL v3 Drizzle adapter type-safety pass (A3/M1).

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions