Skip to content

Commit 2d8350e

Browse files
feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189)
* feat(api): add search, filtering, and sorting to the v2 list endpoints One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts: `search` (case-insensitive substring on the resource's natural name field), `sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2 knowledge-documents already ship rather than inventing a third dialect alongside the Logs filters and the Tables predicate grammar. Every filter and sort is pushed into SQL. GET /api/v2/files previously read the whole scope and sorted/sliced it in JS; it now goes through a new queryWorkspaceFiles that filters, orders, and bounds the page in one query. Cursors are stamped with the sort they were minted under, so replaying one under a different sort is a 400 instead of silently duplicated or skipped rows. * fix(api): validate v2 cursor key values and compare timestamps at ms precision Two review findings, fixed at the root by making a keyset key own its cursor codec instead of hand-writing a decoder per sort. Cursor key values are caller-controlled, and matching the sort stamp and key count was not enough: an unparseable timestamp or a non-numeric size reached the query as an Invalid Date or NaN and surfaced as a 500. Each key now type- checks its own value and rejects a cursor it cannot hold, which both routes render as the documented 400. Timestamp keys now order and compare on date_trunc('milliseconds', col). Postgres keeps microseconds and defaultNow() populates them, but a cursor value round-trips through a millisecond-only JS Date — comparing the raw column against the truncated value re-admitted the page's own last row, duplicating it and stalling pagination outright at a page size of one. Reachable today via workspace_files.updated_at, which insertFileMetadata leaves to defaultNow().
1 parent 5df4c75 commit 2d8350e

48 files changed

Lines changed: 2612 additions & 218 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-files-audit.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,38 @@
7979
},
8080
{
8181
"$ref": "#/components/parameters/Cursor"
82+
},
83+
{
84+
"name": "folderId",
85+
"in": "query",
86+
"required": false,
87+
"description": "Restrict the list to one folder. Omit to list every file in the workspace.",
88+
"schema": { "type": "string", "minLength": 1 }
89+
},
90+
{
91+
"name": "search",
92+
"in": "query",
93+
"required": false,
94+
"description": "Case-insensitive substring match against the file `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
95+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
96+
},
97+
{
98+
"name": "sortBy",
99+
"in": "query",
100+
"required": false,
101+
"description": "Field to sort by. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
102+
"schema": {
103+
"type": "string",
104+
"enum": ["name", "size", "uploadedAt", "updatedAt"],
105+
"default": "uploadedAt"
106+
}
107+
},
108+
{
109+
"name": "sortOrder",
110+
"in": "query",
111+
"required": false,
112+
"description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
113+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" }
82114
}
83115
],
84116
"responses": {

apps/docs/openapi-v2-knowledge.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,38 @@
4848
"parameters": [
4949
{
5050
"$ref": "#/components/parameters/WorkspaceIdQuery"
51+
},
52+
{
53+
"name": "folderId",
54+
"in": "query",
55+
"required": false,
56+
"description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.",
57+
"schema": { "type": "string", "minLength": 1 }
58+
},
59+
{
60+
"name": "search",
61+
"in": "query",
62+
"required": false,
63+
"description": "Case-insensitive substring match against the knowledge base `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
64+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
65+
},
66+
{
67+
"name": "sortBy",
68+
"in": "query",
69+
"required": false,
70+
"description": "Field to sort by.",
71+
"schema": {
72+
"type": "string",
73+
"enum": ["name", "createdAt", "updatedAt"],
74+
"default": "createdAt"
75+
}
76+
},
77+
{
78+
"name": "sortOrder",
79+
"in": "query",
80+
"required": false,
81+
"description": "Sort direction.",
82+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" }
5183
}
5284
],
5385
"responses": {

apps/docs/openapi-v2-resources.json

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,34 @@
6161
"source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
6262
}
6363
],
64-
"parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }],
64+
"parameters": [
65+
{ "$ref": "#/components/parameters/WorkspaceIdQuery" },
66+
{
67+
"name": "search",
68+
"in": "query",
69+
"required": false,
70+
"description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
71+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
72+
},
73+
{
74+
"name": "sortBy",
75+
"in": "query",
76+
"required": false,
77+
"description": "Field to sort by.",
78+
"schema": {
79+
"type": "string",
80+
"enum": ["name", "createdAt", "updatedAt"],
81+
"default": "createdAt"
82+
}
83+
},
84+
{
85+
"name": "sortOrder",
86+
"in": "query",
87+
"required": false,
88+
"description": "Sort direction.",
89+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" }
90+
}
91+
],
6592
"responses": {
6693
"200": {
6794
"description": "MCP servers registered in the workspace.",
@@ -405,7 +432,34 @@
405432
"source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
406433
}
407434
],
408-
"parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }],
435+
"parameters": [
436+
{ "$ref": "#/components/parameters/WorkspaceIdQuery" },
437+
{
438+
"name": "search",
439+
"in": "query",
440+
"required": false,
441+
"description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
442+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
443+
},
444+
{
445+
"name": "sortBy",
446+
"in": "query",
447+
"required": false,
448+
"description": "Field to sort by. Built-in skills have no stored timestamps and sort as if created at the Unix epoch.",
449+
"schema": {
450+
"type": "string",
451+
"enum": ["name", "createdAt", "updatedAt"],
452+
"default": "createdAt"
453+
}
454+
},
455+
{
456+
"name": "sortOrder",
457+
"in": "query",
458+
"required": false,
459+
"description": "Sort direction.",
460+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" }
461+
}
462+
],
409463
"responses": {
410464
"200": {
411465
"description": "Skills available in the workspace.",
@@ -706,7 +760,34 @@
706760
"source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
707761
}
708762
],
709-
"parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }],
763+
"parameters": [
764+
{ "$ref": "#/components/parameters/WorkspaceIdQuery" },
765+
{
766+
"name": "search",
767+
"in": "query",
768+
"required": false,
769+
"description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
770+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
771+
},
772+
{
773+
"name": "sortBy",
774+
"in": "query",
775+
"required": false,
776+
"description": "Field to sort by.",
777+
"schema": {
778+
"type": "string",
779+
"enum": ["title", "createdAt", "updatedAt"],
780+
"default": "createdAt"
781+
}
782+
},
783+
{
784+
"name": "sortOrder",
785+
"in": "query",
786+
"required": false,
787+
"description": "Sort direction.",
788+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" }
789+
}
790+
],
710791
"responses": {
711792
"200": {
712793
"description": "Custom tools defined in the workspace.",
@@ -1057,6 +1138,31 @@
10571138
"required": false,
10581139
"description": "`active` (default) lists live folders; `archived` lists Recently Deleted.",
10591140
"schema": { "type": "string", "enum": ["active", "archived"], "default": "active" }
1141+
},
1142+
{
1143+
"name": "search",
1144+
"in": "query",
1145+
"required": false,
1146+
"description": "Case-insensitive substring match against the folder `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
1147+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
1148+
},
1149+
{
1150+
"name": "sortBy",
1151+
"in": "query",
1152+
"required": false,
1153+
"description": "Field to sort by. `position` is the tree's own manual arrangement, which is the default order.",
1154+
"schema": {
1155+
"type": "string",
1156+
"enum": ["position", "name", "createdAt", "updatedAt"],
1157+
"default": "position"
1158+
}
1159+
},
1160+
{
1161+
"name": "sortOrder",
1162+
"in": "query",
1163+
"required": false,
1164+
"description": "Sort direction.",
1165+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" }
10601166
}
10611167
],
10621168
"responses": {
@@ -1407,6 +1513,31 @@
14071513
"required": false,
14081514
"description": "Only return credentials for this integration.",
14091515
"schema": { "type": "string", "minLength": 1, "example": "slack" }
1516+
},
1517+
{
1518+
"name": "search",
1519+
"in": "query",
1520+
"required": false,
1521+
"description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
1522+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
1523+
},
1524+
{
1525+
"name": "sortBy",
1526+
"in": "query",
1527+
"required": false,
1528+
"description": "Field to sort by.",
1529+
"schema": {
1530+
"type": "string",
1531+
"enum": ["displayName", "createdAt", "updatedAt"],
1532+
"default": "createdAt"
1533+
}
1534+
},
1535+
{
1536+
"name": "sortOrder",
1537+
"in": "query",
1538+
"required": false,
1539+
"description": "Sort direction.",
1540+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" }
14101541
}
14111542
],
14121543
"responses": {

apps/docs/openapi-v2-tables.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,38 @@
4949
"parameters": [
5050
{
5151
"$ref": "#/components/parameters/WorkspaceIdQuery"
52+
},
53+
{
54+
"name": "folderId",
55+
"in": "query",
56+
"required": false,
57+
"description": "Restrict the list to one folder. Omit to list every table in the workspace.",
58+
"schema": { "type": "string", "minLength": 1 }
59+
},
60+
{
61+
"name": "search",
62+
"in": "query",
63+
"required": false,
64+
"description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
65+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
66+
},
67+
{
68+
"name": "sortBy",
69+
"in": "query",
70+
"required": false,
71+
"description": "Field to sort by.",
72+
"schema": {
73+
"type": "string",
74+
"enum": ["name", "createdAt", "updatedAt"],
75+
"default": "createdAt"
76+
}
77+
},
78+
{
79+
"name": "sortOrder",
80+
"in": "query",
81+
"required": false,
82+
"description": "Sort direction.",
83+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" }
5284
}
5385
],
5486
"responses": {

apps/docs/openapi-v2-workflows.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,31 @@
9090
"schema": {
9191
"type": "string"
9292
}
93+
},
94+
{
95+
"name": "search",
96+
"in": "query",
97+
"required": false,
98+
"description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
99+
"schema": { "type": "string", "minLength": 1, "maxLength": 200 }
100+
},
101+
{
102+
"name": "sortBy",
103+
"in": "query",
104+
"required": false,
105+
"description": "Field to sort by. `position` is the workspace's own manual arrangement of its workflows, which is the default order. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
106+
"schema": {
107+
"type": "string",
108+
"enum": ["position", "name", "createdAt", "updatedAt", "runCount"],
109+
"default": "position"
110+
}
111+
},
112+
{
113+
"name": "sortOrder",
114+
"in": "query",
115+
"required": false,
116+
"description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
117+
"schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" }
93118
}
94119
],
95120
"responses": {

apps/sim/app/api/v2/credentials/route.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,33 @@ describe('GET /api/v2/credentials', () => {
204204
expect.objectContaining({ type: 'oauth', providerId: 'slack' })
205205
)
206206
})
207+
it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
208+
const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`)
209+
210+
expect(res.status).toBe(400)
211+
expect((await res.json()).error.code).toBe('BAD_REQUEST')
212+
})
213+
214+
it('400s on a sort direction outside the enum', async () => {
215+
const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`)
216+
217+
expect(res.status).toBe(400)
218+
})
219+
220+
it('400s on an empty search rather than treating it as unsearched', async () => {
221+
const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`)
222+
223+
expect(res.status).toBe(400)
224+
})
225+
226+
it('forwards search and sort into the query and still terminates pagination', async () => {
227+
const res = await callList(
228+
`workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc`
229+
)
230+
231+
expect(res.status).toBe(200)
232+
expect((await res.json()).nextCursor).toBeNull()
233+
})
207234
})
208235

209236
describe('POST /api/v2/credentials', () => {

apps/sim/app/api/v2/credentials/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5454
)
5555
if (!parsed.success) return parsed.response
5656

57-
const { workspaceId, type, providerId } = parsed.data.query
57+
const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query
5858

5959
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
6060
if (access) return v2WorkspaceAccessError(access)
@@ -71,6 +71,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7171
workspaceAccess,
7272
type,
7373
providerId,
74+
search,
75+
sortBy,
76+
sortOrder,
7477
})
7578

7679
// The per-workspace credential set is small and bounded → a single full page.

0 commit comments

Comments
 (0)