Skip to content

Commit b8cb599

Browse files
authored
fix(stack-drizzle): emit unqualified eql_v3 domain so drizzle-kit DDL is valid (rc.2 M5) (#688)
* fix(stack-drizzle): emit unqualified eql_v3 domain so drizzle-kit DDL is valid (rc.2 M5) 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 * fix(stack-drizzle): assert eql_v3 domains live in public before stripping schema `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
1 parent 457705c commit b8cb599

5 files changed

Lines changed: 127 additions & 11 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@cipherstash/stack-drizzle': patch
3+
'stash': patch
4+
---
5+
6+
Fix invalid DDL from `drizzle-kit generate`/`push` for EQL v3 encrypted columns.
7+
A v3 column declared its SQL type as the schema-qualified domain
8+
(`public.eql_v3_text_search`), but drizzle-kit wraps a custom type's whole name
9+
in a single pair of double quotes — emitting `"public.eql_v3_text_search"`, which
10+
Postgres reads as one dotted identifier and rejects with `type
11+
"public.eql_v3_text_search" does not exist`. Generated migrations had to be
12+
hand-repaired.
13+
14+
The v3 column now emits the **unqualified** domain (`eql_v3_text_search`), which
15+
drizzle-kit renders as the valid `"eql_v3_text_search"` and which resolves via the
16+
search path (the domains live in `public`). This matches how the v2
17+
`encryptedType` surface already declares its type, and how drizzle-kit reads the
18+
type back during a `push` introspection diff, so the two sides no longer disagree.
19+
Builder recovery still yields the canonical `public.eql_v3_*` identity, so
20+
operators and schema extraction are unchanged.
21+
22+
The bundled `stash-drizzle` skill is updated to describe the unqualified generated
23+
type and the search-path requirement (hence the `stash` bump — the skill ships in
24+
its tarball).

packages/stack-drizzle/__tests__/v3/bigint.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ describe('v3 drizzle bigint columns', () => {
5454
)
5555
})
5656

57-
it('emits the concrete public.eql_v3_bigint* SQL type through pgTable', () => {
58-
expect(accounts.balance.getSQLType()).toBe('public.eql_v3_bigint_ord')
59-
expect(accounts.ledgerId.getSQLType()).toBe('public.eql_v3_bigint_eq')
60-
expect(accounts.archived.getSQLType()).toBe('public.eql_v3_bigint')
57+
it('emits the BARE eql_v3_bigint* SQL type through pgTable (drizzle-kit safe)', () => {
58+
// Unqualified: a `public.`-prefixed name would be quote-wrapped whole by
59+
// drizzle-kit into an invalid identifier. See makeEqlV3Column.
60+
expect(accounts.balance.getSQLType()).toBe('eql_v3_bigint_ord')
61+
expect(accounts.ledgerId.getSQLType()).toBe('eql_v3_bigint_eq')
62+
expect(accounts.archived.getSQLType()).toBe('eql_v3_bigint')
6163
})
6264

6365
it('encrypts a native bigint operand for eq without JSON-stringifying it', async () => {

packages/stack-drizzle/__tests__/v3/column.test.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,41 @@ import {
1515
} from '../../src/v3/column'
1616

1717
describe('makeEqlV3Column', () => {
18-
it('sets dataType() to the concrete eql_v3 domain', () => {
18+
it('sets dataType() to the BARE eql_v3 domain (no schema qualifier)', () => {
1919
const col = makeEqlV3Column(v3Types.IntegerOrd('age'))
2020
const table = pgTable('users', { age: col })
2121

22-
expect(table.age.getSQLType()).toBe('public.eql_v3_integer_ord')
22+
// Bare, not `public.eql_v3_integer_ord`: drizzle-kit wraps the whole
23+
// dataType() string in one pair of quotes, so a qualified name would emit
24+
// the invalid identifier `"public.eql_v3_integer_ord"`. See makeEqlV3Column.
25+
expect(table.age.getSQLType()).toBe('eql_v3_integer_ord')
26+
})
27+
28+
it('never leaks a schema-qualified (dotted) type into the emitted DDL', () => {
29+
// The regression guard for the drizzle-kit invalid-DDL bug: getSQLType() is
30+
// what drizzle-kit quote-wraps into the CREATE/ALTER, so a dot here is an
31+
// un-runnable migration. Assert it for every concrete domain.
32+
for (const [eqlType] of typedEntries(V3_MATRIX)) {
33+
const column = makeEqlV3Column(V3_MATRIX[eqlType].builder(slug(eqlType)))
34+
const table = pgTable('t', { [slug(eqlType)]: column } as never)
35+
const sqlType = (table as Record<string, { getSQLType(): string }>)[
36+
slug(eqlType)
37+
].getSQLType()
38+
expect(sqlType).not.toContain('.')
39+
expect(sqlType).toBe(slug(eqlType))
40+
}
41+
})
42+
43+
it('throws for a domain outside the public schema instead of emitting bad DDL', () => {
44+
// stripDomainSchema/qualifyDomain are inverses only over public-schema
45+
// domains. A non-public qualified name would pass stripDomainSchema through
46+
// untouched and drizzle-kit would emit the invalid dotted identifier again —
47+
// silently. The guard turns that into a loud construction-time failure.
48+
const rogue = {
49+
getEqlType: () => 'other_schema.eql_v3_text_eq',
50+
getName: () => 'nickname',
51+
} as unknown as Parameters<typeof makeEqlV3Column>[0]
52+
expect(() => makeEqlV3Column(rogue)).toThrow(EQL_V3_DOMAIN_SCHEMA)
2353
})
2454

2555
it('recovers the stashed builder before and after pgTable processing', () => {
@@ -98,6 +128,18 @@ describe('makeEqlV3Column', () => {
98128
expect(builder?.getEqlType()).toBe('public.eql_v3_text_eq')
99129
})
100130

131+
it('recovers a v3 builder from a BARE getSQLType (a real live column)', () => {
132+
// A processed column reports its type unqualified now, so recovery must
133+
// re-qualify a bare name to the canonical `public.eql_v3_*` identity.
134+
const builder = getEqlV3Column('nickname', {
135+
getSQLType: () => 'eql_v3_text_eq',
136+
})
137+
138+
expect(isEqlV3Column({ getSQLType: () => 'eql_v3_text_eq' })).toBe(true)
139+
expect(builder?.getName()).toBe('nickname')
140+
expect(builder?.getEqlType()).toBe('public.eql_v3_text_eq')
141+
})
142+
101143
it('recognises v3 columns by dataType() when getSQLType is absent', () => {
102144
const column = { dataType: () => 'public.eql_v3_text_eq' }
103145
const builder = getEqlV3Column('nickname', column)
@@ -132,7 +174,9 @@ describe('makeEqlV3Column', () => {
132174
const pgColumn = (table as Record<string, { getSQLType(): string }>)[
133175
columnName
134176
]
135-
expect(pgColumn?.getSQLType()).toBe(eqlType)
177+
// getSQLType() is the bare (unqualified) domain — what drizzle-kit emits —
178+
// while recovery still yields the qualified builder identity.
179+
expect(pgColumn?.getSQLType()).toBe(slug(eqlType))
136180
expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType)
137181
})
138182

packages/stack-drizzle/src/v3/column.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { stripDomainSchema } from '@cipherstash/stack/adapter-kit'
12
import {
23
type AnyEncryptedV3Column,
34
types as v3Types,
@@ -6,6 +7,22 @@ import type { Encrypted } from '@cipherstash/stack/types'
67
import { customType } from 'drizzle-orm/pg-core'
78
import { v3FromDriver, v3ToDriver } from './codec.js'
89

10+
/** The schema the concrete `eql_v3_*` domains are created in. */
11+
const EQL_V3_DOMAIN_SCHEMA = 'public'
12+
13+
/**
14+
* Re-attach the `public.` schema to a bare domain name, leaving an
15+
* already-qualified name untouched. Recovery keys (`buildersByDomain`,
16+
* {@link EQL_V3_DOMAINS}) are the qualified `public.eql_v3_*` identities, but a
17+
* live column now reports its type UNqualified (see {@link makeEqlV3Column}), so
18+
* the value read back off a column has to be re-qualified before it can match.
19+
* A name that already carries a schema (a test double, an introspected snapshot)
20+
* passes through unchanged.
21+
*/
22+
function qualifyDomain(sqlType: string): string {
23+
return sqlType.includes('.') ? sqlType : `${EQL_V3_DOMAIN_SCHEMA}.${sqlType}`
24+
}
25+
926
const buildersByDomain: ReadonlyMap<
1027
string,
1128
(name: string) => AnyEncryptedV3Column
@@ -70,23 +87,52 @@ function getSqlType(column: unknown): string | undefined {
7087
typeof columnAny.getSQLType === 'function'
7188
? columnAny.getSQLType()
7289
: undefined
73-
if (typeof sqlType === 'string') return sqlType
90+
// Re-qualify: a live column reports its type bare (see `makeEqlV3Column`), but
91+
// the recovery keys are the qualified `public.eql_v3_*` identities.
92+
if (typeof sqlType === 'string') return qualifyDomain(sqlType)
7493
const dt = columnAny.dataType
7594
const domain = typeof dt === 'function' ? dt() : (columnAny.sqlName ?? dt)
76-
return typeof domain === 'string' ? domain : undefined
95+
return typeof domain === 'string' ? qualifyDomain(domain) : undefined
7796
}
7897

7998
export function makeEqlV3Column<C extends AnyEncryptedV3Column>(builder: C) {
8099
const domain = builder.getEqlType()
81100
const name = builder.getName()
82101

102+
// The SQL type drizzle emits is the domain name WITHOUT its `public.` schema.
103+
// drizzle-kit renders a customType's `dataType()` by wrapping the whole string
104+
// in one pair of double quotes, so a qualified `public.eql_v3_text_search`
105+
// becomes the invalid identifier `"public.eql_v3_text_search"` (Postgres reads
106+
// it as a single type name containing a dot, not schema.type) — the DDL then
107+
// fails with `type "public.eql_v3_text_search" does not exist`. Emitting the
108+
// bare `eql_v3_text_search` yields a valid `"eql_v3_text_search"` that resolves
109+
// via the search_path (the domains live in `public`, always in-path), and it
110+
// also matches what drizzle-kit introspection reads back for a `push` diff, so
111+
// the two sides no longer disagree.
112+
//
113+
// Stripping the schema is only safe BECAUSE every domain lives in `public`:
114+
// `stripDomainSchema`/`qualifyDomain` are inverses over exactly that set. If a
115+
// domain ever lived elsewhere, `stripDomainSchema` would pass its qualified
116+
// name through untouched and drizzle-kit would emit the invalid dotted
117+
// identifier again — silently. Assert the invariant so that day fails loudly at
118+
// column construction instead of shipping a broken migration.
119+
if (!domain.startsWith(`${EQL_V3_DOMAIN_SCHEMA}.`)) {
120+
throw new Error(
121+
`EQL v3 domain "${domain}" is not in the "${EQL_V3_DOMAIN_SCHEMA}" schema. ` +
122+
'drizzle-kit cannot emit a schema-qualified custom type as valid DDL, so ' +
123+
'the bare domain name is emitted and resolved via search_path — which only ' +
124+
`works when the domain lives in "${EQL_V3_DOMAIN_SCHEMA}".`,
125+
)
126+
}
127+
const sqlType = stripDomainSchema(domain)
128+
83129
// What is stored/inserted/selected is the ENCRYPTED EQL v3 jsonb envelope
84130
// (produced by `client.encrypt` / `bulkEncryptModels`), NOT the column's
85131
// plaintext. So `data` is the envelope type — an insert takes an already-
86132
// encrypted `Encrypted`, and a select yields one, ready for `decryptModel`.
87133
const column = customType<{ data: Encrypted; driverData: string | null }>({
88134
dataType() {
89-
return domain
135+
return sqlType
90136
},
91137
toDriver(value: Encrypted): string | null {
92138
return v3ToDriver(value)

skills/stash-drizzle/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ CREATE TABLE users (
6666
);
6767
```
6868

69-
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.
69+
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.
7070

7171
## Schema Definition
7272

0 commit comments

Comments
 (0)