feat(API): Remove offset query param from list workflow history endpoint (#36940)

This commit is contained in:
Sam Wooler
2026-08-24 14:42:51 +00:00
committed by GitHub
parent 12a2e38817
commit 704fd6d177
5 changed files with 22 additions and 29 deletions
+7 -3
View File
@@ -133,11 +133,15 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
## List endpoints (cursor pagination)
Copy the cursor flow from `tags.public.controller.ts`. Use `publicApiPaginationSchema`
plus `decodeCursor` / `encodeNextCursor` from the shared pagination service; the
Copy the cursor flow from `tags.public.controller.ts`. The input DTO takes
`limit: publicApiPaginationSchema.limit` plus `cursor: z.string().optional()`
pick `limit` off the schema, never spread the whole `publicApiPaginationSchema`
(it also exports `offset`, which must never be a Public API query param). Use
`decodeCursor` / `encodeNextCursor` from the shared pagination service; the
cursor is opaque; return `{ data, nextCursor }` (never a bare array) with
`nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve an
existing endpoint's pagination as-is. Detail:
existing endpoint's cursor semantics as-is — but an `offset` param is a
defect to remove, not a contract to preserve. Detail:
[List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination).
## Wiring checklist
+14 -4
View File
@@ -13,18 +13,28 @@ Copy the working flow from `v1/controllers/tags.public.controller.ts` (and
`workflows.public.controller.ts` for a `@Param` list) rather than pasting a
snippet here — a copy would drift. The moving parts:
- Input DTO composes `publicApiPaginationSchema` from `@n8n/api-types` (a `limit`
plus an opaque `cursor`).
- Input DTO takes `limit: publicApiPaginationSchema.limit` plus an opaque
`cursor: z.string().optional()` — cherry-pick `limit` but never spread the whole
`publicApiPaginationSchema`. That schema also exports `offset`, used by
internal-API-style page params elsewhere; a Public API list DTO must never
expose it as a query param. See `ListTagsQueryDto` for the shape to copy.
- `decodeCursor` / `encodeNextCursor` live in
`v1/shared/services/pagination.service.ts`. Decode the incoming cursor to
`{ offset, limit }`, guard the decoded shape, and pass `offset`/`limit` to the
service.
service`offset` is an internal implementation detail of the cursor here,
never a client-facing query param.
- Treat the cursor as opaque; never hand-encode a token.
- Return an envelope `{ data, nextCursor }` — never a bare array.
- `encodeNextCursor(...)` returns `null` when there is no further page; surface
that as `nextCursor: null`.
- An invalid/undecodable cursor is a `400` via the existing bad-request error.
- For an existing list endpoint, keep its current pagination semantics unchanged.
- For an existing list endpoint, keep its current *cursor* semantics unchanged.
A leaked `offset` query param (DTO spreading `publicApiPaginationSchema`
instead of picking `limit`) is a defect to remove, not a contract to
preserve — decorator-routed DTOs validate via a plain `z.object()`, which
silently strips unknown query keys rather than rejecting them, so removing
`offset` from the DTO makes it inert rather than erroring for existing
callers.
The output DTO wraps the list as `{ data, nextCursor }` and is declared with
`@ApiResponse(...)` so the registry strips undeclared fields.
@@ -4,6 +4,6 @@ import { Z } from '../../zod-class';
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
export class ListWorkflowHistoryQueryDto extends Z.class({
...publicApiPaginationSchema,
limit: publicApiPaginationSchema.limit,
cursor: z.string().optional(),
}) {}
@@ -5,7 +5,6 @@ summary: Retrieve workflow version history
description: Returns a paginated list of workflow versions (version IDs and metadata) for a workflow.
x-required-scope: workflow:read
parameters:
- $ref: ../../../../shared/spec/parameters/offset.yml
- $ref: ../../../../shared/spec/parameters/limit.yml
- $ref: ../../../../shared/spec/parameters/cursor.yml
- schema:
@@ -1210,26 +1210,6 @@ describe('GET /workflows/:id/history', () => {
expect(thirdPage.body.nextCursor).toBeNull();
});
test('should paginate with limit and offset', async () => {
const workflow = await createWorkflow({}, owner);
await createManyWorkflowHistoryItems(workflow.id, 5);
const firstPage = await authOwnerAgent.get(`/workflows/${workflow.id}/history`).query({
limit: '2',
offset: '0',
});
expect(firstPage.statusCode).toBe(200);
expect(firstPage.body.data).toHaveLength(2);
const secondPage = await authOwnerAgent.get(`/workflows/${workflow.id}/history`).query({
limit: '2',
offset: '2',
});
expect(secondPage.statusCode).toBe(200);
expect(secondPage.body.data).toHaveLength(2);
expect(secondPage.body.data[0].versionId).not.toBe(firstPage.body.data[0].versionId);
});
test('should retrieve history for non-owned workflow when owner', async () => {
const workflow = await createWorkflow({}, member);
await createWorkflowHistoryItem(workflow.id, { name: 'Member Version' });