Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/fix-drizzle-encrypted-datatype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cipherstash/stack': patch
'@cipherstash/cli': patch
---

Fix mangled `eql_v2_encrypted` type in drizzle-kit migrations.

- `@cipherstash/stack/drizzle`'s `encryptedType` now returns the bare `eql_v2_encrypted` identifier from its Drizzle `customType.dataType()` callback. Returning the schema-qualified `"public"."eql_v2_encrypted"` (0.15.0) triggered a drizzle-kit quirk that wraps the return value in double-quotes and prepends `"{typeSchema}".` in ALTER COLUMN output — producing `"undefined".""public"."eql_v2_encrypted""`, which Postgres cannot parse.
- `stash db install` / `stash wizard`'s migration rewriter now matches all four forms drizzle-kit may emit (`eql_v2_encrypted`, `"public"."eql_v2_encrypted"`, `"undefined"."eql_v2_encrypted"`, `"undefined".""public"."eql_v2_encrypted""`) and rewrites each into the safe `ADD COLUMN … DROP COLUMN … RENAME COLUMN` sequence.

Users on 0.15.0 who hit this in generated migrations should upgrade and re-run `npx drizzle-kit generate` + `stash db install` (or re-run the wizard).
30 changes: 30 additions & 0 deletions packages/cli/src/__tests__/rewrite-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,36 @@ describe('rewriteEncryptedAlterColumns', () => {
expect(updated).not.toContain('SET DATA TYPE')
})

it('rewrites the "undefined" schema form drizzle-kit emits for bare custom types', async () => {
const original =
'ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE "undefined"."eql_v2_encrypted";\n'
const filePath = path.join(tmpDir, '0005_undef.sql')
fs.writeFileSync(filePath, original)

await rewriteEncryptedAlterColumns(tmpDir)

const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";',
)
expect(updated).not.toContain('SET DATA TYPE')
})

it('rewrites the double-quoted form produced by stack 0.15.0', async () => {
const original =
'ALTER TABLE "transactions" ALTER COLUMN "description" SET DATA TYPE "undefined".""public"."eql_v2_encrypted"";\n'
const filePath = path.join(tmpDir, '0006_double.sql')
fs.writeFileSync(filePath, original)

await rewriteEncryptedAlterColumns(tmpDir)

const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
'ALTER TABLE "transactions" ADD COLUMN "description__cipherstash_tmp" "public"."eql_v2_encrypted";',
)
expect(updated).not.toContain('SET DATA TYPE')
})

it('leaves unrelated migrations untouched', async () => {
const original =
'CREATE TABLE "widgets" ("id" integer PRIMARY KEY, "name" text);\n'
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/src/commands/db/rewrite-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ import { join } from 'node:path'

/**
* Matches drizzle-kit's generated in-place type change to the encrypted
* column type. We accept both the fully-qualified
* `"public"."eql_v2_encrypted"` form (emitted after CIP-2990) and the bare
* `eql_v2_encrypted` form older schemas produced.
* column type. drizzle-kit's ALTER COLUMN path wraps the customType
* `dataType()` return value in double-quotes and prepends `"{typeSchema}".`.
* Custom types have no `typeSchema`, so we see several mangled forms
* depending on what `dataType()` returned. We match all of them:
*
* - bare `eql_v2_encrypted` → `"undefined"."eql_v2_encrypted"`
* - pre-quoted `"public"."eql_v2_encrypted"` (stack 0.15.0 regression) →
* `"undefined".""public"."eql_v2_encrypted""`
* - the plain `eql_v2_encrypted` and `"public"."eql_v2_encrypted"` forms,
* in case a future drizzle-kit release stops prepending undefined.
*
* Captures:
* - $1: table name (without quotes)
* - $2: column name (without quotes)
*/
const ALTER_COLUMN_TO_ENCRYPTED_RE =
/ALTER TABLE "([^"]+)"\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (?:"public"\."eql_v2_encrypted"|eql_v2_encrypted)[^;]*;/gi
/ALTER TABLE "([^"]+)"\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (?:"undefined"\.""public"\."eql_v2_encrypted""|"undefined"\."eql_v2_encrypted"|"public"\."eql_v2_encrypted"|eql_v2_encrypted)[^;]*;/gi

/**
* Replace in-place `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` statements
Expand Down
28 changes: 17 additions & 11 deletions packages/stack/src/drizzle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { customType } from 'drizzle-orm/pg-core'
export type { CastAs, MatchIndexOpts, TokenFilter }

// The encrypted column type is created by the EQL install script in the
// `public` schema (see packages/cli/src/installer/index.ts). Emitting the
// fully-qualified, quoted identifier here means drizzle-kit writes
// `"public"."eql_v2_encrypted"` into generated migrations instead of
// `"undefined"."eql_v2_encrypted"`, which was the symptom that drizzle-kit
// couldn't resolve against the database.
const EQL_ENCRYPTED_DATA_TYPE = '"public"."eql_v2_encrypted"'
// `public` schema (see packages/cli/src/installer/index.ts). We return the
// bare identifier here — drizzle-kit's CREATE TABLE path emits it correctly
// (it only prepends a schema when `typeSchema` is set, which is only true
// for pgEnum columns). The ALTER COLUMN path is a different story: it
// unconditionally wraps the dataType() return in double-quotes and prepends
// `"{typeSchema}".`, and since custom types have no typeSchema, the output
// becomes `"undefined"."eql_v2_encrypted"`. Returning a pre-quoted
// `"public"."eql_v2_encrypted"` here does NOT fix that — drizzle-kit just
// double-escapes the quotes, producing `"undefined".""public"."eql_v2_encrypted""`.
// Instead, the CLI's `rewriteEncryptedAlterColumns` rewrites every broken
// ALTER COLUMN form into an ADD + DROP + RENAME sequence that does work.
const EQL_ENCRYPTED_DATA_TYPE = 'eql_v2_encrypted'

/**
* Configuration for encrypted column indexes and data types
Expand Down Expand Up @@ -174,12 +180,12 @@ export function getEncryptedColumnConfig(
const columnAny = column as any

// Check if it's an encrypted column by checking sqlName or dataType.
// We accept both the fully-qualified `"public"."eql_v2_encrypted"` form
// that `encryptedType` now emits and the bare `eql_v2_encrypted` form
// that earlier versions produced, for back-compat with tables built
// against older releases.
// We accept both the bare `eql_v2_encrypted` form (current) and the
// fully-qualified `"public"."eql_v2_encrypted"` form that @cipherstash/stack
// 0.15.0 briefly emitted, for back-compat with tables built against that
// release.
const isEncryptedTypeString = (value: unknown): boolean =>
value === EQL_ENCRYPTED_DATA_TYPE || value === 'eql_v2_encrypted'
value === EQL_ENCRYPTED_DATA_TYPE || value === '"public"."eql_v2_encrypted"'

const isEncrypted =
isEncryptedTypeString(columnAny.sqlName) ||
Expand Down
Loading