Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/drizzle-kit-eql-v3-ddl.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 6 additions & 4 deletions packages/stack-drizzle/__tests__/v3/bigint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
50 changes: 47 additions & 3 deletions packages/stack-drizzle/__tests__/v3/column.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { getSQLType(): string }>)[
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<typeof makeEqlV3Column>[0]
expect(() => makeEqlV3Column(rogue)).toThrow(EQL_V3_DOMAIN_SCHEMA)
})

it('recovers the stashed builder before and after pgTable processing', () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -132,7 +174,9 @@ describe('makeEqlV3Column', () => {
const pgColumn = (table as Record<string, { getSQLType(): string }>)[
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)
})

Expand Down
52 changes: 49 additions & 3 deletions packages/stack-drizzle/src/v3/column.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripDomainSchema } from '@cipherstash/stack/adapter-kit'
import {
type AnyEncryptedV3Column,
types as v3Types,
Expand All @@ -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
Expand Down Expand Up @@ -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<C extends AnyEncryptedV3Column>(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)
Expand Down
2 changes: 1 addition & 1 deletion skills/stash-drizzle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading