@@ -200,8 +200,9 @@ async function dispatchUpdateJob(params: {
200200 filter : Filter
201201 data : RowData
202202 cutoff : Date
203+ maxRows ?: number
203204} ) : Promise < void > {
204- const { jobId, tableId, workspaceId, filter, data, cutoff } = params
205+ const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params
205206 if ( isTriggerDevEnabled ) {
206207 try {
207208 const [ { tableUpdateTask } , { tasks } ] = await Promise . all ( [
@@ -210,7 +211,7 @@ async function dispatchUpdateJob(params: {
210211 ] )
211212 await tasks . trigger < typeof tableUpdateTask > (
212213 'table-update' ,
213- { jobId, tableId, workspaceId, filter, data, cutoff : cutoff . toISOString ( ) } ,
214+ { jobId, tableId, workspaceId, filter, data, cutoff : cutoff . toISOString ( ) , maxRows } ,
214215 { tags : [ `tableId:${ tableId } ` , `jobId:${ jobId } ` ] }
215216 )
216217 } catch ( error ) {
@@ -219,10 +220,12 @@ async function dispatchUpdateJob(params: {
219220 }
220221 } else {
221222 runDetached ( 'table-update' , ( ) =>
222- runTableUpdate ( { jobId, tableId, workspaceId, filter, data, cutoff } ) . catch ( async ( error ) => {
223- await markTableUpdateFailed ( tableId , jobId , error )
224- throw error
225- } )
223+ runTableUpdate ( { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } ) . catch (
224+ async ( error ) => {
225+ await markTableUpdateFailed ( tableId , jobId , error )
226+ throw error
227+ }
228+ )
226229 )
227230 }
228231}
@@ -280,17 +283,16 @@ function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | unde
280283}
281284
282285/**
283- * Validates an optional row limit against the same bounds the HTTP contracts
284- * enforce. Returns an error message, or `null` when the limit is acceptable.
286+ * Validates an optional row limit. There's no upper bound the caller must respect — the model may
287+ * ask for any number. `MAX_QUERY_LIMIT` / `MAX_BULK_OPERATION_SIZE` are applied internally instead
288+ * (query_rows clamps the page; bulk ops above the bound run as a background job). Returns an error
289+ * message, or `null` when the limit is acceptable.
285290 */
286- function limitError ( limit : unknown , max : number ) : string | null {
291+ function limitError ( limit : unknown ) : string | null {
287292 if ( limit === undefined ) return null
288293 if ( typeof limit !== 'number' || ! Number . isInteger ( limit ) || limit < 1 ) {
289294 return 'Limit must be an integer of at least 1'
290295 }
291- if ( limit > max ) {
292- return `Limit cannot exceed ${ max } `
293- }
294296 return null
295297}
296298
@@ -579,7 +581,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
579581 return { success : false , message : 'Workspace ID is required' }
580582 }
581583
582- const queryLimitError = limitError ( args . limit , TABLE_LIMITS . MAX_QUERY_LIMIT )
584+ const queryLimitError = limitError ( args . limit )
583585 if ( queryLimitError ) {
584586 return { success : false , message : queryLimitError }
585587 }
@@ -592,12 +594,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
592594 const requestId = generateId ( ) . slice ( 0 , 8 )
593595 const idByName = buildIdByName ( table . schema )
594596 const nameById = buildNameById ( table . schema )
597+ // The model may request any number; we serve at most MAX_QUERY_LIMIT per page so a single
598+ // tool result can't drain a whole table. `totalCount` in the response signals truncation,
599+ // and the model pages with `offset`.
595600 const result = await queryRows (
596601 table ,
597602 {
598603 filter : args . filter ? filterNamesToIds ( args . filter , idByName ) : undefined ,
599604 sort : args . sort ? sortNamesToIds ( args . sort , idByName ) : undefined ,
600- limit : args . limit ,
605+ limit :
606+ args . limit !== undefined
607+ ? Math . min ( args . limit , TABLE_LIMITS . MAX_QUERY_LIMIT )
608+ : undefined ,
601609 offset : args . offset ,
602610 withExecutions : false ,
603611 } ,
@@ -700,7 +708,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
700708 if ( ! workspaceId ) {
701709 return { success : false , message : 'Workspace ID is required' }
702710 }
703- const updateLimitError = limitError ( args . limit , TABLE_LIMITS . MAX_BULK_OPERATION_SIZE )
711+ const updateLimitError = limitError ( args . limit )
704712 if ( updateLimitError ) {
705713 return { success : false , message : updateLimitError }
706714 }
@@ -715,29 +723,34 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
715723 const idFilter = filterNamesToIds ( args . filter , idByName )
716724 const idData = rowDataNameToId ( args . data , idByName )
717725
718- // Unbounded "update everything matching": measure the blast radius first and hand
719- // anything past the inline cap to the background update worker — same escalation as
720- // delete_rows_by_filter, so a broad update on a huge table doesn't load every matching
721- // row into this request. A patch touching a unique column stays inline (the service
722- // rejects bulk-setting a unique value across multiple rows).
726+ // Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation
727+ // (an explicit limit above the cap, or unbounded "update everything matching") runs in the
728+ // background worker so a broad update on a huge table doesn't load every matching row into
729+ // this request. A small explicit limit is the fast path — no count needed. A patch
730+ // touching a unique column always stays inline (the service rejects bulk-setting a unique
731+ // value across multiple rows).
723732 const patchTouchesUnique = table . schema . columns . some (
724733 ( c ) => c . unique === true && ( c . id ?? c . name ) in idData
725734 )
726- if ( args . limit === undefined && ! patchTouchesUnique ) {
735+ const updateInlineEligible =
736+ args . limit !== undefined && args . limit <= TABLE_LIMITS . MAX_BULK_OPERATION_SIZE
737+ if ( ! updateInlineEligible && ! patchTouchesUnique ) {
727738 const { totalCount } = await queryRows (
728739 table ,
729740 { filter : idFilter , limit : 1 , withExecutions : false } ,
730741 requestId
731742 )
732743 const matchCount = totalCount ?? 0
733- if ( matchCount > TABLE_LIMITS . MAX_BULK_OPERATION_SIZE ) {
744+ const target = args . limit !== undefined ? Math . min ( args . limit , matchCount ) : matchCount
745+ if ( target > TABLE_LIMITS . MAX_BULK_OPERATION_SIZE ) {
734746 const cutoff = new Date ( )
735747 const jobId = generateId ( )
736748 const payload : TableUpdateJobPayload = {
737749 filter : idFilter ,
738750 data : idData ,
739751 cutoff : cutoff . toISOString ( ) ,
740- affectedCount : matchCount ,
752+ affectedCount : target ,
753+ maxRows : args . limit ,
741754 }
742755 assertNotAborted ( )
743756 const claimed = await markTableJobRunning ( table . id , jobId , 'update' , payload )
@@ -751,11 +764,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
751764 filter : idFilter ,
752765 data : idData ,
753766 cutoff,
767+ maxRows : args . limit ,
754768 } )
755769 return {
756770 success : true ,
757- message : `Started background update of ${ matchCount } matching rows (job ${ jobId } ). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.` ,
758- data : { jobId, affectedCount : matchCount } ,
771+ message : `Started background update of ${ target } matching rows (job ${ jobId } ). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.` ,
772+ data : { jobId, affectedCount : target } ,
759773 }
760774 }
761775 }
@@ -789,7 +803,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
789803 if ( ! workspaceId ) {
790804 return { success : false , message : 'Workspace ID is required' }
791805 }
792- const deleteLimitError = limitError ( args . limit , TABLE_LIMITS . MAX_BULK_OPERATION_SIZE )
806+ const deleteLimitError = limitError ( args . limit )
793807 if ( deleteLimitError ) {
794808 return { success : false , message : deleteLimitError }
795809 }
@@ -803,10 +817,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
803817 const idByName = buildIdByName ( table . schema )
804818 const idFilter = filterNamesToIds ( args . filter , idByName )
805819
806- // Unbounded "delete everything matching": measure the blast radius
807- // first, and hand anything past the inline cap to the background
808- // delete worker (same path as the UI's select-all delete) instead of
809- // loading every matching row id into this request.
820+ // An explicit limit runs inline (delete loads only row ids, so even a large bounded
821+ // delete is light). Only an unbounded "delete everything matching" measures the blast
822+ // radius and hands off to the background delete worker (same path as the UI's select-all
823+ // delete) — the read-path mask hides exactly the all-matching set, which a bounded delete
824+ // would over-hide.
810825 if ( args . limit === undefined ) {
811826 const { totalCount } = await queryRows (
812827 table ,
0 commit comments