From 615378a637bfdcc5939f162e164d1d80f1096402 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 21:28:59 +1000 Subject: [PATCH 1/2] fix(stack-drizzle): emit unqualified eql_v3 domain so drizzle-kit DDL is valid (rc.2 M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A v3 encrypted column declared its SQL type as the schema-qualified domain (`public.eql_v3_text_search`). drizzle-kit wraps a customType's whole `dataType()` string in one pair of double quotes, so `generate`/`push` emitted the invalid identifier `"public.eql_v3_text_search"` — Postgres reads that as a single dotted type name and rejects it (`type "public.eql_v3_text_search" does not exist`). Reproduced against drizzle-kit 0.30.6: CREATE TABLE and ADD COLUMN both emit the broken quoted form; generated migrations needed hand repair. Fix: `makeEqlV3Column` now emits the BARE domain (`eql_v3_text_search`) via `stripDomainSchema`, which drizzle-kit renders as the valid `"eql_v3_text_search"` and which resolves through the search_path (the domains live in `public`). This mirrors the v2 `encryptedType` surface, which already emits its bare type for the same reason, and it matches what drizzle-kit introspection reads back on a `push` diff so the two sides stop disagreeing (the `"undefined"."public.eql_v3_*"` mode). Builder recovery is unchanged in identity: `getSqlType()` re-qualifies a bare name back to the canonical `public.eql_v3_*` before matching the recovery keys, and it still accepts an already-qualified name (test doubles / introspected snapshots), so operators and schema extraction see the same domain as before. - Regression guard: every V3_MATRIX domain's `getSQLType()` is asserted dot-free. - Bare-getSQLType recovery test added alongside the existing qualified one. - stash-drizzle skill updated to describe the unqualified generated type + the search-path requirement. Tests: stack-drizzle 370 unit green, test:types clean, build + biome clean. Changeset: stack-drizzle patch + stash patch (skill ships in the tarball). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/drizzle-kit-eql-v3-ddl.md | 24 ++++++++++++ .../stack-drizzle/__tests__/v3/bigint.test.ts | 10 +++-- .../stack-drizzle/__tests__/v3/column.test.ts | 38 +++++++++++++++++-- packages/stack-drizzle/src/v3/column.ts | 37 ++++++++++++++++-- skills/stash-drizzle/SKILL.md | 2 +- 5 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 .changeset/drizzle-kit-eql-v3-ddl.md diff --git a/.changeset/drizzle-kit-eql-v3-ddl.md b/.changeset/drizzle-kit-eql-v3-ddl.md new file mode 100644 index 000000000..32a295e6c --- /dev/null +++ b/.changeset/drizzle-kit-eql-v3-ddl.md @@ -0,0 +1,24 @@ +--- +'@cipherstash/stack-drizzle': patch +'stash': patch +--- + +Fix invalid DDL from `drizzle-kit generate`/`push` for EQL v3 encrypted columns. +A v3 column declared its SQL type as the schema-qualified domain +(`public.eql_v3_text_search`), but drizzle-kit wraps a custom type's whole name +in a single pair of double quotes — emitting `"public.eql_v3_text_search"`, which +Postgres reads as one dotted identifier and rejects with `type +"public.eql_v3_text_search" does not exist`. Generated migrations had to be +hand-repaired. + +The v3 column now emits the **unqualified** domain (`eql_v3_text_search`), which +drizzle-kit renders as the valid `"eql_v3_text_search"` and which resolves via the +search path (the domains live in `public`). This matches how the v2 +`encryptedType` surface already declares its type, and how drizzle-kit reads the +type back during a `push` introspection diff, so the two sides no longer disagree. +Builder recovery still yields the canonical `public.eql_v3_*` identity, so +operators and schema extraction are unchanged. + +The bundled `stash-drizzle` skill is updated to describe the unqualified generated +type and the search-path requirement (hence the `stash` bump — the skill ships in +its tarball). diff --git a/packages/stack-drizzle/__tests__/v3/bigint.test.ts b/packages/stack-drizzle/__tests__/v3/bigint.test.ts index 6a7298170..e31a6a4e2 100644 --- a/packages/stack-drizzle/__tests__/v3/bigint.test.ts +++ b/packages/stack-drizzle/__tests__/v3/bigint.test.ts @@ -54,10 +54,12 @@ describe('v3 drizzle bigint columns', () => { ) }) - it('emits the concrete public.eql_v3_bigint* SQL type through pgTable', () => { - expect(accounts.balance.getSQLType()).toBe('public.eql_v3_bigint_ord') - expect(accounts.ledgerId.getSQLType()).toBe('public.eql_v3_bigint_eq') - expect(accounts.archived.getSQLType()).toBe('public.eql_v3_bigint') + it('emits the BARE eql_v3_bigint* SQL type through pgTable (drizzle-kit safe)', () => { + // Unqualified: a `public.`-prefixed name would be quote-wrapped whole by + // drizzle-kit into an invalid identifier. See makeEqlV3Column. + expect(accounts.balance.getSQLType()).toBe('eql_v3_bigint_ord') + expect(accounts.ledgerId.getSQLType()).toBe('eql_v3_bigint_eq') + expect(accounts.archived.getSQLType()).toBe('eql_v3_bigint') }) it('encrypts a native bigint operand for eq without JSON-stringifying it', async () => { diff --git a/packages/stack-drizzle/__tests__/v3/column.test.ts b/packages/stack-drizzle/__tests__/v3/column.test.ts index f812a3bcf..01a862d57 100644 --- a/packages/stack-drizzle/__tests__/v3/column.test.ts +++ b/packages/stack-drizzle/__tests__/v3/column.test.ts @@ -15,11 +15,29 @@ import { } from '../../src/v3/column' describe('makeEqlV3Column', () => { - it('sets dataType() to the concrete eql_v3 domain', () => { + it('sets dataType() to the BARE eql_v3 domain (no schema qualifier)', () => { const col = makeEqlV3Column(v3Types.IntegerOrd('age')) const table = pgTable('users', { age: col }) - expect(table.age.getSQLType()).toBe('public.eql_v3_integer_ord') + // Bare, not `public.eql_v3_integer_ord`: drizzle-kit wraps the whole + // dataType() string in one pair of quotes, so a qualified name would emit + // the invalid identifier `"public.eql_v3_integer_ord"`. See makeEqlV3Column. + expect(table.age.getSQLType()).toBe('eql_v3_integer_ord') + }) + + it('never leaks a schema-qualified (dotted) type into the emitted DDL', () => { + // The regression guard for the drizzle-kit invalid-DDL bug: getSQLType() is + // what drizzle-kit quote-wraps into the CREATE/ALTER, so a dot here is an + // un-runnable migration. Assert it for every concrete domain. + for (const [eqlType] of typedEntries(V3_MATRIX)) { + const column = makeEqlV3Column(V3_MATRIX[eqlType].builder(slug(eqlType))) + const table = pgTable('t', { [slug(eqlType)]: column } as never) + const sqlType = (table as Record)[ + slug(eqlType) + ].getSQLType() + expect(sqlType).not.toContain('.') + expect(sqlType).toBe(slug(eqlType)) + } }) it('recovers the stashed builder before and after pgTable processing', () => { @@ -98,6 +116,18 @@ describe('makeEqlV3Column', () => { expect(builder?.getEqlType()).toBe('public.eql_v3_text_eq') }) + it('recovers a v3 builder from a BARE getSQLType (a real live column)', () => { + // A processed column reports its type unqualified now, so recovery must + // re-qualify a bare name to the canonical `public.eql_v3_*` identity. + const builder = getEqlV3Column('nickname', { + getSQLType: () => 'eql_v3_text_eq', + }) + + expect(isEqlV3Column({ getSQLType: () => 'eql_v3_text_eq' })).toBe(true) + expect(builder?.getName()).toBe('nickname') + expect(builder?.getEqlType()).toBe('public.eql_v3_text_eq') + }) + it('recognises v3 columns by dataType() when getSQLType is absent', () => { const column = { dataType: () => 'public.eql_v3_text_eq' } const builder = getEqlV3Column('nickname', column) @@ -132,7 +162,9 @@ describe('makeEqlV3Column', () => { const pgColumn = (table as Record)[ columnName ] - expect(pgColumn?.getSQLType()).toBe(eqlType) + // getSQLType() is the bare (unqualified) domain — what drizzle-kit emits — + // while recovery still yields the qualified builder identity. + expect(pgColumn?.getSQLType()).toBe(slug(eqlType)) expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType) }) diff --git a/packages/stack-drizzle/src/v3/column.ts b/packages/stack-drizzle/src/v3/column.ts index 3c2d184a1..cd3a1cb67 100644 --- a/packages/stack-drizzle/src/v3/column.ts +++ b/packages/stack-drizzle/src/v3/column.ts @@ -1,3 +1,4 @@ +import { stripDomainSchema } from '@cipherstash/stack/adapter-kit' import { type AnyEncryptedV3Column, types as v3Types, @@ -6,6 +7,22 @@ import type { Encrypted } from '@cipherstash/stack/types' import { customType } from 'drizzle-orm/pg-core' import { v3FromDriver, v3ToDriver } from './codec.js' +/** The schema the concrete `eql_v3_*` domains are created in. */ +const EQL_V3_DOMAIN_SCHEMA = 'public' + +/** + * Re-attach the `public.` schema to a bare domain name, leaving an + * already-qualified name untouched. Recovery keys (`buildersByDomain`, + * {@link EQL_V3_DOMAINS}) are the qualified `public.eql_v3_*` identities, but a + * live column now reports its type UNqualified (see {@link makeEqlV3Column}), so + * the value read back off a column has to be re-qualified before it can match. + * A name that already carries a schema (a test double, an introspected snapshot) + * passes through unchanged. + */ +function qualifyDomain(sqlType: string): string { + return sqlType.includes('.') ? sqlType : `${EQL_V3_DOMAIN_SCHEMA}.${sqlType}` +} + const buildersByDomain: ReadonlyMap< string, (name: string) => AnyEncryptedV3Column @@ -70,23 +87,37 @@ function getSqlType(column: unknown): string | undefined { typeof columnAny.getSQLType === 'function' ? columnAny.getSQLType() : undefined - if (typeof sqlType === 'string') return sqlType + // Re-qualify: a live column reports its type bare (see `makeEqlV3Column`), but + // the recovery keys are the qualified `public.eql_v3_*` identities. + if (typeof sqlType === 'string') return qualifyDomain(sqlType) const dt = columnAny.dataType const domain = typeof dt === 'function' ? dt() : (columnAny.sqlName ?? dt) - return typeof domain === 'string' ? domain : undefined + return typeof domain === 'string' ? qualifyDomain(domain) : undefined } export function makeEqlV3Column(builder: C) { const domain = builder.getEqlType() const name = builder.getName() + // The SQL type drizzle emits is the domain name WITHOUT its `public.` schema. + // drizzle-kit renders a customType's `dataType()` by wrapping the whole string + // in one pair of double quotes, so a qualified `public.eql_v3_text_search` + // becomes the invalid identifier `"public.eql_v3_text_search"` (Postgres reads + // it as a single type name containing a dot, not schema.type) — the DDL then + // fails with `type "public.eql_v3_text_search" does not exist`. Emitting the + // bare `eql_v3_text_search` yields a valid `"eql_v3_text_search"` that resolves + // via the search_path (the domains live in `public`, always in-path), and it + // also matches what drizzle-kit introspection reads back for a `push` diff, so + // the two sides no longer disagree. + const sqlType = stripDomainSchema(domain) + // What is stored/inserted/selected is the ENCRYPTED EQL v3 jsonb envelope // (produced by `client.encrypt` / `bulkEncryptModels`), NOT the column's // plaintext. So `data` is the envelope type — an insert takes an already- // encrypted `Encrypted`, and a select yields one, ready for `decryptModel`. const column = customType<{ data: Encrypted; driverData: string | null }>({ dataType() { - return domain + return sqlType }, toDriver(value: Encrypted): string | null { return v3ToDriver(value) diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 725f83c6e..79aa07cf6 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -66,7 +66,7 @@ CREATE TABLE users ( ); ``` -You don't usually hand-write this: the `types.*` factories below emit the domain as the column's SQL type, so `drizzle-kit generate` produces the `ADD COLUMN email public.eql_v3_text_search` DDL for you. +You don't usually hand-write this: the `types.*` factories below emit the domain as the column's SQL type, so `drizzle-kit generate` produces the `ADD COLUMN "email" "eql_v3_text_search"` DDL for you. The generated type is **unqualified** (`eql_v3_text_search`, not `public.eql_v3_text_search`): drizzle-kit wraps a custom type's whole name in one pair of quotes, which would turn a schema-qualified name into the invalid identifier `"public.eql_v3_text_search"`. The bare name resolves via the search path because the domains live in `public` — so keep `public` on the search path (the default), and don't hand-edit the generated type back to a qualified name. ## Schema Definition From 49569004fbcec16e01d0212c966ced6fda770da5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 18 Jul 2026 19:48:45 +1000 Subject: [PATCH 2/2] fix(stack-drizzle): assert eql_v3 domains live in public before stripping schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `makeEqlV3Column` emits the bare domain name because drizzle-kit wraps a customType's whole `dataType()` return in one pair of quotes, so a qualified `public.eql_v3_text_search` would emit the invalid identifier `"public.eql_v3_text_search"`. Stripping the `public.` schema is only safe because every domain lives in `public` — `stripDomainSchema`/`qualifyDomain` are inverses over exactly that set. If a domain ever lived in another schema, `stripDomainSchema` would pass its qualified name through untouched and drizzle-kit would silently emit the invalid dotted identifier again. Assert the invariant at column construction so that day fails loudly with an actionable message instead of shipping a broken migration. Addresses the schema-qualification review notes on #688. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../stack-drizzle/__tests__/v3/column.test.ts | 12 ++++++++++++ packages/stack-drizzle/src/v3/column.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/stack-drizzle/__tests__/v3/column.test.ts b/packages/stack-drizzle/__tests__/v3/column.test.ts index 01a862d57..9ca47384d 100644 --- a/packages/stack-drizzle/__tests__/v3/column.test.ts +++ b/packages/stack-drizzle/__tests__/v3/column.test.ts @@ -40,6 +40,18 @@ describe('makeEqlV3Column', () => { } }) + it('throws for a domain outside the public schema instead of emitting bad DDL', () => { + // stripDomainSchema/qualifyDomain are inverses only over public-schema + // domains. A non-public qualified name would pass stripDomainSchema through + // untouched and drizzle-kit would emit the invalid dotted identifier again — + // silently. The guard turns that into a loud construction-time failure. + const rogue = { + getEqlType: () => 'other_schema.eql_v3_text_eq', + getName: () => 'nickname', + } as unknown as Parameters[0] + expect(() => makeEqlV3Column(rogue)).toThrow(EQL_V3_DOMAIN_SCHEMA) + }) + it('recovers the stashed builder before and after pgTable processing', () => { const col = makeEqlV3Column(v3Types.TextEq('nickname')) expect(isEqlV3Column(col)).toBe(true) diff --git a/packages/stack-drizzle/src/v3/column.ts b/packages/stack-drizzle/src/v3/column.ts index cd3a1cb67..46af025fc 100644 --- a/packages/stack-drizzle/src/v3/column.ts +++ b/packages/stack-drizzle/src/v3/column.ts @@ -109,6 +109,21 @@ export function makeEqlV3Column(builder: C) { // via the search_path (the domains live in `public`, always in-path), and it // also matches what drizzle-kit introspection reads back for a `push` diff, so // the two sides no longer disagree. + // + // Stripping the schema is only safe BECAUSE every domain lives in `public`: + // `stripDomainSchema`/`qualifyDomain` are inverses over exactly that set. If a + // domain ever lived elsewhere, `stripDomainSchema` would pass its qualified + // name through untouched and drizzle-kit would emit the invalid dotted + // identifier again — silently. Assert the invariant so that day fails loudly at + // column construction instead of shipping a broken migration. + if (!domain.startsWith(`${EQL_V3_DOMAIN_SCHEMA}.`)) { + throw new Error( + `EQL v3 domain "${domain}" is not in the "${EQL_V3_DOMAIN_SCHEMA}" schema. ` + + 'drizzle-kit cannot emit a schema-qualified custom type as valid DDL, so ' + + 'the bare domain name is emitted and resolved via search_path — which only ' + + `works when the domain lives in "${EQL_V3_DOMAIN_SCHEMA}".`, + ) + } const sqlType = stripDomainSchema(domain) // What is stored/inserted/selected is the ENCRYPTED EQL v3 jsonb envelope