@@ -26,11 +26,13 @@ import { deleteColumn, updateColumnConstraints } from '@/lib/table/columns/servi
2626import { getMaxRowSizeBytes } from '@/lib/table/constants'
2727import { bulkInsertImportBatch , importReplaceRows } from '@/lib/table/import-data'
2828import type { DbTransaction } from '@/lib/table/planner'
29+ import { readCurrentRowsVersion } from '@/lib/table/row-changes'
2930import { lockLiveTableSchema } from '@/lib/table/rows/live-schema'
3031import { acquireRowOrderLock } from '@/lib/table/rows/ordering'
3132import {
3233 batchInsertRows ,
3334 batchUpdateRows ,
35+ deleteRowsByFilter ,
3436 insertRow ,
3537 replaceTableRows ,
3638 updateRow ,
@@ -66,6 +68,12 @@ const [{ migrated }] = await control<{ migrated: boolean }[]>`SELECT EXISTS (
6668 AND p.proname = 'bump_user_table_rows_version_at_commit'
6769) AS migrated`
6870
71+ /** Whether the row triggers log to `user_table_row_changes` (0402) rather than lock the definition. */
72+ const [ { logsRowChanges } ] = await control < { logsRowChanges : boolean } [ ] > `SELECT EXISTS (
73+ SELECT 1 FROM pg_proc
74+ WHERE proname = 'increment_user_table_row_count_stmt' AND prosrc LIKE '%user_table_row_changes%'
75+ ) AS "logsRowChanges"`
76+
6977async function createTable ( columns : ColumnDefinition [ ] ) : Promise < TableDefinition > {
7078 const id = generateId ( )
7179 await db
@@ -84,8 +92,9 @@ async function seedRows(
8492}
8593
8694async function rowsVersion ( tableId : string ) : Promise < number > {
87- const [ row ] = await control `SELECT rows_version FROM user_table_definitions WHERE id = ${ tableId } `
88- return Number ( row . rows_version )
95+ const version = await readCurrentRowsVersion ( tableId )
96+ if ( version === null ) throw new Error ( 'Fixture table missing' )
97+ return version
8998}
9099
91100const textColumns = ( ...ids : string [ ] ) : ColumnDefinition [ ] =>
@@ -1389,6 +1398,160 @@ describe('table row writes against real PostgreSQL', () => {
13891398 } )
13901399 } )
13911400
1401+ describe . skipIf ( ! migrated || ! logsRowChanges ) ( 'a held definition row' , ( ) => {
1402+ it ( 'lets every row write path commit while another session holds the definition row' , async ( ) => {
1403+ const table = await createTable ( [
1404+ { id : 'key' , name : 'key' , type : 'string' , unique : true } ,
1405+ { id : 'name' , name : 'name' , type : 'string' } ,
1406+ ] )
1407+ const versionBefore = await rowsVersion ( table . id )
1408+ const rowIdByKey = async ( key : string ) => {
1409+ const [ row ] = await control < { id : string } [ ] > `SELECT id FROM user_table_rows
1410+ WHERE table_id = ${ table . id } AND data->>'key' = ${ key } `
1411+ return row . id
1412+ }
1413+ const writes : Array < [ string , ( ) => Promise < unknown > ] > = [
1414+ [
1415+ 'insert' ,
1416+ ( ) =>
1417+ insertRow (
1418+ {
1419+ tableId : table . id ,
1420+ workspaceId,
1421+ data : { key : 'a' , name : 'a' } ,
1422+ secretProvenance : undefined ,
1423+ capabilityGovernedUserId : null ,
1424+ } ,
1425+ table ,
1426+ 'held-insert'
1427+ ) ,
1428+ ] ,
1429+ [
1430+ 'batch insert' ,
1431+ ( ) =>
1432+ batchInsertRows (
1433+ {
1434+ tableId : table . id ,
1435+ workspaceId,
1436+ rows : [
1437+ { key : 'b' , name : 'b' } ,
1438+ { key : 'c' , name : 'c' } ,
1439+ ] ,
1440+ secretProvenance : undefined ,
1441+ capabilityGovernedUserId : null ,
1442+ } ,
1443+ table ,
1444+ 'held-batch-insert'
1445+ ) ,
1446+ ] ,
1447+ [
1448+ 'upsert' ,
1449+ ( ) =>
1450+ upsertRow (
1451+ {
1452+ tableId : table . id ,
1453+ workspaceId,
1454+ data : { key : 'd' , name : 'd' } ,
1455+ conflictTarget : 'key' ,
1456+ secretProvenance : undefined ,
1457+ capabilityGovernedUserId : null ,
1458+ } ,
1459+ table ,
1460+ 'held-upsert'
1461+ ) ,
1462+ ] ,
1463+ [
1464+ 'upsert of an existing key' ,
1465+ ( ) =>
1466+ upsertRow (
1467+ {
1468+ tableId : table . id ,
1469+ workspaceId,
1470+ data : { key : 'd' , name : 'd2' } ,
1471+ conflictTarget : 'key' ,
1472+ secretProvenance : undefined ,
1473+ capabilityGovernedUserId : null ,
1474+ } ,
1475+ table ,
1476+ 'held-upsert-existing'
1477+ ) ,
1478+ ] ,
1479+ [
1480+ 'update by id' ,
1481+ async ( ) =>
1482+ updateRow (
1483+ {
1484+ tableId : table . id ,
1485+ rowId : await rowIdByKey ( 'a' ) ,
1486+ workspaceId,
1487+ data : { name : 'a2' } ,
1488+ secretProvenance : undefined ,
1489+ capabilityGovernedUserId : null ,
1490+ } ,
1491+ table ,
1492+ 'held-update'
1493+ ) ,
1494+ ] ,
1495+ [
1496+ 'update by filter' ,
1497+ ( ) =>
1498+ updateRowsByFilter (
1499+ table ,
1500+ {
1501+ filter : { name : 'b' } ,
1502+ data : { name : 'b2' } ,
1503+ limit : 10 ,
1504+ secretProvenance : undefined ,
1505+ capabilityGovernedUserId : null ,
1506+ } ,
1507+ 'held-update-by-filter'
1508+ ) ,
1509+ ] ,
1510+ [
1511+ 'delete by filter' ,
1512+ ( ) => deleteRowsByFilter ( table , { filter : { name : 'c' } } , 'held-delete-by-filter' ) ,
1513+ ] ,
1514+ [
1515+ 'replace' ,
1516+ ( ) =>
1517+ replaceTableRows (
1518+ {
1519+ tableId : table . id ,
1520+ workspaceId,
1521+ rows : [ { key : 'x' , name : 'x' } ] ,
1522+ secretProvenance : undefined ,
1523+ } ,
1524+ table ,
1525+ 'held-replace'
1526+ ) ,
1527+ ] ,
1528+ ]
1529+
1530+ const holder = await control . reserve ( )
1531+ const elapsedMs : Record < string , number > = { }
1532+ try {
1533+ await holder `BEGIN`
1534+ await holder `SELECT 1 FROM user_table_definitions WHERE id = ${ table . id } FOR NO KEY UPDATE`
1535+ for ( const [ name , write ] of writes ) {
1536+ const started = Date . now ( )
1537+ await write ( )
1538+ elapsedMs [ name ] = Date . now ( ) - started
1539+ }
1540+ } finally {
1541+ await holder `ROLLBACK` . catch ( ( ) => { } )
1542+ holder . release ( )
1543+ }
1544+
1545+ for ( const [ name ] of writes ) expect ( elapsedMs [ name ] , name ) . toBeLessThan ( 2_000 )
1546+ const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1547+ FROM user_table_rows WHERE table_id = ${ table . id } `
1548+ expect ( count ) . toBe ( 1 )
1549+ expect ( ( await getTableById ( table . id ) ) ?. rowCount ) . toBe ( count )
1550+ // One log entry per write, except replace, which logs its DELETE and its INSERT.
1551+ expect ( await rowsVersion ( table . id ) ) . toBe ( versionBefore + writes . length + 1 )
1552+ } )
1553+ } )
1554+
13921555 describe . skipIf ( ! migrated ) ( 'rows_version' , ( ) => {
13931556 it ( 'advances once for a transaction that edits cells across several statements' , async ( ) => {
13941557 const table = await createTable ( textColumns ( 'name' ) )
0 commit comments