@@ -44,6 +44,7 @@ import { lockUniqueColumns, lockUniqueValues } from '@/lib/table/rows/unique-loc
4444import { getTableById } from '@/lib/table/service'
4545import { getOrCreateTableSnapshot } from '@/lib/table/snapshot-cache'
4646import type { ColumnDefinition , JsonValue , RowData , TableDefinition } from '@/lib/table/types'
47+ import { checkBatchUniqueConstraintsDb } from '@/lib/table/validation'
4748
4849const url = readTestDatabaseUrl ( )
4950if ( process . env . DATABASE_URL !== url ) {
@@ -87,7 +88,7 @@ async function createTable(columns: ColumnDefinition[]): Promise<TableDefinition
8788
8889async function seedRows (
8990 tableId : string ,
90- rows : Array < { id : string ; data : RowData ; orderKey : string | null } >
91+ rows : Array < { id : string ; data : RowData ; orderKey : string | null ; position ?: number } >
9192) {
9293 await db . insert ( userTableRows ) . values ( rows . map ( ( row ) => ( { ...row , tableId, workspaceId } ) ) )
9394}
@@ -481,6 +482,33 @@ describe('table row writes against real PostgreSQL', () => {
481482 expect ( await storedCount ( table . id , { email : 'dup@example.test' } ) ) . toBe ( 1 )
482483 } )
483484
485+ it ( 'rejects the second of two concurrent batches over the value-lock cap that share a value' , async ( ) => {
486+ const table = await createTable ( uniqueColumns )
487+ const batch = ( prefix : string ) => ( ) =>
488+ batchInsertRows (
489+ {
490+ tableId : table . id ,
491+ workspaceId,
492+ rows : [
493+ ...Array . from ( { length : 100 } , ( _ , i ) => ( { email : `${ prefix } -${ i } @example.test` } ) ) ,
494+ { email : 'shared@example.test' } ,
495+ ] ,
496+ secretProvenance : undefined ,
497+ capabilityGovernedUserId : null ,
498+ } ,
499+ table ,
500+ 'unique-race'
501+ )
502+
503+ const results = await raceUnderHeldDefinitionRow ( table . id , [ batch ( 'left' ) , batch ( 'right' ) ] , {
504+ onDefinitionRow : 1 ,
505+ onValueLock : 1 ,
506+ } )
507+
508+ expect ( fulfilled ( results ) ) . toBe ( 1 )
509+ expect ( await storedCount ( table . id , { email : 'shared@example.test' } ) ) . toBe ( 1 )
510+ } )
511+
484512 it ( 'rejects an import batch racing a single insert of the same value' , async ( ) => {
485513 const table = await createTable ( uniqueColumns )
486514
@@ -700,6 +728,163 @@ describe('table row writes against real PostgreSQL', () => {
700728 } )
701729 } )
702730
731+ describe ( 'batch unique check across many values' , ( ) => {
732+ /** Seeds `cells` at positions 0.., which the conflict messages report one-based. */
733+ const seedAt = ( tableId : string , cells : RowData [ ] ) =>
734+ seedRows (
735+ tableId ,
736+ cells . map ( ( data , position ) => ( {
737+ id : `${ tableId } -${ position } ` ,
738+ data,
739+ orderKey : `a${ position } ` ,
740+ position,
741+ } ) )
742+ )
743+
744+ /**
745+ * Index and sequential scans this backend has counted on `user_table_rows`, by relation. The
746+ * counters are the backend's unflushed statistics, which can still hold scans from earlier
747+ * transactions on the same pooled connection, so callers diff two readings.
748+ */
749+ async function rowScans ( trx : DbTransaction ) : Promise < Map < string , number > > {
750+ const scans = await trx . execute < { relname : string ; scans : number } > ( sql `
751+ SELECT c.relname, pg_stat_get_xact_numscans(c.oid)::int AS scans
752+ FROM pg_class c
753+ WHERE c.oid = 'user_table_rows'::regclass
754+ OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'user_table_rows'::regclass)` )
755+ return new Map ( scans . map ( ( row ) => [ row . relname , row . scans ] ) )
756+ }
757+
758+ /** Runs the check in a transaction and returns its result with the scans the check alone ran. */
759+ const checkWithScans = ( table : TableDefinition , rows : RowData [ ] ) =>
760+ db . transaction ( async ( trx ) => {
761+ // A parallel worker's scans are not counted in the leader's transaction statistics.
762+ await trx . execute ( sql `SET LOCAL max_parallel_workers_per_gather = 0` )
763+ const before = await rowScans ( trx )
764+ const result = await checkBatchUniqueConstraintsDb ( table . id , rows , table . schema , trx )
765+ const scans : Record < string , number > = { }
766+ for ( const [ relname , count ] of await rowScans ( trx ) ) {
767+ const ran = count - ( before . get ( relname ) ?? 0 )
768+ if ( ran > 0 ) scans [ relname ] = ran
769+ }
770+ return { result, scans }
771+ } )
772+
773+ it ( 'reports stored and in-batch conflicts across every probe group' , async ( ) => {
774+ const table = await createTable ( [
775+ { id : 'email' , name : 'email' , type : 'string' , unique : true } ,
776+ { id : 'meta' , name : 'meta' , type : 'json' , unique : true } ,
777+ ] )
778+ await seedAt ( table . id , [
779+ { email : 'first@example.test' } ,
780+ { email : 'middle@example.test' } ,
781+ { email : 'last@example.test' } ,
782+ { meta : { a : 1 , b : 2 } } ,
783+ { meta : [ 1 , 2 ] } ,
784+ ] )
785+ const rows : RowData [ ] = Array . from ( { length : 260 } , ( _ , i ) => ( {
786+ email : `new-${ i } @example.test` ,
787+ } ) )
788+ rows [ 0 ] = { email : 'new-0@example.test' , meta : { b : 2 , a : 1 } }
789+ rows [ 1 ] = { email : 'new-1@example.test' , meta : { a : 1 } }
790+ rows [ 2 ] = { email : 'new-2@example.test' , meta : [ 2 , 1 ] }
791+ rows [ 3 ] = { email : 'first@example.test' , meta : [ 1 , 2 ] }
792+ rows [ 7 ] = { email : null }
793+ rows [ 8 ] = { }
794+ rows [ 130 ] = { email : 'middle@example.test' }
795+ rows [ 200 ] = { email : 'new-5@example.test' }
796+ rows [ 259 ] = { email : 'last@example.test' }
797+
798+ expect ( ( await checkWithScans ( table , rows ) ) . result ) . toEqual ( {
799+ valid : false ,
800+ errors : [
801+ {
802+ row : 0 ,
803+ errors : [
804+ 'Column "meta" must be unique. Value "[object Object]" already exists in row 4' ,
805+ ] ,
806+ } ,
807+ {
808+ row : 3 ,
809+ errors : [
810+ 'Column "email" must be unique. Value "first@example.test" already exists in row 1' ,
811+ 'Column "meta" must be unique. Value "1,2" already exists in row 5' ,
812+ ] ,
813+ } ,
814+ {
815+ row : 130 ,
816+ errors : [
817+ 'Column "email" must be unique. Value "middle@example.test" already exists in row 2' ,
818+ ] ,
819+ } ,
820+ {
821+ row : 200 ,
822+ errors : [
823+ 'Column "email" must be unique. Value "new-5@example.test" duplicates row 6 in batch' ,
824+ ] ,
825+ } ,
826+ {
827+ row : 259 ,
828+ errors : [
829+ 'Column "email" must be unique. Value "last@example.test" already exists in row 3' ,
830+ ] ,
831+ } ,
832+ ] ,
833+ } )
834+ } )
835+
836+ it ( 'matches an expiration by instant in one pass over the table' , async ( ) => {
837+ const table = await createTable ( [
838+ { id : 'expires' , name : 'expires' , type : 'ttl' , unique : true } ,
839+ ] )
840+ await seedAt ( table . id , [ { expires : '2024-01-31T00:00:00-00:00' } ] )
841+ const rows : RowData [ ] = Array . from ( { length : 30 } , ( _ , i ) => ( {
842+ expires : `2025-01-${ String ( i + 1 ) . padStart ( 2 , '0' ) } T00:00:00-00:00` ,
843+ } ) )
844+ rows [ 27 ] = { expires : '2024-01-31T01:00:00+01:00' }
845+ rows [ 28 ] = { expires : null }
846+
847+ const { result, scans } = await checkWithScans ( table , rows )
848+
849+ expect ( result ) . toEqual ( {
850+ valid : false ,
851+ errors : [
852+ {
853+ row : 27 ,
854+ errors : [
855+ 'Column "expires" must be unique. Value "2024-01-31T01:00:00+01:00" already exists in row 1' ,
856+ ] ,
857+ } ,
858+ ] ,
859+ } )
860+ expect ( Object . values ( scans ) . reduce ( ( sum , count ) => sum + count , 0 ) ) . toBe ( 1 )
861+ } )
862+
863+ it ( 'probes the containment index once per value on a large table' , async ( ) => {
864+ const table = await createTable ( [
865+ { id : 'email' , name : 'email' , type : 'string' , unique : true } ,
866+ ] )
867+ await control `INSERT INTO user_table_rows (id, table_id, workspace_id, data, position, order_key)
868+ SELECT ${ table . id } ::text || '-' || i, ${ table . id } , ${ workspaceId } ,
869+ jsonb_build_object('email', 'stored-' || i || '@example.test'), i, 'a' || i
870+ FROM generate_series(0, 39999) AS i`
871+ // A bulk insert leaves its entries in the GIN pending list, which a manual ANALYZE does not
872+ // merge and which the planner charges to every probe, so flush it for a deterministic plan.
873+ await control `SELECT gin_clean_pending_list('user_table_rows_tenant_data_gin_idx'::regclass)`
874+ await control `ANALYZE user_table_rows`
875+ const rows : RowData [ ] = Array . from ( { length : 260 } , ( _ , i ) => ( {
876+ email : i % 10 === 0 ? `stored-${ i * 100 } @example.test` : `new-${ i } @example.test` ,
877+ } ) )
878+
879+ const { result, scans } = await checkWithScans ( table , rows )
880+
881+ await control `DELETE FROM user_table_rows WHERE table_id = ${ table . id } `
882+
883+ expect ( result . errors ) . toHaveLength ( 26 )
884+ expect ( scans ) . toEqual ( { user_table_rows_tenant_data_gin_idx : 260 } )
885+ } , 60_000 )
886+ } )
887+
703888 describe ( 'writers validate against the live schema, not their snapshot' , ( ) => {
704889 const columns : ColumnDefinition [ ] = [
705890 { id : 'key' , name : 'key' , type : 'string' , unique : true } ,
0 commit comments