@@ -9,7 +9,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema'
99import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
1010import { storageServiceMock , storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock'
1111import { tableBillingMock , tableBillingMockFns } from '@sim/testing/mocks/table-billing.mock'
12- import { tableTriggerMock } from '@sim/testing/mocks/table-trigger.mock'
12+ import { tableTriggerMock , tableTriggerMockFns } from '@sim/testing/mocks/table-trigger.mock'
1313import { tableWorkflowColumnsMock } from '@sim/testing/mocks/table-workflow-columns.mock'
1414import { sleep } from '@sim/utils/helpers'
1515import { generateId } from '@sim/utils/id'
@@ -97,6 +97,27 @@ async function rowsVersion(tableId: string): Promise<number> {
9797const textColumns = ( ...ids : string [ ] ) : ColumnDefinition [ ] =>
9898 ids . map ( ( id ) => ( { id, name : id , type : 'string' } ) )
9999
100+ /** Sessions waiting on the table's schema lock, matched by the key's hash as `pg_locks` shows it. */
101+ async function schemaLockWaiters ( tableId : string ) : Promise < number > {
102+ const [ { waiting } ] = await control < { waiting : number } [ ] > `
103+ WITH lock AS (SELECT hashtextextended(${ `user_table_schema:${ tableId } ` } , 0) AS key)
104+ SELECT count(*)::int AS waiting FROM pg_locks l CROSS JOIN lock
105+ WHERE l.locktype = 'advisory' AND NOT l.granted
106+ AND l.classid = ((lock.key >> 32) & 4294967295)::oid
107+ AND l.objid = (lock.key & 4294967295)::oid`
108+ return waiting
109+ }
110+
111+ /** Polls until exactly `expected` sessions wait on the table's schema lock, and asserts it. */
112+ async function untilSchemaLockWaiters ( tableId : string , expected : number ) {
113+ let waiting = 0
114+ for ( let attempt = 0 ; attempt < 400 && waiting !== expected ; attempt ++ ) {
115+ await sleep ( 5 )
116+ waiting = await schemaLockWaiters ( tableId )
117+ }
118+ expect ( waiting ) . toBe ( expected )
119+ }
120+
100121describe ( 'table row writes against real PostgreSQL' , ( ) => {
101122 beforeAll ( async ( ) => {
102123 await control `INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
@@ -1050,6 +1071,61 @@ describe('table row writes against real PostgreSQL', () => {
10501071 }
10511072 )
10521073
1074+ it ( 'fires the insert trigger of a batch insert with the column names of the live schema' , async ( ) => {
1075+ const table = await seededTable ( )
1076+ const stale : TableDefinition = {
1077+ ...table ,
1078+ schema : {
1079+ columns : table . schema . columns . map ( ( column ) =>
1080+ column . id === 'note' ? { ...column , name : 'old_note' } : column
1081+ ) ,
1082+ } ,
1083+ }
1084+ tableTriggerMockFns . mockFireTableTrigger . mockClear ( )
1085+
1086+ await batchInsertRows (
1087+ {
1088+ tableId : table . id ,
1089+ workspaceId,
1090+ rows : [ { key : 'k3' , note : 'n' } ] ,
1091+ secretProvenance : undefined ,
1092+ capabilityGovernedUserId : null ,
1093+ } ,
1094+ stale ,
1095+ 'stale-schema'
1096+ )
1097+
1098+ const [ trigger ] = tableTriggerMockFns . mockFireTableTrigger . mock . calls
1099+ const schema = trigger ?. [ 6 ] as { columns : ColumnDefinition [ ] }
1100+ expect ( schema . columns . map ( ( column ) => column . name ) ) . toContain ( 'note' )
1101+ } )
1102+
1103+ it ( 'does not count a legacy unique column named after an object prototype key as patched' , async ( ) => {
1104+ const table = await createTable ( [
1105+ { name : 'constructor' , type : 'string' , unique : true } ,
1106+ { id : 'note' , name : 'note' , type : 'string' } ,
1107+ ] )
1108+ await seedRows ( table . id , [
1109+ { id : `${ table . id } -a` , data : { constructor : 'a' , note : 'n' } , orderKey : 'a0' } ,
1110+ { id : `${ table . id } -b` , data : { constructor : 'b' , note : 'n' } , orderKey : 'a1' } ,
1111+ ] )
1112+
1113+ const result = await updateRowsByFilter (
1114+ table ,
1115+ {
1116+ filter : { note : 'n' } ,
1117+ data : { note : 'patched' } ,
1118+ // The limited path: the paged one skips rows created after its JS-clock cutoff.
1119+ limit : 10 ,
1120+ secretProvenance : undefined ,
1121+ capabilityGovernedUserId : null ,
1122+ } ,
1123+ 'prototype-key'
1124+ )
1125+
1126+ expect ( result . affectedCount ) . toBe ( 2 )
1127+ } )
1128+
10531129 it ( 'refuses a bulk update writing one value to rows of a column made unique since its snapshot' , async ( ) => {
10541130 const table = await seededTable ( )
10551131 await updateColumnConstraints (
@@ -1120,17 +1196,7 @@ describe('table row writes against real PostgreSQL', () => {
11201196 ( ) => 'rejected'
11211197 )
11221198
1123- let waiting = 0
1124- for ( let attempt = 0 ; attempt < 400 && waiting === 0 ; attempt ++ ) {
1125- await sleep ( 5 )
1126- ; [ { waiting } ] = await control < { waiting : number } [ ] > `
1127- WITH lock AS (SELECT hashtextextended(${ schemaLockKey } , 0) AS key)
1128- SELECT count(*)::int AS waiting FROM pg_locks l CROSS JOIN lock
1129- WHERE l.locktype = 'advisory' AND NOT l.granted
1130- AND l.classid = ((lock.key >> 32) & 4294967295)::oid
1131- AND l.objid = (lock.key & 4294967295)::oid`
1132- }
1133- expect ( waiting ) . toBe ( 1 )
1199+ await untilSchemaLockWaiters ( table . id , 1 )
11341200 expect ( await Promise . race ( [ settled , sleep ( 50 ) . then ( ( ) => 'pending' ) ] ) ) . toBe ( 'pending' )
11351201
11361202 await holder `COMMIT`
@@ -1143,7 +1209,7 @@ describe('table row writes against real PostgreSQL', () => {
11431209
11441210 /**
11451211 * Holds the table's schema lock exclusively, starts `write`, and commits once `write` is seen
1146- * waiting and `holdMs` has passed. Settles as `write` does.
1212+ * waiting on it and `holdMs` more has passed. Settles as `write` does.
11471213 */
11481214 async function writeBehindSchemaLock (
11491215 table : TableDefinition ,
@@ -1156,6 +1222,7 @@ describe('table row writes against real PostgreSQL', () => {
11561222 await holder `SELECT pg_advisory_xact_lock(hashtextextended(${ `user_table_schema:${ table . id } ` } , 0))`
11571223 const pending = write ( )
11581224 pending . catch ( ( ) => { } )
1225+ await untilSchemaLockWaiters ( table . id , 1 )
11591226 await sleep ( holdMs )
11601227 await holder `COMMIT`
11611228 return await pending
@@ -1301,25 +1368,6 @@ describe('table row writes against real PostgreSQL', () => {
13011368 return { table, run, retry }
13021369 }
13031370
1304- async function schemaLockWaiters ( tableId : string ) : Promise < number > {
1305- const [ { waiting } ] = await control < { waiting : number } [ ] > `
1306- WITH lock AS (SELECT hashtextextended(${ `user_table_schema:${ tableId } ` } , 0) AS key)
1307- SELECT count(*)::int AS waiting FROM pg_locks l CROSS JOIN lock
1308- WHERE l.locktype = 'advisory' AND NOT l.granted
1309- AND l.classid = ((lock.key >> 32) & 4294967295)::oid
1310- AND l.objid = (lock.key & 4294967295)::oid`
1311- return waiting
1312- }
1313-
1314- async function untilSchemaLockWaiters ( tableId : string , expected : number ) {
1315- let waiting = 0
1316- for ( let attempt = 0 ; attempt < 400 && waiting !== expected ; attempt ++ ) {
1317- await sleep ( 5 )
1318- waiting = await schemaLockWaiters ( tableId )
1319- }
1320- expect ( waiting ) . toBe ( expected )
1321- }
1322-
13231371 /**
13241372 * Commits `change` between the job's first and second batch. A held schema lock stops the
13251373 * first batch; `change` then queues behind it, and a waiting lock is granted in queue order, so
@@ -1478,6 +1526,28 @@ describe('table row writes against real PostgreSQL', () => {
14781526 expect ( count ) . toBe ( TABLE_LIMITS . UPDATE_BATCH_SIZE )
14791527 } )
14801528
1529+ it ( 'refuses a batch whose re-derived patch grows a row past the size limit' , async ( ) => {
1530+ const { table, run } = await seededJob ( { note : 7 } )
1531+ await changeSchemaUnderLock ( table . id , `jsonb_set(schema, '{columns,3,type}', '"number"')` )
1532+ const bigRowId = `${ table . id } -${ String ( TABLE_LIMITS . UPDATE_BATCH_SIZE + 1 ) . padStart ( 4 , '0' ) } `
1533+ // Stored jsonb orders keys by length, so a merged row reads kind, email, filler, then note.
1534+ const email = `e${ TABLE_LIMITS . UPDATE_BATCH_SIZE + 1 } @example.test`
1535+ const base = Buffer . byteLength ( JSON . stringify ( { kind : 'seed' , email, filler : '' , note : 7 } ) )
1536+ await control `UPDATE user_table_rows
1537+ SET data = data || jsonb_build_object('filler', repeat('x', ${ getMaxRowSizeBytes ( ) - base } ))
1538+ WHERE id = ${ bigRowId } `
1539+
1540+ const [ job , change ] = await changeBetweenBatches ( table . id , run , ( ) =>
1541+ changeSchemaUnderLock ( table . id , `jsonb_set(schema, '{columns,3,type}', '"string"')` )
1542+ )
1543+
1544+ expect ( change . status ) . toBe ( 'fulfilled' )
1545+ expect ( job . status === 'rejected' && job . reason ) . toBeInstanceOf ( UpdatePatchRejectedError )
1546+ const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1547+ FROM user_table_rows WHERE table_id = ${ table . id } AND data ? 'note'`
1548+ expect ( count ) . toBe ( TABLE_LIMITS . UPDATE_BATCH_SIZE )
1549+ } )
1550+
14811551 it ( 'writes a date patch given as an epoch number' , async ( ) => {
14821552 const { table, run } = await seededJob ( { due : 1704067200000 } )
14831553
0 commit comments