@@ -22,14 +22,9 @@ vi.mock('@/lib/table/trigger', () => tableTriggerMock)
2222vi . mock ( '@/lib/table/workflow-columns' , ( ) => tableWorkflowColumnsMock )
2323vi . mock ( '@/lib/uploads/core/storage-service' , ( ) => storageServiceMock )
2424
25- import {
26- deleteColumn ,
27- updateColumnConstraints ,
28- updateColumnOptions ,
29- } from '@/lib/table/columns/service'
30- import { getMaxRowSizeBytes , TABLE_LIMITS } from '@/lib/table/constants'
25+ import { deleteColumn , updateColumnConstraints } from '@/lib/table/columns/service'
26+ import { getMaxRowSizeBytes } from '@/lib/table/constants'
3127import { bulkInsertImportBatch , importReplaceRows } from '@/lib/table/import-data'
32- import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service'
3328import type { DbTransaction } from '@/lib/table/planner'
3429import { lockLiveTableSchema } from '@/lib/table/rows/live-schema'
3530import { acquireRowOrderLock } from '@/lib/table/rows/ordering'
@@ -46,7 +41,6 @@ import { lockUniqueColumns, lockUniqueValues } from '@/lib/table/rows/unique-loc
4641import { getTableById } from '@/lib/table/service'
4742import { getOrCreateTableSnapshot } from '@/lib/table/snapshot-cache'
4843import type { ColumnDefinition , JsonValue , RowData , TableDefinition } from '@/lib/table/types'
49- import { runTableUpdate , UpdatePatchRejectedError } from '@/lib/table/update-runner'
5044
5145const url = readTestDatabaseUrl ( )
5246if ( process . env . DATABASE_URL !== url ) {
@@ -1388,246 +1382,6 @@ describe('table row writes against real PostgreSQL', () => {
13881382 } )
13891383 } )
13901384
1391- describe ( 'background updates validate their patch against the live schema' , ( ) => {
1392- const columns : ColumnDefinition [ ] = [
1393- { id : 'email' , name : 'email' , type : 'string' } ,
1394- { id : 'kind' , name : 'kind' , type : 'string' } ,
1395- {
1396- id : 'status' ,
1397- name : 'status' ,
1398- type : 'select' ,
1399- options : [ { id : 'opt_open' , name : 'Open' } ] ,
1400- } ,
1401- { id : 'note' , name : 'note' , type : 'string' } ,
1402- { id : 'due' , name : 'due' , type : 'date' } ,
1403- ]
1404- /** Two update batches' worth of rows. */
1405- const ROWS = TABLE_LIMITS . UPDATE_BATCH_SIZE + 50
1406-
1407- async function seededJob ( data : RowData ) {
1408- const table = await createTable ( columns )
1409- await control `INSERT INTO user_table_rows (id, table_id, workspace_id, data, position, order_key)
1410- SELECT ${ table . id } || '-' || lpad(g::text, 4, '0'), ${ table . id } , ${ workspaceId } ,
1411- jsonb_build_object('email', 'e' || g || '@example.test', 'kind', 'seed'), g, 'a' || lpad(g::text, 4, '0')
1412- FROM generate_series(1, ${ ROWS } ) g`
1413- const jobId = generateId ( )
1414- const filter = { kind : 'seed' }
1415- expect (
1416- await markTableJobRunningInWorkspace ( table . id , workspaceId , jobId , 'update' , {
1417- filter,
1418- data,
1419- } )
1420- ) . toBe ( true )
1421- const run = ( ) =>
1422- runTableUpdate ( {
1423- jobId,
1424- tableId : table . id ,
1425- workspaceId,
1426- filter,
1427- data : { ...data } ,
1428- cutoff : new Date ( ) ,
1429- } )
1430- /** Re-runs the job as a retry after a crash would: the job is still running. */
1431- const retry = async ( ) => {
1432- await control `UPDATE table_jobs SET status = 'running', completed_at = NULL
1433- WHERE id = ${ jobId } `
1434- return run ( )
1435- }
1436- return { table, run, retry }
1437- }
1438-
1439- /**
1440- * Commits `change` between the job's first and second batch. A held schema lock stops the
1441- * first batch; `change` then queues behind it, and a waiting lock is granted in queue order, so
1442- * the first batch commits, then `change`, then the second batch.
1443- */
1444- async function changeBetweenBatches (
1445- tableId : string ,
1446- run : ( ) => Promise < void > ,
1447- change : ( ) => Promise < unknown >
1448- ) : Promise < PromiseSettledResult < unknown > [ ] > {
1449- const holder = await control . reserve ( )
1450- try {
1451- await holder `BEGIN`
1452- await holder `SELECT pg_advisory_xact_lock(hashtextextended(${ `user_table_schema:${ tableId } ` } , 0))`
1453- const job = run ( )
1454- await untilSchemaLockWaiters ( tableId , 1 )
1455- const changed = change ( )
1456- await untilSchemaLockWaiters ( tableId , 2 )
1457- await holder `COMMIT`
1458- return await Promise . allSettled ( [ job , changed ] )
1459- } finally {
1460- await holder `ROLLBACK` . catch ( ( ) => { } )
1461- holder . release ( )
1462- }
1463- }
1464-
1465- /** Commits `schema = <change>` for the table under its exclusive schema lock. */
1466- async function changeSchemaUnderLock ( tableId : string , change : string ) : Promise < void > {
1467- const changer = await control . reserve ( )
1468- try {
1469- await changer `BEGIN`
1470- await changer `SELECT pg_advisory_xact_lock(hashtextextended(${ `user_table_schema:${ tableId } ` } , 0))`
1471- await changer . unsafe ( `UPDATE user_table_definitions SET schema = ${ change } WHERE id = $1` , [
1472- tableId ,
1473- ] )
1474- await changer `COMMIT`
1475- } finally {
1476- changer . release ( )
1477- }
1478- }
1479-
1480- async function countEmail ( tableId : string , email : string ) : Promise < number > {
1481- const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1482- FROM user_table_rows WHERE table_id = ${ tableId } AND data->>'email' = ${ email } `
1483- return count
1484- }
1485-
1486- it ( 'refuses a patch to a column made unique before the job started, retry included' , async ( ) => {
1487- const { table, run } = await seededJob ( { email : 'same@example.test' } )
1488- await updateColumnConstraints (
1489- { tableId : table . id , columnName : 'email' , unique : true } ,
1490- 'bulk-update'
1491- )
1492-
1493- await expect ( run ( ) ) . rejects . toBeInstanceOf ( UpdatePatchRejectedError )
1494- await expect ( run ( ) ) . rejects . toBeInstanceOf ( UpdatePatchRejectedError )
1495- expect ( await countEmail ( table . id , 'same@example.test' ) ) . toBe ( 0 )
1496- } )
1497-
1498- it ( 'refuses the next batch once a column it clears is made required mid-job' , async ( ) => {
1499- const { table, run } = await seededJob ( { note : null } )
1500- await control `UPDATE user_table_rows SET data = data || '{"note":"filled"}' WHERE table_id = ${ table . id } `
1501-
1502- const [ job , change ] = await changeBetweenBatches ( table . id , run , async ( ) => {
1503- const changer = await control . reserve ( )
1504- try {
1505- await changer `BEGIN`
1506- await changer `SELECT pg_advisory_xact_lock(hashtextextended(${ `user_table_schema:${ table . id } ` } , 0))`
1507- await changer `UPDATE user_table_definitions
1508- SET schema = jsonb_set(schema, '{columns,3,required}', 'true') WHERE id = ${ table . id } `
1509- await changer `COMMIT`
1510- } finally {
1511- changer . release ( )
1512- }
1513- } )
1514-
1515- expect ( change . status ) . toBe ( 'fulfilled' )
1516- expect ( job . status === 'rejected' && job . reason ) . toBeInstanceOf ( UpdatePatchRejectedError )
1517- const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1518- FROM user_table_rows WHERE table_id = ${ table . id } AND data->'note' = 'null'::jsonb`
1519- expect ( count ) . toBe ( TABLE_LIMITS . UPDATE_BATCH_SIZE )
1520- } )
1521-
1522- it ( 'refuses the next batch once a patched column is made unique mid-job, retry included' , async ( ) => {
1523- const { table, run } = await seededJob ( { email : 'same@example.test' } )
1524-
1525- const [ job , change ] = await changeBetweenBatches ( table . id , run , ( ) =>
1526- changeSchemaUnderLock ( table . id , `jsonb_set(schema, '{columns,0,unique}', 'true')` )
1527- )
1528-
1529- expect ( change . status ) . toBe ( 'fulfilled' )
1530- expect ( job . status === 'rejected' && job . reason ) . toBeInstanceOf ( UpdatePatchRejectedError )
1531- await expect ( run ( ) ) . rejects . toBeInstanceOf ( UpdatePatchRejectedError )
1532- expect ( await countEmail ( table . id , 'same@example.test' ) ) . toBe ( TABLE_LIMITS . UPDATE_BATCH_SIZE )
1533- } )
1534-
1535- it ( 'finishes when a column it writes gains a select option mid-job' , async ( ) => {
1536- const { table, run } = await seededJob ( { status : 'Open' } )
1537-
1538- const [ job , change ] = await changeBetweenBatches ( table . id , run , ( ) =>
1539- updateColumnOptions (
1540- {
1541- tableId : table . id ,
1542- columnName : 'status' ,
1543- options : [
1544- { id : 'opt_open' , name : 'Open' } ,
1545- { id : 'opt_closed' , name : 'Closed' } ,
1546- ] ,
1547- } ,
1548- 'bulk-update'
1549- )
1550- )
1551-
1552- expect ( change . status ) . toBe ( 'fulfilled' )
1553- expect ( job . status ) . toBe ( 'fulfilled' )
1554- const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1555- FROM user_table_rows WHERE table_id = ${ table . id } AND data->>'status' = 'opt_open'`
1556- expect ( count ) . toBe ( ROWS )
1557- } )
1558-
1559- it ( 're-derives the patch for later batches once a patched column is retyped mid-job' , async ( ) => {
1560- const { table, run, retry } = await seededJob ( { note : '7' } )
1561- const notes = async ( ) =>
1562- control < { kind : string ; count : number } [ ] > `SELECT jsonb_typeof(data->'note') AS kind,
1563- count(*)::int AS count FROM user_table_rows WHERE table_id = ${ table . id }
1564- GROUP BY 1 ORDER BY 1`
1565-
1566- const [ job , change ] = await changeBetweenBatches ( table . id , run , ( ) =>
1567- changeSchemaUnderLock ( table . id , `jsonb_set(schema, '{columns,3,type}', '"number"')` )
1568- )
1569-
1570- expect ( [ job . status , change . status ] ) . toEqual ( [ 'fulfilled' , 'fulfilled' ] )
1571- expect ( await notes ( ) ) . toEqual ( [
1572- { kind : 'number' , count : ROWS - TABLE_LIMITS . UPDATE_BATCH_SIZE } ,
1573- { kind : 'string' , count : TABLE_LIMITS . UPDATE_BATCH_SIZE } ,
1574- ] )
1575- const [ { seven } ] = await control < { seven : number } [ ] > `SELECT count(*)::int AS seven
1576- FROM user_table_rows WHERE table_id = ${ table . id } AND data->'note' IN ('"7"', '7')`
1577- expect ( seven ) . toBe ( ROWS )
1578-
1579- await expect ( retry ( ) ) . resolves . toBeUndefined ( )
1580- expect ( await notes ( ) ) . toEqual ( [ { kind : 'number' , count : ROWS } ] )
1581- } )
1582-
1583- it ( 'drops a column deleted mid-job from later batches' , async ( ) => {
1584- const { table, run } = await seededJob ( { note : 'x' , email : 'y@example.test' } )
1585-
1586- const [ job , change ] = await changeBetweenBatches ( table . id , run , ( ) =>
1587- changeSchemaUnderLock ( table . id , `schema #- '{columns,3}'` )
1588- )
1589-
1590- expect ( [ job . status , change . status ] ) . toEqual ( [ 'fulfilled' , 'fulfilled' ] )
1591- expect ( await countEmail ( table . id , 'y@example.test' ) ) . toBe ( ROWS )
1592- const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1593- FROM user_table_rows WHERE table_id = ${ table . id } AND data->>'note' = 'x'`
1594- expect ( count ) . toBe ( TABLE_LIMITS . UPDATE_BATCH_SIZE )
1595- } )
1596-
1597- it ( 'refuses a batch whose re-derived patch grows a row past the size limit' , async ( ) => {
1598- const { table, run } = await seededJob ( { note : 7 } )
1599- await changeSchemaUnderLock ( table . id , `jsonb_set(schema, '{columns,3,type}', '"number"')` )
1600- const bigRowId = `${ table . id } -${ String ( TABLE_LIMITS . UPDATE_BATCH_SIZE + 1 ) . padStart ( 4 , '0' ) } `
1601- // Stored jsonb orders keys by length, so a merged row reads kind, email, filler, then note.
1602- const email = `e${ TABLE_LIMITS . UPDATE_BATCH_SIZE + 1 } @example.test`
1603- const base = Buffer . byteLength ( JSON . stringify ( { kind : 'seed' , email, filler : '' , note : 7 } ) )
1604- await control `UPDATE user_table_rows
1605- SET data = data || jsonb_build_object('filler', repeat('x', ${ getMaxRowSizeBytes ( ) - base } ))
1606- WHERE id = ${ bigRowId } `
1607-
1608- const [ job , change ] = await changeBetweenBatches ( table . id , run , ( ) =>
1609- changeSchemaUnderLock ( table . id , `jsonb_set(schema, '{columns,3,type}', '"string"')` )
1610- )
1611-
1612- expect ( change . status ) . toBe ( 'fulfilled' )
1613- expect ( job . status === 'rejected' && job . reason ) . toBeInstanceOf ( UpdatePatchRejectedError )
1614- const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1615- FROM user_table_rows WHERE table_id = ${ table . id } AND data ? 'note'`
1616- expect ( count ) . toBe ( TABLE_LIMITS . UPDATE_BATCH_SIZE )
1617- } )
1618-
1619- it ( 'writes a date patch given as an epoch number' , async ( ) => {
1620- const { table, run } = await seededJob ( { due : 1704067200000 } )
1621-
1622- await run ( )
1623-
1624- const [ { count } ] = await control < { count : number } [ ] > `SELECT count(*)::int AS count
1625- FROM user_table_rows WHERE table_id = ${ table . id }
1626- AND (data->>'due')::timestamptz = '2024-01-01T00:00:00Z'`
1627- expect ( count ) . toBe ( ROWS )
1628- } )
1629- } )
1630-
16311385 describe . skipIf ( ! migrated ) ( 'rows_version' , ( ) => {
16321386 it ( 'advances once for a transaction that edits cells across several statements' , async ( ) => {
16331387 const table = await createTable ( textColumns ( 'name' ) )
0 commit comments