Skip to content

Commit 02e145d

Browse files
authored
Merge pull request #728 from cipherstash/fix/drizzle-v3-alter-column
fix(cli): rewrite ALTER COLUMN to EQL v3 domains, not just eql_v2_encrypted
2 parents 98a9c09 + 8dcbf05 commit 02e145d

9 files changed

Lines changed: 725 additions & 48 deletions

File tree

.changeset/wild-donkeys-shave.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
Fix invalid DDL when a Drizzle column changes to an EQL v3 domain.
6+
7+
`drizzle-kit generate` emits an in-place `ALTER TABLE … ALTER COLUMN … SET DATA TYPE`
8+
when a plaintext column is changed to an encrypted one, which Postgres rejects — there
9+
is no cast from `text`/`numeric` to an EQL type, and on drizzle-kit 0.31.0+ the emitted
10+
type name is additionally mangled to `"undefined"."eql_v3_<name>"`. The migration
11+
rewriter only recognised the EQL v2 type, so a v3 user was left with an un-runnable
12+
migration and nothing to repair it.
13+
14+
The rewriter now matches the whole `eql_v3_*` domain family alongside `eql_v2_encrypted`,
15+
across every mangled form observed from drizzle-kit 0.24 through 0.31, and emits the
16+
matched domain in the replacement instead of a hardcoded v2 type. `stash eql migration
17+
--drizzle` — the EQL v3 migration-first path — now runs the same sweep that `eql install
18+
--drizzle` has always run, so the repair actually reaches v3 projects.
19+
20+
The rewrite's guidance comment now also warns that it drops the plaintext column in the
21+
same migration, and points at the staged `stash encrypt` path (add → backfill → cutover →
22+
drop) for populated production tables.

packages/cli/src/__tests__/rewrite-migrations.test.ts

Lines changed: 336 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('rewriteEncryptedAlterColumns', () => {
2020
const filePath = path.join(tmpDir, '0002_alter.sql')
2121
fs.writeFileSync(filePath, original)
2222

23-
const rewritten = await rewriteEncryptedAlterColumns(tmpDir)
23+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
2424

2525
expect(rewritten).toEqual([filePath])
2626
const updated = fs.readFileSync(filePath, 'utf-8')
@@ -51,6 +51,29 @@ describe('rewriteEncryptedAlterColumns', () => {
5151
expect(updated).not.toContain('SET DATA TYPE')
5252
})
5353

54+
it('rewrites a schema-qualified table produced by pgSchema()', async () => {
55+
// drizzle-kit emits `"app"."users"` for a table declared in a pgSchema();
56+
// the old `\s+` between the table and ALTER COLUMN could never cross the `.`.
57+
const original =
58+
'ALTER TABLE "app"."users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n'
59+
const filePath = path.join(tmpDir, '0014_qualified.sql')
60+
fs.writeFileSync(filePath, original)
61+
62+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
63+
64+
expect(rewritten).toEqual([filePath])
65+
const updated = fs.readFileSync(filePath, 'utf-8')
66+
// Every emitted statement keeps the schema qualifier.
67+
expect(updated).toContain(
68+
'ALTER TABLE "app"."users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";',
69+
)
70+
expect(updated).toContain('ALTER TABLE "app"."users" DROP COLUMN "email";')
71+
expect(updated).toContain(
72+
'ALTER TABLE "app"."users" RENAME COLUMN "email__cipherstash_tmp" TO "email";',
73+
)
74+
expect(updated).not.toContain('SET DATA TYPE')
75+
})
76+
5477
it('rewrites the "undefined" schema form drizzle-kit emits for bare custom types', async () => {
5578
const original =
5679
'ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE "undefined"."eql_v2_encrypted";\n'
@@ -87,7 +110,7 @@ describe('rewriteEncryptedAlterColumns', () => {
87110
const filePath = path.join(tmpDir, '0001_init.sql')
88111
fs.writeFileSync(filePath, original)
89112

90-
const rewritten = await rewriteEncryptedAlterColumns(tmpDir)
113+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
91114

92115
expect(rewritten).toEqual([])
93116
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
@@ -102,7 +125,7 @@ describe('rewriteEncryptedAlterColumns', () => {
102125
'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v2_encrypted;',
103126
)
104127

105-
const rewritten = await rewriteEncryptedAlterColumns(tmpDir, {
128+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir, {
106129
skip: install,
107130
})
108131
expect(rewritten).toEqual([alter])
@@ -111,8 +134,317 @@ describe('rewriteEncryptedAlterColumns', () => {
111134

112135
it('returns an empty list when the directory does not exist', async () => {
113136
const missing = path.join(tmpDir, 'does-not-exist')
114-
const rewritten = await rewriteEncryptedAlterColumns(missing)
137+
const { rewritten } = await rewriteEncryptedAlterColumns(missing)
138+
expect(rewritten).toEqual([])
139+
})
140+
141+
// Every concrete `eql_v3_*` domain shipped by `@cipherstash/stack/eql/v3`
142+
// (see `packages/stack/src/eql/v3/columns.ts`). Eight scalar bases carry the
143+
// four storage/eq/ord flavours; text adds `_match`/`_search`; boolean and json
144+
// stand alone.
145+
const V3_SCALAR_BASES = [
146+
'integer',
147+
'smallint',
148+
'bigint',
149+
'numeric',
150+
'real',
151+
'double',
152+
'date',
153+
'timestamp',
154+
]
155+
const V3_DOMAINS = [
156+
...V3_SCALAR_BASES.flatMap((base) =>
157+
['', '_eq', '_ord', '_ord_ore'].map(
158+
(flavour) => `eql_v3_${base}${flavour}`,
159+
),
160+
),
161+
'eql_v3_text',
162+
'eql_v3_text_eq',
163+
'eql_v3_text_match',
164+
'eql_v3_text_ord',
165+
'eql_v3_text_ord_ore',
166+
'eql_v3_text_search',
167+
'eql_v3_boolean',
168+
'eql_v3_json',
169+
]
170+
171+
it.each(V3_DOMAINS)('rewrites an ALTER COLUMN to %s', async (domain) => {
172+
const filePath = path.join(tmpDir, '0007_v3.sql')
173+
fs.writeFileSync(
174+
filePath,
175+
`ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."${domain}";\n`,
176+
)
177+
178+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
179+
180+
expect(rewritten).toEqual([filePath])
181+
const updated = fs.readFileSync(filePath, 'utf-8')
182+
expect(updated).toContain(
183+
`ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."${domain}";`,
184+
)
185+
expect(updated).toContain('ALTER TABLE "users" DROP COLUMN "email";')
186+
expect(updated).toContain(
187+
'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";',
188+
)
189+
expect(updated).not.toContain('SET DATA TYPE')
190+
})
191+
192+
// DOMAIN_RE is derived from ENCRYPTED_DOMAIN, so drift between the two can't
193+
// silently leave a domain unrewritten. Prove every domain the alternation
194+
// recognises is actually extracted into the emitted ADD COLUMN.
195+
it.each([
196+
...V3_DOMAINS,
197+
'eql_v2_encrypted',
198+
])('extracts the bare domain %s from a mangled ALTER', async (domain) => {
199+
const filePath = path.join(tmpDir, '0015_drift.sql')
200+
fs.writeFileSync(
201+
filePath,
202+
`ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`,
203+
)
204+
205+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
206+
207+
expect(rewritten).toEqual([filePath])
208+
const updated = fs.readFileSync(filePath, 'utf-8')
209+
expect(updated).toContain(
210+
`ALTER TABLE "t" ADD COLUMN "c__cipherstash_tmp" "public"."${domain}";`,
211+
)
212+
expect(updated).not.toContain('SET DATA TYPE')
213+
})
214+
215+
// The mangled forms are the cross product of what `dataType()` returns and
216+
// which drizzle-kit era renders it. Verified against drizzle-kit 0.24.2,
217+
// 0.28.1, 0.30.6, 0.31.0, 0.31.1 and 0.31.10 via `drizzle-kit/api`'s
218+
// `generateMigration`: the `"undefined".` prefix appears in **0.31.0 and
219+
// later** — 0.30.6 and earlier emit the plain form. (Issue #693 and PR #688
220+
// both describe it as an *older*-drizzle-kit artifact; that is backwards.)
221+
const MANGLED_FORMS: Array<[label: string, emitted: string]> = [
222+
// dataType() → bare `eql_v3_text_search` (what stack emits post-#688)
223+
['plain, drizzle-kit <=0.30.6', 'eql_v3_text_search'],
224+
[
225+
'"undefined"-prefixed, drizzle-kit >=0.31.0',
226+
'"undefined"."eql_v3_text_search"',
227+
],
228+
// dataType() → qualified `public.eql_v3_text_search` (stack pre-#688)
229+
['dotted, drizzle-kit <=0.30.6', 'public.eql_v3_text_search'],
230+
[
231+
'dotted inside "undefined", drizzle-kit >=0.31.0',
232+
'"undefined"."public.eql_v3_text_search"',
233+
],
234+
// dataType() → pre-quoted `"public"."eql_v3_text_search"`
235+
['pre-quoted, drizzle-kit <=0.30.6', '"public"."eql_v3_text_search"'],
236+
[
237+
'pre-quoted inside "undefined", drizzle-kit >=0.31.0',
238+
'"undefined".""public"."eql_v3_text_search""',
239+
],
240+
// Not observed from any released drizzle-kit, but the CREATE TABLE path
241+
// renders this shape — guard it in case ALTER converges on it.
242+
['bare-quoted (speculative)', '"eql_v3_text_search"'],
243+
]
244+
245+
it.each(MANGLED_FORMS)('rewrites the v3 %s form', async (_label, emitted) => {
246+
const filePath = path.join(tmpDir, '0008_form.sql')
247+
fs.writeFileSync(
248+
filePath,
249+
`ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`,
250+
)
251+
252+
await rewriteEncryptedAlterColumns(tmpDir)
253+
254+
const updated = fs.readFileSync(filePath, 'utf-8')
255+
expect(updated).toContain(
256+
'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";',
257+
)
258+
expect(updated).not.toContain('SET DATA TYPE')
259+
})
260+
261+
it.each([
262+
['dotted, drizzle-kit <=0.30.6', 'public.eql_v2_encrypted'],
263+
[
264+
'dotted inside "undefined", drizzle-kit >=0.31.0',
265+
'"undefined"."public.eql_v2_encrypted"',
266+
],
267+
])('rewrites the previously unmatched v2 %s form', async (_label, emitted) => {
268+
const filePath = path.join(tmpDir, '0009_v2form.sql')
269+
fs.writeFileSync(
270+
filePath,
271+
`ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`,
272+
)
273+
274+
await rewriteEncryptedAlterColumns(tmpDir)
275+
276+
const updated = fs.readFileSync(filePath, 'utf-8')
277+
expect(updated).toContain(
278+
'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v2_encrypted";',
279+
)
280+
expect(updated).not.toContain('SET DATA TYPE')
281+
})
282+
283+
it('names the target domain in the guidance comment', async () => {
284+
const filePath = path.join(tmpDir, '0010_comment.sql')
285+
fs.writeFileSync(
286+
filePath,
287+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_integer_ord";\n',
288+
)
289+
290+
await rewriteEncryptedAlterColumns(tmpDir)
291+
292+
const updated = fs.readFileSync(filePath, 'utf-8')
293+
expect(updated).toContain('-- eql_v3_integer_ord.')
294+
expect(updated).not.toContain('eql_v2_encrypted')
295+
})
296+
297+
it('notes that constraints/defaults/indexes are not carried over', async () => {
298+
const filePath = path.join(tmpDir, '0016_constraints.sql')
299+
fs.writeFileSync(
300+
filePath,
301+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n',
302+
)
303+
304+
await rewriteEncryptedAlterColumns(tmpDir)
305+
306+
const updated = fs.readFileSync(filePath, 'utf-8')
307+
expect(updated).toContain('constraints, defaults, and indexes')
308+
})
309+
310+
it('does not terminate the commented UPDATE placeholder with a semicolon', async () => {
311+
// A runner that naively splits on `;` must not cut mid-comment.
312+
const filePath = path.join(tmpDir, '0017_semicolon.sql')
313+
fs.writeFileSync(
314+
filePath,
315+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n',
316+
)
317+
318+
await rewriteEncryptedAlterColumns(tmpDir)
319+
320+
const updated = fs.readFileSync(filePath, 'utf-8')
321+
const updateLine = updated
322+
.split('\n')
323+
.find(
324+
(line) => line.includes('UPDATE') && line.includes('encrypted value'),
325+
)
326+
expect(updateLine).toBeDefined()
327+
expect(updateLine?.trimEnd().endsWith(';')).toBe(false)
328+
})
329+
330+
it('separates ADD/DROP/RENAME with --> statement-breakpoint, one exec stmt per chunk', async () => {
331+
const filePath = path.join(tmpDir, '0018_breakpoint.sql')
332+
fs.writeFileSync(
333+
filePath,
334+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n',
335+
)
336+
337+
await rewriteEncryptedAlterColumns(tmpDir)
338+
339+
const updated = fs.readFileSync(filePath, 'utf-8').trimEnd()
340+
const chunks = updated.split('--> statement-breakpoint')
341+
// Three executable statements: ADD, DROP, RENAME — one per chunk.
342+
expect(chunks).toHaveLength(3)
343+
for (const chunk of chunks) {
344+
const execLines = chunk
345+
.split('\n')
346+
.map((line) => line.trim())
347+
.filter((line) => line.length > 0 && !line.startsWith('--'))
348+
expect(execLines).toHaveLength(1)
349+
}
350+
expect(chunks[0]).toContain('ADD COLUMN')
351+
expect(chunks[1]).toContain('DROP COLUMN')
352+
expect(chunks[2]).toContain('RENAME COLUMN')
353+
})
354+
355+
it('rewrites each statement to its own domain when v2 and v3 are mixed', async () => {
356+
const filePath = path.join(tmpDir, '0011_mixed.sql')
357+
fs.writeFileSync(
358+
filePath,
359+
[
360+
'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;',
361+
'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE "undefined"."eql_v3_json";',
362+
].join('\n'),
363+
)
364+
365+
await rewriteEncryptedAlterColumns(tmpDir)
366+
367+
const updated = fs.readFileSync(filePath, 'utf-8')
368+
expect(updated).toContain(
369+
'ALTER TABLE "a" ADD COLUMN "x__cipherstash_tmp" "public"."eql_v2_encrypted";',
370+
)
371+
expect(updated).toContain(
372+
'ALTER TABLE "a" ADD COLUMN "y__cipherstash_tmp" "public"."eql_v3_json";',
373+
)
374+
})
375+
376+
it.each([
377+
['a plaintext type', 'text'],
378+
['jsonb', 'jsonb'],
379+
['a lookalike from another EQL major', 'eql_v4_text_search'],
380+
['a lookalike prefix', 'not_eql_v3_text_search'],
381+
])('leaves an ALTER COLUMN to %s untouched', async (_label, type) => {
382+
const original = `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${type};\n`
383+
const filePath = path.join(tmpDir, '0012_other.sql')
384+
fs.writeFileSync(filePath, original)
385+
386+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
387+
388+
expect(rewritten).toEqual([])
389+
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
390+
})
391+
392+
it('leaves a hand-authored SET DATA TYPE ... USING conversion untouched', async () => {
393+
// A user who writes their own cast expression has a runnable statement we
394+
// must not clobber — the tail is `\s*;`, not `[^;]*;`, precisely so the
395+
// USING clause keeps this out of the match.
396+
const original =
397+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING encrypt(email);\n'
398+
const filePath = path.join(tmpDir, '0013_using.sql')
399+
fs.writeFileSync(filePath, original)
400+
401+
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
402+
115403
expect(rewritten).toEqual([])
404+
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
405+
})
406+
407+
it('reports a near-miss SET DATA TYPE as skipped and leaves it on disk', async () => {
408+
// A hand-authored cast the strict regex won't rewrite (its USING tail keeps
409+
// it out) — but it IS an ALTER-to-encrypted, so silently passing it over
410+
// would ship broken SQL. The broad secondary scan must surface it.
411+
const original =
412+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n'
413+
const filePath = path.join(tmpDir, '0019_nearmiss.sql')
414+
fs.writeFileSync(filePath, original)
415+
416+
const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
417+
418+
expect(rewritten).toEqual([])
419+
expect(skipped).toHaveLength(1)
420+
expect(skipped[0].file).toBe(filePath)
421+
expect(skipped[0].statement).toContain('SET DATA TYPE')
422+
expect(skipped[0].statement).toContain('eql_v3_text_search')
423+
// Left untouched on disk — we flag, we don't guess.
424+
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
425+
})
426+
427+
it('reports no skipped statements for a clean file', async () => {
428+
const filePath = path.join(tmpDir, '0020_clean.sql')
429+
fs.writeFileSync(filePath, 'CREATE TABLE "t" ("id" integer);\n')
430+
431+
const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
432+
433+
expect(rewritten).toEqual([])
434+
expect(skipped).toEqual([])
435+
})
436+
437+
it('reports no skipped statements when the strict rewrite fully handled the file', async () => {
438+
const filePath = path.join(tmpDir, '0021_handled.sql')
439+
fs.writeFileSync(
440+
filePath,
441+
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
442+
)
443+
444+
const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
445+
446+
expect(rewritten).toEqual([filePath])
447+
expect(skipped).toEqual([])
116448
})
117449

118450
it('handles multiple ALTER statements in one file', async () => {

0 commit comments

Comments
 (0)