@@ -23,7 +23,9 @@ import {
2323 internalTableRowsErrorPolicy ,
2424 v2TableRowsErrorPolicy ,
2525} from '@/lib/table/api/row-route-policies'
26- import { insertRow , updateRowsByFilter } from '@/lib/table/rows/service'
26+ import { getDeleteSnapshotBatchSize , TABLE_LIMITS } from '@/lib/table/constants'
27+ import { TablePartialWriteError } from '@/lib/table/errors'
28+ import { deleteRowsByFilter , insertRow , updateRowsByFilter } from '@/lib/table/rows/service'
2729import { getTableById } from '@/lib/table/service'
2830import type { TableDefinition } from '@/lib/table/types'
2931import { orchestrationErrorResponse } from '@/app/api/table/utils'
@@ -61,6 +63,26 @@ async function createTable(): Promise<TableDefinition> {
6163 return table
6264}
6365
66+ /** Seeds `count` rows whose id order and row order agree, created before any write's cutoff. */
67+ async function seedNotes ( tableId : string , count : number ) : Promise < string [ ] > {
68+ const ids = Array . from (
69+ { length : count } ,
70+ ( _ , index ) => `${ tableId } -${ String ( index ) . padStart ( 4 , '0' ) } `
71+ )
72+ const createdAt = new Date ( Date . now ( ) - 60_000 )
73+ await db . insert ( userTableRows ) . values (
74+ ids . map ( ( id , index ) => ( {
75+ id,
76+ tableId,
77+ workspaceId,
78+ data : { note : 'n' } ,
79+ orderKey : `a${ String ( index ) . padStart ( 4 , '0' ) } ` ,
80+ createdAt,
81+ } ) )
82+ )
83+ return ids
84+ }
85+
6486/** Runs `write` while a second session holds whatever `hold` locks, and returns what it threw. */
6587async function failedUnderLock (
6688 hold : ( holder : postgres . ReservedSql ) => Promise < unknown > ,
@@ -92,6 +114,13 @@ function postgresErrorContext(error: unknown): string | undefined {
92114 return undefined
93115}
94116
117+ /** Every surface leaves the failure to the route's generic 500 rather than inviting a retry. */
118+ async function expectNotRetryableOnAnySurface ( error : unknown ) {
119+ expect ( internalTableRowsErrorPolicy . project ( error ) ) . toBeNull ( )
120+ expect ( v2TableRowsErrorPolicy . render ( error ) ) . toBeNull ( )
121+ expect ( orchestrationErrorResponse ( error ) ) . toBeNull ( )
122+ }
123+
95124async function expectRetryableOnEverySurface ( error : unknown ) {
96125 const internal = internalTableRowsErrorPolicy . project ( error )
97126 expect ( internal ?. status ) . toBe ( 503 )
@@ -184,4 +213,57 @@ describe('table writes that lose a lock race', () => {
184213 expect ( row . data ) . toEqual ( { note : 'n' } )
185214 }
186215 )
216+
217+ it ( 'leaves a filtered update that loses a lock race after a committed page non-retryable' , async ( ) => {
218+ const table = await createTable ( )
219+ const ids = await seedNotes ( table . id , TABLE_LIMITS . UPDATE_BATCH_SIZE + 1 )
220+ const lastId = ids [ ids . length - 1 ]
221+
222+ const error = await failedUnderLock (
223+ ( holder ) => holder `SELECT 1 FROM user_table_rows
224+ WHERE id = ${ lastId } AND table_id = ${ table . id } AND workspace_id = ${ workspaceId }
225+ FOR UPDATE` ,
226+ ( ) =>
227+ updateRowsByFilter (
228+ table ,
229+ {
230+ filter : { note : 'n' } ,
231+ data : { note : 'patched' } ,
232+ secretProvenance : undefined ,
233+ capabilityGovernedUserId : null ,
234+ } ,
235+ 'contention-partial-update'
236+ )
237+ )
238+
239+ expect ( error ) . toBeInstanceOf ( TablePartialWriteError )
240+ expect ( error ) . toMatchObject ( { committedCount : TABLE_LIMITS . UPDATE_BATCH_SIZE } )
241+ await expectNotRetryableOnAnySurface ( error )
242+ } )
243+
244+ it ( 'leaves a limited filtered delete that loses a lock race after a committed batch non-retryable' , async ( ) => {
245+ const table = await createTable ( )
246+ const batchSize = getDeleteSnapshotBatchSize ( )
247+ const ids = await seedNotes ( table . id , batchSize + 1 )
248+ const lastId = ids [ ids . length - 1 ]
249+
250+ const error = await failedUnderLock (
251+ ( holder ) => holder `SELECT 1 FROM user_table_rows
252+ WHERE id = ${ lastId } AND table_id = ${ table . id } AND workspace_id = ${ workspaceId }
253+ FOR UPDATE` ,
254+ ( ) =>
255+ deleteRowsByFilter (
256+ table ,
257+ { filter : { note : 'n' } , limit : batchSize + 1 } ,
258+ 'contention-partial-delete'
259+ )
260+ )
261+
262+ expect ( error ) . toBeInstanceOf ( TablePartialWriteError )
263+ expect ( error ) . toMatchObject ( { committedCount : batchSize } )
264+ await expectNotRetryableOnAnySurface ( error )
265+ const [ { remaining } ] = await control < { remaining : number } [ ] > `SELECT count(*)::int AS remaining
266+ FROM user_table_rows WHERE table_id = ${ table . id } AND workspace_id = ${ workspaceId } `
267+ expect ( remaining ) . toBe ( 1 )
268+ } )
187269} )
0 commit comments