feat(workflow): add offset to Find Records node for pagination (#21484)

<img width="471" height="362" alt="Capture d’écran 2026-06-12 à 15 38
45"
src="https://github.com/user-attachments/assets/9656d3a6-6f56-4587-add6-55c0a0a32482"
/>

## Summary

The workflow Find Records (search) node previously exposed only
`objectName`, `filter`, `sort`, and `limit` (capped at
`QUERY_MAX_RECORDS` = 200), with no way to page beyond the first page of
results.

This adds an optional **Offset** to the node so a workflow can fetch an
arbitrary page (`offset = pageIndex * limit`) while keeping the same
filter and sort. The underlying `FindRecordsService` already accepts
`offset` (it forwards it to the query runner's `skip`, and stabilizes
ordering with an `id` tiebreaker), so this change just threads `offset`
through the remaining layers:

- `workflowFindRecordsActionSettingsSchema` (shared zod schema) — new
optional `offset`
- `FindRecordsInput` type — new optional `offset?: number`
- `find-records.workflow-action.ts` — forwards `offset` to
`FindRecordsService.execute`
- `WorkflowEditActionFindRecords.tsx` — new "Offset" number input
(non-negative, defaults to 0) with form state + persistence
- Default `FIND_RECORDS` step settings — `offset: 0`

### Notes / non-goals
- Offset-only, single page: the node returns one page. Looping over all
pages inside one run is not included (the Iterator action loops a static
array and cannot re-query). The node output already returns
`totalCount`, so a workflow can compute total pages as `ceil(totalCount
/ limit)`.
- Offset on very large/changing datasets can be slow or skip/duplicate
rows; cursor/keyset pagination would be a future follow-up.

## Test plan
- [x] Create a Find Records node, set Limit=50, Offset=0 → returns first
page
- [x] Set Offset=50 with the same filter/sort → returns the second page
(no overlap)
- [x] Negative offset shows a validation error and is not saved
- [x] Existing Find Records nodes (no offset stored) still run,
defaulting to offset 0
- [x] Typecheck/lint pass in CI

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21484?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas Trompette
2026-06-12 16:11:53 +02:00
committed by GitHub
parent 49026a7368
commit a8a8bbb2ed
5 changed files with 42 additions and 0 deletions
@@ -66,6 +66,7 @@ type FindRecordsFormData = {
filter?: FindRecordsActionFilter;
orderBy?: FindRecordsActionOrderBy;
limit?: number;
offset?: number;
};
export type FindRecordsActionFilter = {
@@ -100,11 +101,13 @@ export const WorkflowEditActionFindRecords = ({
action.settings.input.limit > QUERY_MAX_RECORDS
? QUERY_MAX_RECORDS
: (action.settings.input.limit ?? 1),
offset: Math.max(0, Math.floor(action.settings.input.offset ?? 0)),
filter: action.settings.input.filter as FindRecordsActionFilter,
orderBy: action.settings.input.orderBy as FindRecordsActionOrderBy,
}));
const [limitError, setLimitError] = useState<string | undefined>(undefined);
const [offsetError, setOffsetError] = useState<string | undefined>(undefined);
const isFormDisabled = actionOptions.readonly ?? false;
const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectNameSingular}`;
@@ -140,6 +143,7 @@ export const WorkflowEditActionFindRecords = ({
const {
objectNameSingular: updatedObjectName,
limit: updatedLimit,
offset: updatedOffset,
filter: updatedFilter,
orderBy: updatedOrderBy,
} = formData;
@@ -151,6 +155,7 @@ export const WorkflowEditActionFindRecords = ({
input: {
objectName: updatedObjectName,
limit: updatedLimit ?? 1,
offset: Math.max(0, Math.floor(updatedOffset ?? 0)),
filter: updatedFilter,
orderBy: updatedOrderBy as Record<string, any[]> | undefined,
},
@@ -174,6 +179,7 @@ export const WorkflowEditActionFindRecords = ({
const newFormData: FindRecordsFormData = {
objectNameSingular: value,
limit: 1,
offset: 0,
};
setFormData(newFormData);
@@ -335,6 +341,38 @@ export const WorkflowEditActionFindRecords = ({
saveAction(newFormData);
}}
/>
<FormNumberFieldInput
label={t`Offset`}
defaultValue={formData.offset}
placeholder={t`Enter offset`}
readonly={isFormDisabled}
hint={t`Number of records to skip. Combine with Limit to page through results.`}
error={offsetError}
onChange={(offset) => {
if (isFormDisabled === true || !isNumber(offset)) {
return;
}
const normalizedOffset = Math.floor(offset);
if (normalizedOffset < 0) {
setOffsetError(t`Offset cannot be negative.`);
return;
}
setOffsetError(undefined);
const newFormData: FindRecordsFormData = {
...formData,
offset: normalizedOffset,
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
@@ -38,6 +38,7 @@ export type FindRecordsInput = {
gqlOperationOrderBy?: Partial<ObjectRecordOrderBy>;
};
limit?: number;
offset?: number;
};
export type UpsertRecordInput = {
@@ -434,6 +434,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
limit: 1,
offset: 0,
},
},
},
@@ -95,6 +95,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
filter: gqlOperationFilter,
orderBy: workflowActionInput.orderBy?.gqlOperationOrderBy,
limit: workflowActionInput.limit,
offset: workflowActionInput.offset,
authContext: executionContext.authContext,
rolePermissionConfig: executionContext.rolePermissionConfig,
shouldBuildEffectiveSelectFields: false,
@@ -6,6 +6,7 @@ export const workflowFindRecordsActionSettingsSchema =
input: z.object({
objectName: z.string(),
limit: z.number().optional(),
offset: z.number().int().nonnegative().optional(),
filter: z
.object({
recordFilterGroups: z.array(z.any()).optional(),