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..9ca47384d 100644 --- a/packages/stack-drizzle/__tests__/v3/column.test.ts +++ b/packages/stack-drizzle/__tests__/v3/column.test.ts @@ -15,11 +15,41 @@ 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('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', () => { @@ -98,6 +128,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 +174,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..46af025fc 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,52 @@ 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. + // + // 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 // (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