@@ -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