1- import { spawnSync } from 'node:child_process'
1+ import { spawn , spawnSync } from 'node:child_process'
22import { mkdtemp , rm , writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
@@ -163,10 +163,15 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
163163 it ( 'resumes copy, includes late writes, replaces all widths, and only then purges Search chunks' , async ( ) => {
164164 expect ( await getSearchRetirementStatus ( sql ) ) . toBeNull ( )
165165 await initializeSearchRetirement ( sql )
166- await nextPage ( )
167- await nextPage ( )
166+ for ( let page = 0 ; page < 10 ; page ++ ) {
167+ if ( ( await nextPage ( ) ) . copied > 0 ) break
168+ }
169+ expect ( ( await getSearchRetirementStatus ( sql ) ) ?. copied ) . toBeGreaterThan ( 0 )
170+ expect (
171+ await sql `SELECT id FROM embedding_search_retirement_shadow WHERE id = 'full-1024-1'`
172+ ) . toHaveLength ( 1 )
168173 await writer `UPDATE embedding SET enabled = false WHERE id = 'full-1024-1'`
169- await writer `DELETE FROM embedding WHERE id = 'full-384-1 '`
174+ await writer `DELETE FROM embedding WHERE id = 'full-1024-3 '`
170175 await writer `INSERT INTO embedding (id, knowledge_base_id, document_id, chunk_index, chunk_hash,
171176 content, content_length, token_count, start_offset, end_offset, embedding)
172177 VALUES ('000-late', 'prefix', 'prefix-doc', 5, 'late', 'Late synthetic content', 22, 4, 0, 22,
@@ -192,7 +197,10 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
192197 await sql `SELECT id FROM embedding_search WHERE knowledge_base_id = 'search'`
193198 ) . toHaveLength ( 0 )
194199 expect ( await sql `SELECT id FROM embedding_search WHERE id = 'prefix-4'` ) . toHaveLength ( 1 )
195- expect ( await sql `SELECT id FROM embedding_search WHERE id = 'full-384-1'` ) . toHaveLength ( 0 )
200+ expect ( await sql `SELECT id FROM embedding_search WHERE id = 'full-1024-3'` ) . toHaveLength ( 0 )
201+ expect ( await sql `SELECT enabled FROM embedding_search WHERE id = 'full-1024-1'` ) . toEqual ( [
202+ { enabled : false } ,
203+ ] )
196204 expect (
197205 (
198206 await sql `SELECT vector_dims(vector_512) AS width FROM embedding_search WHERE id = '000-late'`
@@ -568,7 +576,124 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
568576 expect ( ( await sql `SELECT count(*)::int AS n FROM embedding` ) [ 0 ] . n ) . toBe ( 28 )
569577 } )
570578
571- it ( 'the CLI refuses missing or unhealthy telemetry before initializing and status stays read-only' , async ( ) => {
579+ it ( 'refuses db:push while retirement holds its maintenance fence before creating a receipt' , async ( ) => {
580+ await writer `SELECT pg_advisory_lock(hashtextextended('sim:search-retirement-maintenance', 0))`
581+ try {
582+ const fixtureUrl = new URL ( databaseUrl )
583+ fixtureUrl . pathname = `/${ database } `
584+ const result = spawnSync (
585+ 'bun' ,
586+ [ '--no-env-file' , './scripts/push.ts' , '--retirement-test-invalid-option' ] ,
587+ {
588+ cwd : fileURLToPath ( new URL ( '..' , import . meta. url ) ) ,
589+ env : { ...process . env , NODE_ENV : 'development' , DATABASE_URL : fixtureUrl . toString ( ) } ,
590+ encoding : 'utf8' ,
591+ timeout : 10_000 ,
592+ maxBuffer : 64 * 1_024 ,
593+ }
594+ )
595+ expect ( result . status ) . toBe ( 1 )
596+ expect ( `${ result . stdout } ${ result . stderr } ` ) . toContain (
597+ 'Another migration or retirement operation'
598+ )
599+ expect ( `${ result . stdout } ${ result . stderr } ` ) . not . toContain ( 'Unrecognized options' )
600+ expect ( await getSearchRetirementStatus ( sql ) ) . toBeNull ( )
601+ } finally {
602+ await writer `SELECT pg_advisory_unlock_all()`
603+ }
604+ } )
605+
606+ it ( 'fences retirement during db:push and stops database preparation when its fence connection closes' , async ( ) => {
607+ await sql `ALTER TABLE workspace_files ADD COLUMN size bigint`
608+ await writer `BEGIN`
609+ await writer `LOCK TABLE workspace_files IN ACCESS EXCLUSIVE MODE`
610+ const fixtureUrl = new URL ( databaseUrl )
611+ fixtureUrl . pathname = `/${ database } `
612+ const child = spawn (
613+ 'bun' ,
614+ [ '--no-env-file' , './scripts/push.ts' , '--force' , '--retirement-test-invalid-option' ] ,
615+ {
616+ cwd : fileURLToPath ( new URL ( '..' , import . meta. url ) ) ,
617+ env : { ...process . env , NODE_ENV : 'development' , DATABASE_URL : fixtureUrl . toString ( ) } ,
618+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
619+ timeout : 10_000 ,
620+ }
621+ )
622+ let output = ''
623+ child . stdout . on ( 'data' , ( chunk : Buffer ) => {
624+ output += chunk . toString ( )
625+ } )
626+ child . stderr . on ( 'data' , ( chunk : Buffer ) => {
627+ output += chunk . toString ( )
628+ } )
629+ const exited = new Promise < number | null > ( ( resolve , reject ) => {
630+ child . once ( 'error' , reject )
631+ child . once ( 'close' , resolve )
632+ } )
633+ try {
634+ await vi . waitFor (
635+ async ( ) => {
636+ expect (
637+ await sql `SELECT pid FROM pg_stat_activity
638+ WHERE datname = current_database() AND wait_event_type = 'Lock'
639+ AND query = 'LOCK TABLE public.workspace_files IN ACCESS EXCLUSIVE MODE'`
640+ ) . toHaveLength ( 1 )
641+ } ,
642+ { timeout : 5_000 }
643+ )
644+ await expect ( initializeSearchRetirement ( sql ) ) . rejects . toThrow (
645+ / A n o t h e r m i g r a t i o n o r r e t i r e m e n t o p e r a t i o n /
646+ )
647+ const [ guard ] = await sql `SELECT pid, backend_xmin FROM pg_stat_activity
648+ WHERE datname = current_database() AND application_name = 'sim-db-push'`
649+ expect ( guard . backend_xmin ) . toBeNull ( )
650+ await sql `SELECT pg_terminate_backend(${ guard . pid } )`
651+ expect ( await exited ) . toBe ( 1 )
652+ expect ( output ) . toContain ( 'Schema-push lock connection closed' )
653+ expect ( output ) . not . toContain ( 'Unrecognized options' )
654+ await writer `ROLLBACK`
655+ await vi . waitFor ( async ( ) => {
656+ expect (
657+ await sql `SELECT pid FROM pg_stat_activity
658+ WHERE datname = current_database() AND wait_event_type = 'Lock'
659+ AND query = 'LOCK TABLE public.workspace_files IN ACCESS EXCLUSIVE MODE'`
660+ ) . toHaveLength ( 0 )
661+ } )
662+ expect (
663+ await sql `SELECT attname FROM pg_attribute
664+ WHERE attrelid = 'workspace_files'::regclass AND attname = 'size' AND NOT attisdropped`
665+ ) . toHaveLength ( 1 )
666+ expect ( await getSearchRetirementStatus ( sql ) ) . toBeNull ( )
667+ } finally {
668+ await writer `ROLLBACK`
669+ await exited
670+ }
671+ } )
672+
673+ it ( 'the operator CLI refuses unverified endpoints and connection overrides before connecting' , ( ) => {
674+ const script = fileURLToPath ( new URL ( '../scripts/retire-indexed-search.ts' , import . meta. url ) )
675+ for ( const url of [
676+ 'postgresql://reader@pool.example.invalid:5432/postgres' ,
677+ 'postgresql://reader@fixture.pg.psdb.cloud:6432/postgres?sslmode=verify-full&sslrootcert=system' ,
678+ 'postgresql://reader@fixture.pg.psdb.cloud.example.invalid:5432/postgres' ,
679+ 'postgresql://reader@localhost:5432/production' ,
680+ 'postgresql://reader@fixture.pg.psdb.cloud:5432/postgres?sslmode=disable' ,
681+ 'postgresql://fixture.pg.psdb.cloud:5432/postgres?sslmode=verify-full&sslrootcert=system' ,
682+ 'postgresql://reader@fixture.pg.psdb.cloud:5432/?sslmode=verify-full&sslrootcert=system' ,
683+ `${ databaseUrl } ?statement_timeout=0` ,
684+ ] ) {
685+ const result = spawnSync ( 'bun' , [ '--no-env-file' , script , 'identity' ] , {
686+ env : { ...process . env , NODE_ENV : 'development' , MIGRATION_DATABASE_URL : url } ,
687+ encoding : 'utf8' ,
688+ timeout : 5_000 ,
689+ maxBuffer : 64 * 1_024 ,
690+ } )
691+ expect ( result . status ) . toBe ( 1 )
692+ expect ( `${ result . stdout } ${ result . stderr } ` ) . not . toContain ( url )
693+ }
694+ } )
695+
696+ it ( 'the CLI respects health gates, keeps status read-only, and stops after losing its session' , async ( ) => {
572697 const fixtureUrl = new URL ( databaseUrl )
573698 fixtureUrl . pathname = `/${ database } `
574699 const script = fileURLToPath ( new URL ( '../scripts/retire-indexed-search.ts' , import . meta. url ) )
@@ -608,22 +733,20 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
608733 maxSampleAgeMs : 30_000 ,
609734 } )
610735 )
611- await writeFile (
612- health ,
613- JSON . stringify ( {
614- databaseId,
615- observedAt : new Date ( ) . toISOString ( ) ,
616- healthy : false ,
617- maintenanceAllowed : true ,
618- cutoverAllowed : false ,
619- replicaLagBytes : 0 ,
620- replicaLagSeconds : 0 ,
621- walBytesPerSecond : 0 ,
622- databaseP95Ms : 1 ,
623- cpuPercent : 1 ,
624- freeStorageBytes : 1_000 ,
625- } )
626- )
736+ const sample = {
737+ databaseId,
738+ observedAt : new Date ( ) . toISOString ( ) ,
739+ healthy : false ,
740+ maintenanceAllowed : true ,
741+ cutoverAllowed : false ,
742+ replicaLagBytes : 0 ,
743+ replicaLagSeconds : 0 ,
744+ walBytesPerSecond : 0 ,
745+ databaseP95Ms : 1 ,
746+ cpuPercent : 1 ,
747+ freeStorageBytes : 1_000 ,
748+ }
749+ await writeFile ( health , JSON . stringify ( sample ) )
627750 const refused = invoke (
628751 'prepare' ,
629752 '--ack-release-drained' ,
@@ -636,6 +759,61 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
636759 expect ( `${ refused . stdout } ${ refused . stderr } ` ) . toContain ( 'unhealthy' )
637760 expect ( await getSearchRetirementStatus ( sql ) ) . toBeNull ( )
638761 expect ( invoke ( 'status' ) . status ) . toBe ( 0 )
762+ await writeFile ( health , JSON . stringify ( { ...sample , healthy : true } ) )
763+ expect (
764+ invoke (
765+ 'prepare' ,
766+ '--ack-release-drained' ,
767+ '--health-file' ,
768+ health ,
769+ '--health-policy' ,
770+ policy
771+ ) . status
772+ ) . toBe ( 0 )
773+ const runner = spawn (
774+ 'bun' ,
775+ [
776+ '--no-env-file' ,
777+ script ,
778+ 'run' ,
779+ '--pages' ,
780+ '2' ,
781+ '--health-file' ,
782+ health ,
783+ '--health-policy' ,
784+ policy ,
785+ ] ,
786+ {
787+ env : environment ,
788+ stdio : 'ignore' ,
789+ timeout : 20_000 ,
790+ }
791+ )
792+ const exited = new Promise < number | null > ( ( resolve , reject ) => {
793+ runner . once ( 'error' , reject )
794+ runner . once ( 'close' , resolve )
795+ } )
796+ try {
797+ await vi . waitFor (
798+ async ( ) => {
799+ expect (
800+ ( await sql `SELECT after_id FROM search_retirement_state WHERE id = 1` ) [ 0 ] . after_id
801+ ) . not . toBe ( '' )
802+ } ,
803+ { timeout : 5_000 }
804+ )
805+ const before = await getSearchRetirementStatus ( sql )
806+ const [ session ] = await sql `SELECT pid FROM pg_stat_activity
807+ WHERE datname = current_database() AND application_name = 'sim-search-data-retirement'`
808+ await sql `SELECT pg_terminate_backend(${ session . pid } )`
809+ const exitCode = await exited
810+ expect ( await getSearchRetirementStatus ( sql ) ) . toEqual ( before )
811+ expect ( exitCode ) . toBe ( 1 )
812+ } finally {
813+ runner . kill ( 'SIGKILL' )
814+ await exited
815+ }
816+ await abortSearchRetirement ( sql )
639817 } finally {
640818 await rm ( directory , { recursive : true , force : true } )
641819 }
0 commit comments