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 extractEncryptionSchemaV3 → extractEncryptionSchema 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)
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 }).
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.
- 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
References
Surfaced during the EQL v3 Drizzle adapter type-safety pass (A3/M1).
Summary
extractEncryptionSchema(the Drizzle adapter) erases per-column concrete types, returning the widenedAnyV3Table. As a result, inserts against an extracted schema are not fully type-safe:InferPlaintextover 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 → decryptModeltyping is already clean. It's theinsert-side typing against an extracted schema that widens.Verification
Reproduced on
remove-v2with a throwaway.test-d.tsrunning the documented flow throughvitest --typecheck: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.mainvsremove-v2packages/stack-drizzle/src/column.ts(wassrc/v3/column.ts)packages/stack-drizzle/src/types.ts(wassrc/v3/types.ts)packages/stack-drizzle/src/codec.ts(wassrc/v3/codec.ts)packages/stack/src/eql/v3/**packages/stack-drizzle/src/schema-extraction.tsImpact
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)preservesT.InferPlaintext<T>/InferEncrypted<T>map each concrete column to its precise plaintext/envelope type.But
extractEncryptionSchema(table: PgTable): AnyV3Tablebuildscolumns: Record<string, AnyEncryptedV3Column>(widened) and returnsAnyV3Table, discardingT.makeEqlV3Column<C extends AnyEncryptedV3Column>(builder: C)acceptsCbut discards it in the return type — it returns a barecustomType<{ data: Encrypted; driverData: string | null }>. The builder is stashed at runtime only (writeBuilder, symbol carrier) and recovered bygetEqlV3Column, which returns the widenedAnyEncryptedV3Column | undefined. There is nothing at the type level for extraction to recover.Proposed fix (three linked pieces)
makeEqlV3Columncarries 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 builderC(EqlV3Column<C> = <drizzle column> & { readonly __v3Builder?: C }).extractEncryptionSchemabecomes generic (packages/stack-drizzle/src/schema-extraction.ts) —<T extends PgTable>(table: T): EncryptedTable<BuildersOf<T>>, whereBuildersOf<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.packages/stack/src/eql/v3/table.ts,packages/stack/src/eql/v3/columns.ts) — make the brand flow cleanly throughEncryptedTable/EncryptedV3TableColumn/PlaintextForColumn/InferPlaintext.Risk / why it needs its own PR
customTypecolumns that recovers a phantom brand and filters non-encrypted columns, without breaking Drizzle's native insert/select inference.packages/stack/src/eql/v3is byte-identical tomain, so nothing about the refactor has de-risked this.Acceptance criteria
extractEncryptionSchemareturns a precisely-typedEncryptedTable<...>inferred from the Drizzle table's encrypted columns..test-d.tscoverage proving both paths stay correct: (a) the extracted-Drizzle insert path, and (b) the existing hand-declaredencryptedTable({...})path. This drops into the existing harness —packages/stack-drizzle/vitest.config.tsalready setstypecheck.include: ['__tests__/**/*.test-d.ts']with atest:typesscript, andtypes.test-d.ts/operators.test-d.tspass today. There is currently no test-d coverage of extraction typing.References
packages/stack-drizzle/src/schema-extraction.tspackages/stack-drizzle/src/column.ts(makeEqlV3Column,getEqlV3Column)packages/stack/src/eql/v3/table.ts(EncryptedTable,InferPlaintext,InferEncrypted,AnyV3Table)packages/stack/src/eql/v3/columns.ts(PlaintextForColumn,AnyEncryptedV3Column,EncryptedV3TableColumn)Surfaced during the EQL v3 Drizzle adapter type-safety pass (A3/M1).