Skip to content

fix(stack-drizzle): preserve per-column types in extractEncryptionSchema - #845

Merged
coderdan merged 4 commits into
mainfrom
fix/issue-589-typed-extract-encryption-schema
Jul 30, 2026
Merged

fix(stack-drizzle): preserve per-column types in extractEncryptionSchema#845
coderdan merged 4 commits into
mainfrom
fix/issue-589-typed-extract-encryption-schema

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #589.

Summary

extractEncryptionSchema now preserves each branded Drizzle column concrete EQL v3 domain instead of widening every extracted schema. This keeps InferPlaintext, model operations, and addressed-column encryption precise through the documented Drizzle flow.

The return type also preserves the safe pre-existing widened behavior when no private brands can be recovered. Tables annotated as PgTable and ordinary customType columns detected through their EQL SQL domain now return AnyV3Table, rather than the incorrect empty schema type, while runtime extraction continues to discover their encrypted columns.

The implementation remains isolated to @cipherstash/stack-drizzle; no v3 core or runtime behavior changes are required.

Tests

  • compile-time coverage for branded extraction and the equivalent hand-authored schema
  • regressions for widened PgTable inputs and SQL-type fallback columns
  • model-operation coverage proving widened encrypted fields become Encrypted, not unchanged plaintext
  • runtime Drizzle suite: 371 tests passed
  • type suite: 18 tests passed
  • package build and repository code:check passed

Release notes

The existing changeset updates @cipherstash/stack-drizzle and the shipped stash-drizzle skill documents both precise branded inference and the widened fallback boundary.

Summary by CodeRabbit

  • Bug Fixes

    • Improved extractEncryptionSchema typing to retain each encrypted column’s concrete Postgres domain (instead of falling back to a widened schema).
    • Plain helper columns are no longer included in inferred encrypted plaintext types.
    • Encryption/bulk-encryption helpers now enforce per-field plaintext/domain correctness at compile time.
  • Documentation

    • Updated guidance on when schema extraction preserves precise types versus when it falls back (e.g., when tables are widened).
  • Tests

    • Expanded compile-time schema-extraction coverage and adjusted integration setup for more precise typing.

`extractEncryptionSchema` returned the widened `AnyV3Table`, discarding
every column's concrete EQL v3 domain. `InferPlaintext` over an extracted
schema therefore collapsed to an index signature, so along the documented
flow — `extractEncryptionSchema(table)` -> `Encryption({ schemas })` ->
`bulkEncryptModels` — a `string` written to an `IntegerOrd` column
type-checked, and plain helper columns (`id`, a plain `text()`) were typed
as encrypted. Type-safety/DX only: the runtime already recovered each
builder correctly via `getEqlV3Column`.

Three linked pieces, all in `@cipherstash/stack-drizzle` — the v3 core
needed no change:

- `makeEqlV3Column` carries its builder `C` on a phantom brand. The brand
  rides on the column's `_` config, not the builder object: `pgTable()`
  rebuilds each builder through drizzle's `BuildColumn`, which keeps only
  `Omit<TBuilder['_'], keyof MakeColumnConfig<…>>` — drizzle's own
  extension point (`NotNull<T> = T & { _: { notNull: true } }`). A brand on
  the object would be dropped and extraction would have nothing to read.
- `extractEncryptionSchema` is generic over the table and returns
  `EncryptedTable<Cols> & Cols`, the shape `encryptedTable()` itself
  returns. `EncryptedColumnsOf<T>` recovers each brand and keeps only the
  branded columns — the type-level mirror of the existing runtime loop,
  which is unchanged.
- `V3BuilderOf` short-circuits `any` to `AnyEncryptedV3Column`, so an
  untyped table keeps the widened-but-usable typing it had before rather
  than branding every column `any`.

`__tests__/schema-extraction.test-d.ts` locks both paths: extracted and
hand-authored must infer the same plaintext map, reject a field typed
against the wrong domain, and pin `encrypt` to the addressed column. Six of
its eight assertions fail without this fix.

Two integration files relied on the old looseness and are corrected, not
worked around: the dynamically-built matrix table names its model type
explicitly (its rows are a pure index signature, so inference dropped the
`| null` that `nullableTextEq` needs), and the bench comment claiming
extraction returns `AnyV3Table` is now false and says so.

Closes #589
@tobyhede
tobyhede requested a review from a team as a code owner July 30, 2026 03:36
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9a4af72

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack-drizzle Minor
stash Minor
@cipherstash/bench Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Minor
@cipherstash/stack-supabase Minor
@cipherstash/prisma-next Minor
@cipherstash/wizard Minor
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

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

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@tobyhede, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5188bebb-7d00-4154-8a06-a7d64bdbde3f

📥 Commits

Reviewing files that changed from the base of the PR and between 1def19b and 9a4af72.

📒 Files selected for processing (5)
  • .changeset/typed-extract-encryption-schema.md
  • packages/stack-drizzle/__tests__/schema-extraction.test-d.ts
  • packages/stack-drizzle/src/column.ts
  • packages/stack-drizzle/src/schema-extraction.ts
  • skills/stash-drizzle/SKILL.md
📝 Walkthrough

Walkthrough

extractEncryptionSchema now preserves concrete EQL v3 domains from branded Drizzle columns, producing precise per-column plaintext types while retaining AnyV3Table fallback behavior for widened or unbranded tables. Type tests, integration typing, documentation, and changeset metadata were added.

Changes

Typed schema extraction

Layer / File(s) Summary
Brand encrypted Drizzle columns
packages/stack-drizzle/src/column.ts
Carrier columns retain concrete EQL v3 builders through EqlV3Column and V3BuilderOf type utilities.
Preserve extracted schema types
packages/stack-drizzle/src/schema-extraction.ts
extractEncryptionSchema is generic and maps branded encrypted columns to precise EncryptedTable types while preserving widened fallback behavior.
Validate typed encryption flows
packages/stack-drizzle/__tests__/schema-extraction.test-d.ts, packages/stack-drizzle/integration/relational.integration.test.ts, packages/bench/src/drizzle/setup.ts, skills/stash-drizzle/SKILL.md, .changeset/typed-extract-encryption-schema.md
Type assertions cover modified and JSON domains, encryption rejection, and bulk model typing; documentation and release metadata describe the updated behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: coderdan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving per-column types in extractEncryptionSchema.
Linked Issues check ✅ Passed The changes satisfy #589 by branding concrete builders, making extractEncryptionSchema generic, preserving widened fallback behavior, and adding type tests.
Out of Scope Changes check ✅ Passed The PR stays focused on the Drizzle type-safety fix, with only supporting tests and docs updated.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-589-typed-extract-encryption-schema

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/stack-drizzle/src/column.ts (1)

254-261: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid the unsuppressed type-erasing cast.

column as unknown as EqlV3ColumnCarrier bypasses source typing without a scoped suppression. Replace it with a typed runtime-carrier helper, or add a narrow Biome suppression documenting why this attachment is safe. As per coding guidelines, “Avoid type-erasing assertions such as as any, as never, and as unknown in source unless deliberately suppressed with a reason.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack-drizzle/src/column.ts` around lines 254 - 261, Replace the
unsuppressed unknown-based cast in the column construction flow with a typed
runtime-carrier helper, or add a narrowly scoped Biome suppression with a reason
documenting why the attachment is safe. Keep the existing writeBuilder calls and
getEqlV3Column runtime behavior unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/typed-extract-encryption-schema.md:
- Line 18: Update the changeset release note to include the required follow-up
for the separately maintained documentation site, alongside the existing
skills/stash-drizzle documentation entry. Keep the note focused on the public
typing change and explicitly identify the documentation-site update as required.

In `@packages/stack-drizzle/src/schema-extraction.ts`:
- Around line 49-51: Update ExtractedEncryptionSchema and its schema-extraction
typing so mixed tables with branded and SQL-type-recovered columns retain the
recovered columns’ encrypted runtime behavior instead of selecting the narrow
precise branch. Widen the mixed-schema result or represent recovered columns as
widened encrypted columns, while preserving precise typing for fully branded
tables and AnyV3Table for tables with no encrypted columns; add a type test
covering one branded and one ordinary EQL customType column.

---

Nitpick comments:
In `@packages/stack-drizzle/src/column.ts`:
- Around line 254-261: Replace the unsuppressed unknown-based cast in the column
construction flow with a typed runtime-carrier helper, or add a narrowly scoped
Biome suppression with a reason documenting why the attachment is safe. Keep the
existing writeBuilder calls and getEqlV3Column runtime behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ead2364e-fc06-4a7a-9e66-086e8d69b5c0

📥 Commits

Reviewing files that changed from the base of the PR and between 03ff8f7 and dd65365.

📒 Files selected for processing (7)
  • .changeset/typed-extract-encryption-schema.md
  • packages/bench/src/drizzle/setup.ts
  • packages/stack-drizzle/__tests__/schema-extraction.test-d.ts
  • packages/stack-drizzle/integration/relational.integration.test.ts
  • packages/stack-drizzle/src/column.ts
  • packages/stack-drizzle/src/schema-extraction.ts
  • skills/stash-drizzle/SKILL.md


**Runtime behaviour is unchanged** — the runtime already recovered each column's builder correctly, so this is a type-level fix only. It is `minor` rather than `patch` because code that previously compiled against the widened types can now fail to compile: a model field typed against the wrong domain, or a schema-derived type that relied on the old index signature. Rows whose shape is only known at runtime (a dynamically built table) should name their model type explicitly — `client.bulkEncryptModels<typeof schema, MyRow>(rows, schema)` — rather than being cast back to `AnyV3Table`.

`skills/stash-drizzle` documents the preserved typing and warns against casting an extracted schema to `AnyV3Table` to make an insert compile.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Record the documentation-site follow-up.

This is a public type-surface change, but the release note only mentions the in-repository skill. Add the required note for the separately maintained documentation site. As per coding guidelines, “When changing public APIs, preserve ESM/CJS compatibility, update in-repository examples, add or extend tests, and note required updates to the separately maintained documentation site.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/typed-extract-encryption-schema.md at line 18, Update the
changeset release note to include the required follow-up for the separately
maintained documentation site, alongside the existing skills/stash-drizzle
documentation entry. Keep the note focused on the public typing change and
explicitly identify the documentation-site update as required.

Source: Coding guidelines

Comment thread packages/stack-drizzle/src/schema-extraction.ts Outdated
tobyhede added 2 commits July 30, 2026 13:50
Two gaps in the #589 type-level suite, both of which would have shipped
green.

Drizzle column modifiers. Every existing fixture declares bare columns,
but real schemas almost always use `.notNull()`. The brand survives
`pgTable()` only because drizzle's `BuildColumn` keeps
`Omit<TBuilder['_'], keyof MakeColumnConfig<…> | 'brand' | 'dialect'>`,
and modifiers then intersect further into `_` (`NotNull<T> = T & { _: {
notNull: true } }`). That is an upstream compatibility contract, not one
we control: a drizzle-orm change to either step could drop the brand for
modified columns while bare ones kept working. The runtime would not
notice — `getEqlV3Column` recovers builders from the runtime carrier and
the SQL domain, never from the type — so extraction would silently type a
`.notNull()` column as a plain pass-through field while still encrypting
it.

Verified the guard isolates that class: narrowing `V3BuilderOf` to match
only `notNull: false` fails exactly the three new modifier assertions and
leaves all fourteen pre-existing ones green.

The json domain. `types.Json` is the only domain whose plaintext is a
union (`JsonDocument`) rather than a scalar, which makes it the easiest
to lose to a widened index signature without another assertion noticing.
Covered on both the extracted and hand-authored paths, including a
`@ts-expect-error` on a scalar written to a json column.

Both blocks fail against the pre-fix source, so they cover the fix rather
than passing incidentally.

Test-only; the existing changeset already covers the behaviour.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a long-standing Drizzle adapter type-safety gap by making extractEncryptionSchema() preserve each encrypted column’s concrete EQL v3 domain when the table’s branded column types are available, while retaining the existing safe AnyV3Table fallback for widened PgTable inputs and SQL-domain-detected customType columns. This improves InferPlaintext, column addressing (schema.email), and model encryption (encryptModel/bulkEncryptModels) type precision without changing runtime extraction behavior.

Changes:

  • Add a private, type-level phantom brand to Drizzle carrier columns (makeEqlV3Column) and a mapped-type extractor to recover the concrete v3 builders from a pgTable() definition.
  • Make extractEncryptionSchema generic and conditionally precise, widening back to AnyV3Table when brands are missing or incomplete (e.g., widened PgTable, SQL-domain fallback columns, mixed branded + SQL-derived).
  • Add compile-time .test-d.ts coverage for precise inference and fallback behavior; update the shipped stash-drizzle skill and the changeset accordingly.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/stack-drizzle/src/column.ts Introduces a type-only builder brand (EqlV3Column<C>) and V3BuilderOf recovery to carry concrete v3 domains through Drizzle’s pgTable() typing.
packages/stack-drizzle/src/schema-extraction.ts Makes extractEncryptionSchema generic and conditionally precise via EncryptedColumnsOf<T> / fallback logic, while keeping runtime extraction unchanged.
packages/stack-drizzle/__tests__/schema-extraction.test-d.ts Adds type-level regression tests proving precise inference matches hand-authored schemas and that widened/SQL-derived/mixed cases fall back safely.
packages/stack-drizzle/integration/relational.integration.test.ts Adjusts a runtime-shaped table encryption call to provide an explicit model type parameter where inference is intentionally impossible.
skills/stash-drizzle/SKILL.md Documents the new “precise when branded, widened when not” boundary and warns against casting away the type safety.
.changeset/typed-extract-encryption-schema.md Ships release notes for the typing change (@cipherstash/stack-drizzle minor) and the skill update (stash patch).
packages/bench/src/drizzle/setup.ts Updates bench commentary to reflect the new precise InferPlaintext behavior while explaining why the bench row type remains hand-written.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderdan
coderdan merged commit 62df494 into main Jul 30, 2026
15 checks passed
@coderdan
coderdan deleted the fix/issue-589-typed-extract-encryption-schema branch July 30, 2026 05:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants