diff --git a/.changeset/fix-drizzle-encrypted-datatype.md b/.changeset/fix-drizzle-encrypted-datatype.md new file mode 100644 index 00000000..eb7548b4 --- /dev/null +++ b/.changeset/fix-drizzle-encrypted-datatype.md @@ -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). diff --git a/packages/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts index 4752d358..ad5d21b5 100644 --- a/packages/cli/src/__tests__/rewrite-migrations.test.ts +++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts @@ -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' diff --git a/packages/cli/src/commands/db/rewrite-migrations.ts b/packages/cli/src/commands/db/rewrite-migrations.ts index 55fa7611..0b6cadfe 100644 --- a/packages/cli/src/commands/db/rewrite-migrations.ts +++ b/packages/cli/src/commands/db/rewrite-migrations.ts @@ -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 diff --git a/packages/stack/src/drizzle/index.ts b/packages/stack/src/drizzle/index.ts index bc4e1eda..d88027f2 100644 --- a/packages/stack/src/drizzle/index.ts +++ b/packages/stack/src/drizzle/index.ts @@ -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 @@ -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) ||