11import { spawnSync } from 'node:child_process'
22import { readFile } from 'node:fs/promises'
33import { fileURLToPath } from 'node:url'
4- import { SYNC_EMBEDDING_SEARCH_FUNCTION } from '@sim/db/knowledge-projection'
4+ import { isCurrentSyncEmbeddingSearchSource } from '@sim/db/knowledge-projection'
55import {
66 inspectSearchProjection ,
77 purgeSearchProjection ,
@@ -47,6 +47,19 @@ describe('Search candidate retirement in PostgreSQL', () => {
4747 FROM pg_proc p WHERE p.oid = 'public.sync_embedding_search'::regproc`
4848 }
4949
50+ /** Applies migration 0449 as the migrator does, in one transaction. */
51+ async function applyMigration ( ) {
52+ const migration = await readFile (
53+ new URL ( '../migrations/0449_retire_search_projection_writes.sql' , import . meta. url ) ,
54+ 'utf8'
55+ )
56+ await sql . begin ( async ( tx ) => {
57+ for ( const statement of migration . split ( '--> statement-breakpoint' ) ) {
58+ if ( statement . trim ( ) ) await tx . unsafe ( statement )
59+ }
60+ } )
61+ }
62+
5063 async function control < T > ( run : ( admin : Sql ) => Promise < T > ) : Promise < T > {
5164 const url = new URL ( databaseUrl )
5265 url . pathname = '/postgres'
@@ -91,17 +104,7 @@ describe('Search candidate retirement in PostgreSQL', () => {
91104 expect ( provision . status , provision . stdout + provision . stderr ) . toBe ( 0 )
92105 ; [ provisioned ] = await definitions ( )
93106 // Every behaviour below runs on the function the versioned migration installs, replayed twice.
94- const migration = await readFile (
95- new URL ( '../migrations/0449_retire_search_projection_writes.sql' , import . meta. url ) ,
96- 'utf8'
97- )
98- for ( let replay = 0 ; replay < 2 ; replay ++ ) {
99- await sql . begin ( async ( tx ) => {
100- for ( const statement of migration . split ( '--> statement-breakpoint' ) ) {
101- if ( statement . trim ( ) ) await tx . unsafe ( statement )
102- }
103- } )
104- }
107+ for ( let replay = 0 ; replay < 2 ; replay ++ ) await applyMigration ( )
105108 await sql `INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
106109 VALUES ('reader', 'Synthetic reader', 'reader@example.test', true, now(), now())`
107110 await sql `INSERT INTO project (id, name, owner_id) VALUES ('project', 'Synthetic project', 'reader')`
@@ -159,14 +162,10 @@ describe('Search candidate retirement in PostgreSQL', () => {
159162 return row . n
160163 }
161164
162- const normalized = ( text : string ) => text . replace ( / \s + / g, ' ' ) . trim ( )
163-
164165 it ( 'installs the provisioning function and leaves both triggers as they were' , async ( ) => {
165166 const [ migrated ] = await definitions ( )
166- expect ( normalized ( migrated . body ) ) . toBe ( normalized ( provisioned . body ) )
167- expect ( normalized ( migrated . body ) ) . toBe (
168- normalized ( SYNC_EMBEDDING_SEARCH_FUNCTION . split ( '$$' ) [ 1 ] )
169- )
167+ expect ( isCurrentSyncEmbeddingSearchSource ( provisioned . body ) ) . toBe ( true )
168+ expect ( isCurrentSyncEmbeddingSearchSource ( migrated . body ) ) . toBe ( true )
170169 expect ( migrated . sync ) . toBe ( provisioned . sync )
171170 expect ( migrated . sync ) . toContain ( "current_setting('sim.projection_mode'" )
172171 expect ( migrated . acl ) . toBe ( provisioned . acl )
@@ -368,17 +367,47 @@ describe('Search candidate retirement in PostgreSQL', () => {
368367 } )
369368 } )
370369
370+ function runPurge ( ...args : string [ ] ) {
371+ return spawnSync ( 'bun' , [ '--no-env-file' , './scripts/purge-search-projection.ts' , ...args ] , {
372+ cwd : packageRoot ,
373+ env : { ...process . env , MIGRATION_DATABASE_URL : fixtureUrl . toString ( ) } ,
374+ encoding : 'utf8' ,
375+ timeout : 30_000 ,
376+ } )
377+ }
378+
379+ it ( 'refuses --execute until migration 0449 is live, and the dry run reports it' , async ( ) => {
380+ await seedBase ( { id : 'search' , width : 1536 , model : 'text-embedding-3-small' , chunks : 3 } , true )
381+ await sql . unsafe ( `CREATE OR REPLACE FUNCTION public.sync_embedding_search()
382+ RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$` )
383+ try {
384+ const dryRun = runPurge ( )
385+ expect ( dryRun . status , dryRun . stdout + dryRun . stderr ) . toBe ( 0 )
386+ expect ( dryRun . stdout ) . toContain ( '"projectionRetired":false' )
387+ const stale = runPurge ( '--execute' , '--pause-ms' , '0' )
388+ expect ( stale . status ) . toBe ( 1 )
389+ expect ( stale . stdout + stale . stderr ) . toContain ( 'Migration 0449 is not live' )
390+ expect ( await candidateCount ( 'search' ) ) . toBe ( 3 )
391+
392+ await applyMigration ( )
393+ await sql `ALTER TABLE embedding DISABLE TRIGGER embedding_search_sync`
394+ expect ( runPurge ( '--execute' , '--pause-ms' , '0' ) . status ) . toBe ( 1 )
395+ expect ( await candidateCount ( 'search' ) ) . toBe ( 3 )
396+ } finally {
397+ await sql `ALTER TABLE embedding ENABLE TRIGGER embedding_search_sync`
398+ await applyMigration ( )
399+ }
400+ const dryRun = runPurge ( )
401+ expect ( dryRun . stdout ) . toContain ( '"projectionRetired":true' )
402+ const execute = runPurge ( '--execute' , '--pause-ms' , '0' )
403+ expect ( execute . status , execute . stdout + execute . stderr ) . toBe ( 0 )
404+ expect ( await candidateCount ( 'search' ) ) . toBe ( 0 )
405+ } )
406+
371407 it ( 'runs as an operator command: dry run by default, deletes only with --execute' , async ( ) => {
372408 await seedBase ( { id : 'search' , width : 1536 , model : 'text-embedding-3-small' , chunks : 3 } , true )
373409 await seedBase ( { id : 'ordinary' , width : 1536 , model : 'text-embedding-3-small' , chunks : 2 } )
374- const run = ( ...args : string [ ] ) => {
375- return spawnSync ( 'bun' , [ '--no-env-file' , './scripts/purge-search-projection.ts' , ...args ] , {
376- cwd : packageRoot ,
377- env : { ...process . env , MIGRATION_DATABASE_URL : fixtureUrl . toString ( ) } ,
378- encoding : 'utf8' ,
379- timeout : 30_000 ,
380- } )
381- }
410+ const run = runPurge
382411 const help = run ( '--help' )
383412 expect ( help . status ) . toBe ( 0 )
384413 expect ( help . stdout ) . toContain ( 'REINDEX INDEX CONCURRENTLY embedding_search_512_cosine_hnsw_idx' )
@@ -401,16 +430,7 @@ describe('Search candidate retirement in PostgreSQL', () => {
401430 await sql `CREATE TABLE shadow.knowledge_base (id text, is_search_index boolean)`
402431 await sql . unsafe ( `ALTER DATABASE "${ database } " SET search_path = shadow, public` )
403432 try {
404- const execute = spawnSync (
405- 'bun' ,
406- [ '--no-env-file' , './scripts/purge-search-projection.ts' , '--execute' , '--pause-ms' , '0' ] ,
407- {
408- cwd : packageRoot ,
409- env : { ...process . env , MIGRATION_DATABASE_URL : fixtureUrl . toString ( ) } ,
410- encoding : 'utf8' ,
411- timeout : 30_000 ,
412- }
413- )
433+ const execute = runPurge ( '--execute' , '--pause-ms' , '0' )
414434 expect ( execute . status , execute . stdout + execute . stderr ) . toBe ( 0 )
415435 } finally {
416436 await sql . unsafe ( `ALTER DATABASE "${ database } " RESET search_path` )
0 commit comments