@@ -81,7 +81,8 @@ function makeClient(config = {}) {
8181 state . inFilter = { column : col , values } ;
8282 return builder ;
8383 } ,
84- order ( ) {
84+ order ( col , opts ) {
85+ state . order = { col, opts } ;
8586 return builder ;
8687 } ,
8788 range ( from , to ) {
@@ -105,7 +106,12 @@ function makeClient(config = {}) {
105106 result = config . updateResult ?? { error : null } ;
106107 } else {
107108 calls . select . push ( {
108- table, eqs : state . eqs , cols : state . cols , limited : state . limited ,
109+ table,
110+ eqs : state . eqs ,
111+ cols : state . cols ,
112+ limited : state . limited ,
113+ order : state . order ,
114+ range : state . range ,
109115 } ) ;
110116 if ( state . limited ) {
111117 result = config . getResult ?? { data : [ ] , error : null } ;
@@ -226,6 +232,15 @@ describe('semantic-index.utils', () => {
226232 } ) ;
227233 } ) ;
228234
235+ it ( 'clears the slice when sources is omitted entirely (undefined)' , async ( ) => {
236+ const client = makeClient ( ) ;
237+ const count = await syncOpportunitySemantic ( client , {
238+ siteId : SITE_ID , entityId : ENTITY_ID , entityType : ENTITY_TYPE , sourceType : SOURCE_TYPE ,
239+ } ) ;
240+ expect ( count ) . to . equal ( 0 ) ;
241+ expect ( client . calls . delete ) . to . have . length ( 1 ) ;
242+ } ) ;
243+
229244 it ( 'throws when a non-empty sources input yields no valid rows' , async ( ) => {
230245 const client = makeClient ( ) ;
231246 await expect ( syncOpportunitySemantic ( client , {
@@ -251,12 +266,51 @@ describe('semantic-index.utils', () => {
251266 expect ( client . calls . upsert ) . to . have . length ( 1 ) ;
252267 expect ( client . calls . upsert [ 0 ] . options ) . to . deep . equal ( { onConflict : 'entity_id,source_type,source_hash' } ) ;
253268 const row = client . calls . upsert [ 0 ] . rows [ 0 ] ;
254- expect ( row ) . to . include ( { site_id : SITE_ID , entity_id : ENTITY_ID , source_type : SOURCE_TYPE } ) ;
269+ // Full row shape — cross-repo write contract (model/dims/original text/hash/null id).
270+ expect ( row ) . to . include ( {
271+ site_id : SITE_ID ,
272+ entity_id : ENTITY_ID ,
273+ source_type : SOURCE_TYPE ,
274+ entity_type : ENTITY_TYPE ,
275+ model : 'azure/text-embedding-3-small' ,
276+ dims : 2 ,
277+ source_text : 'Running Shoes' , // original case, not normalized
278+ source_id : null ,
279+ } ) ;
255280 expect ( row . embedding ) . to . equal ( '[0.1,0.2]' ) ;
281+ expect ( row . source_hash ) . to . equal ( hashText ( normalizeText ( 'Running Shoes' ) ) ) ;
256282 expect ( client . calls . delete ) . to . have . length ( 1 ) ;
257283 expect ( client . calls . delete [ 0 ] . inFilter . values ) . to . deep . equal ( [ 'stale-hash' ] ) ;
258284 } ) ;
259285
286+ it ( 'passes an explicit sourceId through and validates model/dims/vector length' , async ( ) => {
287+ const client = makeClient ( { selectPages : [ { data : [ ] , error : null } ] } ) ;
288+ await syncOpportunitySemantic ( client , {
289+ siteId : SITE_ID ,
290+ entityId : ENTITY_ID ,
291+ entityType : ENTITY_TYPE ,
292+ sourceType : SOURCE_TYPE ,
293+ sources : [ src ( 'Running Shoes' , [ 0.1 , 0.2 ] , { sourceId : 'sid-1' } ) ] ,
294+ } ) ;
295+ expect ( client . calls . upsert [ 0 ] . rows [ 0 ] . source_id ) . to . equal ( 'sid-1' ) ;
296+
297+ // model/dims/vector-length are hard contract errors on the authoritative write side.
298+ await expect ( syncOpportunitySemantic ( makeClient ( ) , {
299+ siteId : SITE_ID ,
300+ entityId : ENTITY_ID ,
301+ entityType : ENTITY_TYPE ,
302+ sourceType : SOURCE_TYPE ,
303+ sources : [ { text : 'x' , vector : [ 0.1 , 0.2 ] , dims : 2 } ] , // missing model
304+ } ) ) . to . be . rejectedWith ( ValidationError , 'model is required' ) ;
305+ await expect ( syncOpportunitySemantic ( makeClient ( ) , {
306+ siteId : SITE_ID ,
307+ entityId : ENTITY_ID ,
308+ entityType : ENTITY_TYPE ,
309+ sourceType : SOURCE_TYPE ,
310+ sources : [ src ( 'x' , [ 0.1 , 0.2 ] , { dims : 1536 } ) ] , // vector length 2 != dims 1536
311+ } ) ) . to . be . rejectedWith ( ValidationError , 'does not match dims' ) ;
312+ } ) ;
313+
260314 it ( 'handles an empty read-back (nothing to prune)' , async ( ) => {
261315 const client = makeClient ( { selectPages : [ { data : [ ] , error : null } ] } ) ;
262316 const count = await syncOpportunitySemantic ( client , {
@@ -315,6 +369,57 @@ describe('semantic-index.utils', () => {
315369 siteId : SITE_ID , entityId : ENTITY_ID , entityType : ENTITY_TYPE , sourceType : SOURCE_TYPE , sources : [ src ( 'x' ) ] ,
316370 } ) ) . to . be . rejectedWith ( DataAccessError , 'Failed to prune' ) ;
317371 } ) ;
372+
373+ it ( 'paginates the stale-hash read-back across pages (>DEFAULT_PAGE_SIZE)' , async ( ) => {
374+ const keptHash = hashText ( normalizeText ( 'kept' ) ) ;
375+ const page1 = Array . from ( { length : 1000 } , ( _ , i ) => ( { source_hash : `h${ i } ` } ) ) ;
376+ const client = makeClient ( {
377+ selectPages : [
378+ { data : page1 , error : null } ,
379+ { data : [ { source_hash : keptHash } ] , error : null } ,
380+ ] ,
381+ } ) ;
382+ await syncOpportunitySemantic ( client , {
383+ siteId : SITE_ID , entityId : ENTITY_ID , entityType : ENTITY_TYPE , sourceType : SOURCE_TYPE , sources : [ src ( 'kept' ) ] ,
384+ } ) ;
385+ // both page windows fetched, ordered by source_hash asc
386+ expect ( client . calls . select ) . to . have . length ( 2 ) ;
387+ expect ( client . calls . select [ 0 ] . range ) . to . deep . equal ( [ 0 , 999 ] ) ;
388+ expect ( client . calls . select [ 1 ] . range ) . to . deep . equal ( [ 1000 , 1999 ] ) ;
389+ expect ( client . calls . select [ 0 ] . order ) . to . deep . equal ( { col : 'source_hash' , opts : { ascending : true } } ) ;
390+ // stale set spans page 1 (1000 hashes) -> pruned; keptHash (page 2) is retained
391+ const deleted = client . calls . delete . flatMap ( ( d ) => d . inFilter . values ) ;
392+ expect ( deleted ) . to . have . length ( 1000 ) ;
393+ expect ( deleted ) . to . not . include ( keptHash ) ;
394+ } ) ;
395+
396+ it ( 'chunks upsert + delete beyond SEMANTIC_CHUNK_SIZE (and single chunk at exactly the size)' , async ( ) => {
397+ // 21 sources -> 2 upsert chunks (20 + 1); read-back has 21 stale -> 2 delete chunks.
398+ const sources21 = Array . from ( { length : 21 } , ( _ , i ) => src ( `topic ${ i } ` ) ) ;
399+ const stale21 = Array . from ( { length : 21 } , ( _ , i ) => ( { source_hash : `stale-${ i } ` } ) ) ;
400+ const client = makeClient ( { selectPages : [ { data : stale21 , error : null } ] } ) ;
401+ await syncOpportunitySemantic ( client , {
402+ siteId : SITE_ID ,
403+ entityId : ENTITY_ID ,
404+ entityType : ENTITY_TYPE ,
405+ sourceType : SOURCE_TYPE ,
406+ sources : sources21 ,
407+ } ) ;
408+ expect ( client . calls . upsert ) . to . have . length ( 2 ) ;
409+ expect ( client . calls . delete ) . to . have . length ( 2 ) ;
410+
411+ // exactly 20 sources -> a single upsert chunk (boundary).
412+ const sources20 = Array . from ( { length : 20 } , ( _ , i ) => src ( `topic ${ i } ` ) ) ;
413+ const client20 = makeClient ( { selectPages : [ { data : [ ] , error : null } ] } ) ;
414+ await syncOpportunitySemantic ( client20 , {
415+ siteId : SITE_ID ,
416+ entityId : ENTITY_ID ,
417+ entityType : ENTITY_TYPE ,
418+ sourceType : SOURCE_TYPE ,
419+ sources : sources20 ,
420+ } ) ;
421+ expect ( client20 . calls . upsert ) . to . have . length ( 1 ) ;
422+ } ) ;
318423 } ) ;
319424
320425 describe ( 'copyEntityVectors' , ( ) => {
@@ -358,37 +463,61 @@ describe('semantic-index.utils', () => {
358463 } ) ;
359464
360465 describe ( 'lookupOpportunitiesByVector' , ( ) => {
361- it ( 'validates args + vector' , async ( ) => {
466+ const MODEL = 'azure/text-embedding-3-small' ;
467+
468+ it ( 'validates args + vector + model/dims (generation scope)' , async ( ) => {
362469 await expect ( lookupOpportunitiesByVector ( makeClient ( ) , {
363- sourceType : SOURCE_TYPE , vector : [ 0.1 ] ,
470+ sourceType : SOURCE_TYPE , vector : [ 0.1 ] , model : MODEL , dims : 2 ,
364471 } ) )
365472 . to . be . rejectedWith ( ValidationError , 'siteId is required' ) ;
366- await expect ( lookupOpportunitiesByVector ( makeClient ( ) , { siteId : SITE_ID , vector : [ 0.1 ] } ) )
473+ await expect ( lookupOpportunitiesByVector ( makeClient ( ) , {
474+ siteId : SITE_ID , vector : [ 0.1 ] , model : MODEL , dims : 2 ,
475+ } ) )
367476 . to . be . rejectedWith ( ValidationError , 'sourceType is required' ) ;
368477 await expect ( lookupOpportunitiesByVector ( makeClient ( ) , {
369- siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ ] ,
478+ siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 ] , dims : 2 ,
479+ } ) )
480+ . to . be . rejectedWith ( ValidationError , 'model is required' ) ;
481+ await expect ( lookupOpportunitiesByVector ( makeClient ( ) , {
482+ siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 ] , model : MODEL ,
483+ } ) )
484+ . to . be . rejectedWith ( ValidationError , 'dims must be a positive integer' ) ;
485+ await expect ( lookupOpportunitiesByVector ( makeClient ( ) , {
486+ siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ ] , model : MODEL , dims : 2 ,
370487 } ) )
371488 . to . be . rejectedWith ( ValidationError , 'vector must be' ) ;
372489 } ) ;
373490
374- it ( 'calls the ANN RPC and maps rows, using defaults' , async ( ) => {
491+ it ( 'calls the ANN RPC scoped to model/dims and maps rows, using defaults' , async ( ) => {
375492 const client = makeClient ( {
376493 rpcResult : { data : [ { entity_id : 'o1' , entity_type : ENTITY_TYPE , score : 0.87 } ] , error : null } ,
377494 } ) ;
378495 const out = await lookupOpportunitiesByVector ( client , {
379- siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 , 0.2 ] ,
496+ siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 , 0.2 ] , model : MODEL , dims : 2 ,
380497 } ) ;
381498 expect ( out ) . to . deep . equal ( [ { entityId : 'o1' , entityType : ENTITY_TYPE , score : 0.87 } ] ) ;
382499 expect ( client . calls . rpc [ 0 ] . name ) . to . equal ( SEMANTIC_SEARCH_RPC ) ;
383500 expect ( client . calls . rpc [ 0 ] . params ) . to . deep . equal ( {
384- p_site_id : SITE_ID , p_source_type : SOURCE_TYPE , p_query_embedding : '[0.1,0.2]' , p_limit : 10 , p_min_score : 0 ,
501+ p_site_id : SITE_ID ,
502+ p_source_type : SOURCE_TYPE ,
503+ p_query_embedding : '[0.1,0.2]' ,
504+ p_model : MODEL ,
505+ p_dims : 2 ,
506+ p_limit : 10 ,
507+ p_min_score : 0 ,
385508 } ) ;
386509 } ) ;
387510
388511 it ( 'passes k/minScore and tolerates null data' , async ( ) => {
389512 const client = makeClient ( { rpcResult : { data : null , error : null } } ) ;
390513 const out = await lookupOpportunitiesByVector ( client , {
391- siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 , 0.2 ] , k : 5 , minScore : 0.3 ,
514+ siteId : SITE_ID ,
515+ sourceType : SOURCE_TYPE ,
516+ vector : [ 0.1 , 0.2 ] ,
517+ model : MODEL ,
518+ dims : 2 ,
519+ k : 5 ,
520+ minScore : 0.3 ,
392521 } ) ;
393522 expect ( out ) . to . deep . equal ( [ ] ) ;
394523 expect ( client . calls . rpc [ 0 ] . params ) . to . include ( { p_limit : 5 , p_min_score : 0.3 } ) ;
@@ -397,7 +526,7 @@ describe('semantic-index.utils', () => {
397526 it ( 'wraps an RPC error' , async ( ) => {
398527 const client = makeClient ( { rpcResult : { data : null , error : { message : 'boom' } } } ) ;
399528 await expect ( lookupOpportunitiesByVector ( client , {
400- siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 ] ,
529+ siteId : SITE_ID , sourceType : SOURCE_TYPE , vector : [ 0.1 ] , model : MODEL , dims : 2 ,
401530 } ) ) . to . be . rejectedWith ( DataAccessError , 'Failed semantic search' ) ;
402531 } ) ;
403532 } ) ;
@@ -444,9 +573,13 @@ describe('semantic-index.utils', () => {
444573 await expect ( upsertQueryEmbedding ( makeClient ( ) , { text : 'x' , model : MODEL , vector : [ 0.1 ] } ) )
445574 . to . be . rejectedWith ( ValidationError , 'dims must be a positive integer' ) ;
446575 await expect ( upsertQueryEmbedding ( makeClient ( ) , {
447- text : ' ' , model : MODEL , dims : 2 , vector : [ 0.1 ] ,
576+ text : ' ' , model : MODEL , dims : 2 , vector : [ 0.1 , 0.2 ] ,
448577 } ) )
449578 . to . be . rejectedWith ( ValidationError , 'text is required' ) ;
579+ await expect ( upsertQueryEmbedding ( makeClient ( ) , {
580+ text : 'x' , model : MODEL , dims : 2 , vector : [ 0.1 ] , // length 1 != dims 2
581+ } ) )
582+ . to . be . rejectedWith ( ValidationError , 'does not match dims' ) ;
450583 const client = makeClient ( ) ;
451584 const h = await upsertQueryEmbedding ( client , {
452585 text : 'Running Shoes' , model : MODEL , dims : 2 , vector : [ 0.1 , 0.2 ] ,
@@ -462,7 +595,7 @@ describe('semantic-index.utils', () => {
462595 it ( 'upsertQueryEmbedding wraps an error' , async ( ) => {
463596 const client = makeClient ( { upsertResult : { error : { message : 'boom' } } } ) ;
464597 await expect ( upsertQueryEmbedding ( client , {
465- text : 'x' , model : MODEL , dims : 2 , vector : [ 0.1 ] ,
598+ text : 'x' , model : MODEL , dims : 2 , vector : [ 0.1 , 0.2 ] ,
466599 } ) ) . to . be . rejectedWith ( DataAccessError , 'Failed to upsert semantic_query_embedding' ) ;
467600 } ) ;
468601
0 commit comments