diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 81df2b36c50..476d289159e 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -79,6 +79,38 @@ }, { "$ref": "#/components/parameters/Cursor" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every file in the workspace.", + "schema": { "type": "string", "minLength": 1 } + }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "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.", + "schema": { + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 806bbbccd83..38a6d2ed2f5 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -48,6 +48,38 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.", + "schema": { "type": "string", "minLength": 1 } + }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index d2683950f38..f7a6117a2c9 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -61,7 +61,34 @@ "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], "responses": { "200": { "description": "MCP servers registered in the workspace.", @@ -405,7 +432,34 @@ "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. Built-in skills have no stored timestamps and sort as if created at the Unix epoch.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], "responses": { "200": { "description": "Skills available in the workspace.", @@ -706,7 +760,34 @@ "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["title", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], "responses": { "200": { "description": "Custom tools defined in the workspace.", @@ -1057,6 +1138,31 @@ "required": false, "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. `position` is the tree's own manual arrangement, which is the default order.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { @@ -1407,6 +1513,31 @@ "required": false, "description": "Only return credentials for this integration.", "schema": { "type": "string", "minLength": 1, "example": "slack" } + }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["displayName", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3f50df8b4b0..00f523d23eb 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -49,6 +49,38 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every table in the workspace.", + "schema": { "type": "string", "minLength": 1 } + }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 95b85a369f2..0f7cb0a95b6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -90,6 +90,31 @@ "schema": { "type": "string" } + }, + { + "name": "search", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "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.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt", "runCount"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "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.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index d9fa30535a9..2bfe1cbfad4 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -204,6 +204,33 @@ describe('GET /api/v2/credentials', () => { expect.objectContaining({ type: 'oauth', providerId: 'slack' }) ) }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList( + `workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) }) describe('POST /api/v2/credentials', () => { diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 232110187c1..2b710b275bf 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -54,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, type, providerId } = parsed.data.query + const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -71,6 +71,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceAccess, type, providerId, + search, + sortBy, + sortOrder, }) // The per-workspace credential set is small and bounded → a single full page. diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index 5693e018448..7ca46e81b0c 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -82,6 +82,13 @@ function buildTool(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) @@ -162,7 +169,35 @@ describe('GET /api/v2/custom-tools', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=title&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index b746b285cc2..96b678a5078 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -52,12 +52,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const rows = await listWorkspaceCustomTools({ workspaceId }) + const rows = await listWorkspaceCustomTools({ workspaceId, search, sortBy, sortOrder }) // The per-workspace tool set is small and bounded → a single full page. return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 270661356a4..c6e68913959 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, mockResolveWorkspaceAccess, - mockListWorkspaceFiles, + mockQueryWorkspaceFiles, mockUploadWorkspaceFile, mockGetWorkspaceFile, mockReadFormDataWithLimit, @@ -18,7 +18,7 @@ const { } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), - mockListWorkspaceFiles: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), mockUploadWorkspaceFile: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockReadFormDataWithLimit: vi.fn(), @@ -35,7 +35,7 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, + queryWorkspaceFiles: mockQueryWorkspaceFiles, uploadWorkspaceFile: mockUploadWorkspaceFile, getWorkspaceFile: mockGetWorkspaceFile, FileConflictError: class FileConflictError extends Error {}, @@ -94,6 +94,17 @@ function buildRecord(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + scope: 'active', + folderId: undefined, + search: undefined, + sortBy: 'uploadedAt', + sortOrder: 'asc', + limit: 100, + after: undefined, +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) @@ -111,7 +122,7 @@ describe('GET /api/v2/files', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListWorkspaceFiles.mockResolvedValue([buildRecord()]) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -123,20 +134,20 @@ describe('GET /api/v2/files', () => { expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('400s when workspaceId is missing', async () => { const res = await callList('limit=10') expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('400s on a scope outside the enum', async () => { const res = await callList(`workspaceId=${WS}&scope=everything`) expect(res.status).toBe(400) - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('surfaces an access-denied failure in the v2 error envelope', async () => { @@ -147,7 +158,7 @@ describe('GET /api/v2/files', () => { }) const res = await callList(`workspaceId=${WS}`) expect(res.status).toBe(403) - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { @@ -158,9 +169,10 @@ describe('GET /api/v2/files', () => { }) it('returns the public file shape including folder and updatedAt', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }), - ]) + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], + nextKeys: null, + }) const res = await callList(`workspaceId=${WS}`) const body = await res.json() @@ -181,22 +193,121 @@ describe('GET /api/v2/files', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) }) it('defaults to the active scope and passes archived through', async () => { await callList(`workspaceId=${WS}`) - expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) - mockListWorkspaceFiles.mockResolvedValue([archived]) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [archived], nextKeys: null }) const res = await callList(`workspaceId=${WS}&scope=archived`) const body = await res.json() - expect(mockListWorkspaceFiles).toHaveBeenLastCalledWith(WS, { scope: 'archived' }) + expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + scope: 'archived', + }) expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + await callList( + `workspaceId=${WS}&search=report&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + ) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: FOLDER_ID, + search: 'report', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + it('400s on a sort field outside the enum instead of passing it toward the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('emits a cursor stamped with the sort and resumes from its keys', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord()], + nextKeys: ['data.csv', 'wf_1'], + }) + + const first = await callList(`workspaceId=${WS}&sortBy=name`) + const { nextCursor } = await first.json() + expect(nextCursor).not.toBeNull() + + await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`) + + expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + sortBy: 'name', + after: ['data.csv', 'wf_1'], + }) + }) + + it('400s when a cursor is replayed under a different sort', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord()], + nextKeys: ['data.csv', 'wf_1'], + }) + + const first = await callList(`workspaceId=${WS}&sortBy=name`) + const { nextCursor } = await first.json() + mockQueryWorkspaceFiles.mockClear() + + const res = await callList( + `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toMatch(/cursor does not match/i) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on a malformed cursor instead of silently restarting from page one', async () => { + const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) + + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s when the cursor carries values the sort cannot hold', async () => { + mockQueryWorkspaceFiles.mockRejectedValue( + new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.') + ) + const cursor = Buffer.from( + JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] }) + ).toString('base64') + + const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('terminates pagination when the query reports no further keys', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + + const res = await callList(`workspaceId=${WS}&search=data`) + + expect((await res.json()).nextCursor).toBeNull() + }) }) describe('POST /api/v2/files', () => { diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index fb0a0cb8bce..2c088b8b611 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -16,17 +16,19 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceFile, - listWorkspaceFiles, + queryWorkspaceFiles, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CaughtOrchestrationError, v2CursorList, + v2CursorSortError, v2Data, v2Error, v2RateLimitError, @@ -42,28 +44,16 @@ export const revalidate = 0 const MAX_FILE_SIZE = 100 * 1024 * 1024 const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -interface FileCursor { - uploadedAt: string - id: string -} - -/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ -function compareFiles(a: V2File, b: V2File): number { - if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 - if (a.id !== b.id) return a.id < b.id ? -1 : 1 - return 0 -} - /** - * GET /api/v2/files — List files in a workspace with cursor pagination. + * GET /api/v2/files — List files in a workspace with search, sort, and cursor + * pagination. * * `scope=archived` reads Recently Deleted, which is what makes the restore * endpoints usable — a caller can find the id of something it deleted. * - * The shared {@link listWorkspaceFiles} manager returns the full set for the - * requested scope ordered by `uploadedAt`; v2 applies a bounded keyset slice - * over that result in the route. Pushing `limit`/`cursor` down into the manager - * query is a follow-up, and `scope` makes it more valuable, not less. + * Filtering, ordering, and the page slice all run inside + * {@link queryWorkspaceFiles}' query. The route only translates the validated + * params and the opaque cursor, so a `search` never costs a full-workspace read. */ export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -85,32 +75,35 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, scope, limit, cursor } = parsed.data.query + const { workspaceId, scope, folderId, search, sortBy, sortOrder, limit, cursor } = + parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const items: V2File[] = files.map(toV2File).sort(compareFiles) - - const decoded = cursor ? decodeCursor(cursor) : null - const afterCursor = decoded - ? items.filter( - (f) => - f.uploadedAt > decoded.uploadedAt || - (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) - ) - : items + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { + scope, + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) - const hasMore = afterCursor.length > limit - const page = afterCursor.slice(0, limit) - const last = page.at(-1) - const nextCursor = - hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + const items: V2File[] = files.map(toV2File) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - return v2CursorList(page, nextCursor, { rateLimit }) + return v2CursorList(items, nextCursor, { rateLimit }) } catch (error) { + // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') } diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts index 85399484b5b..4f2622ee903 100644 --- a/apps/sim/app/api/v2/folders/route.test.ts +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -95,6 +95,13 @@ function buildRow(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'position', + sortOrder: 'asc', +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) @@ -189,12 +196,49 @@ describe('GET /api/v2/folders', () => { deletedAt: null, }, ]) - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'active', 'workflow') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + 'workspace-1', + 'active', + 'workflow', + DEFAULT_LIST_ARGS + ) }) it('passes the archived scope through', async () => { await callList('workspaceId=workspace-1&resourceType=table&scope=archived') - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'archived', 'table') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + 'workspace-1', + 'archived', + 'table', + DEFAULT_LIST_ARGS + ) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&resourceType=workflow&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList( + `workspaceId=workspace-1&resourceType=workflow&search=report&sortBy=name&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts index 2ce758499b4..ed74572a617 100644 --- a/apps/sim/app/api/v2/folders/route.ts +++ b/apps/sim/app/api/v2/folders/route.ts @@ -47,12 +47,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, resourceType, scope } = parsed.data.query + const { workspaceId, resourceType, scope, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) + const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType, { + search, + sortBy, + sortOrder, + }) // One workspace's tree for one resource type is bounded → a single full page. return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts new file mode 100644 index 00000000000..2b6d8f635a6 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + * + * Public v2 knowledge-base list: the search/filter/sort convention reaching the + * lib rather than being applied over its result. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetKnowledgeBases } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetKnowledgeBases: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mockGetKnowledgeBases, +})) + +vi.mock('@/lib/knowledge/orchestration', () => ({ + performCreateKnowledgeBase: vi.fn(), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/knowledge/route' + +const WS = 'workspace-1' +const FOLDER_ID = 'fold_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + folderId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'asc', +} + +function buildKnowledgeBase(overrides: Record = {}) { + return { + id: 'kb_1', + userId: 'user-1', + name: 'Support docs', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, + workspaceId: WS, + folderId: null, + docCount: 2, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/knowledge?${query}`)) + +describe('GET /api/v2/knowledge', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()]) + }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + const res = await callList( + `workspaceId=${WS}&search=support&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + ) + + expect(res.status).toBe(200) + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { + folderId: FOLDER_ID, + search: 'support', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + it('defaults to the createdAt ordering when no sort is requested', async () => { + await callList(`workspaceId=${WS}`) + + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS) + }) + + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + + expect(res.status).toBe(400) + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('terminates pagination with a filter applied', async () => { + const res = await callList(`workspaceId=${WS}&search=support`) + + expect((await res.json()).nextCursor).toBeNull() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 64e6445687b..01811a343f5 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -51,12 +51,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { + folderId, + search, + sortBy, + sortOrder, + }) const items = knowledgeBases.map(formatKnowledgeBase) // `getKnowledgeBases` returns the full bounded workspace set → single page. diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 3bdc2b90b91..f226e77a345 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' +import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' @@ -157,6 +158,60 @@ export function decodeCursor>(cursor: string): T | n } } +/** + * The sort a keyset cursor was minted under, as it is written into the cursor + * payload. Comparing the whole string is what makes a mid-pagination sort + * change detectable. + */ +export function cursorSortKey(sortBy: string, sortOrder: string): string { + return `${sortBy}:${sortOrder}` +} + +interface SortedCursorPayload { + sort: string + keys: CursorKey[] +} + +/** + * A keyset cursor stamped with the sort that produced it. The keys are only + * meaningful under that exact ordering, so the stamp travels with them. + */ +export function encodeSortedCursor(sort: string, keys: CursorKey[]): string { + return encodeCursor({ sort, keys } satisfies SortedCursorPayload) +} + +export type DecodedSortedCursor = + | { status: 'absent' } + | { status: 'ok'; keys: CursorKey[] } + /** Malformed, or minted under a different sort — the page cannot be resumed. */ + | { status: 'invalid' } + +/** + * Reads a keyset cursor back, refusing one that does not belong to the + * requested sort. Resuming a `name`-ordered cursor under `createdAt` would + * compare the wrong column and silently duplicate or skip rows, so a mismatch + * is a client error rather than a best-effort page. A cursor that isn't valid + * base64-JSON is rejected for the same reason: ignoring it would restart from + * page one while the caller believes it is paging forward. + * + * This checks the envelope only. The key VALUES are caller-controlled too, and + * are type-checked against the sort's keys by `keysetAfter`, which is where a + * bad arity or an unparseable timestamp is caught. + */ +export function decodeSortedCursor(cursor: string | undefined, sort: string): DecodedSortedCursor { + if (!cursor) return { status: 'absent' } + const decoded = decodeCursor>(cursor) + if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) { + return { status: 'invalid' } + } + return { status: 'ok', keys: decoded.keys } +} + +/** The 400 for a cursor that cannot be resumed under the request's sort. */ +export function v2CursorSortError(): NextResponse { + return v2Error('BAD_REQUEST', INVALID_CURSOR_MESSAGE) +} + const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { validation: 'BAD_REQUEST', unauthorized: 'UNAUTHORIZED', diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts index f9893f60c8e..cb0df3ac683 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -100,6 +100,13 @@ function callCreate(body: unknown) { ) } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + const VALID_BODY = { workspaceId: 'workspace-1', name: 'Docs server', @@ -187,7 +194,10 @@ describe('GET /api/v2/mcp-servers', () => { hasOauthClientSecret: false, }, ]) - expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) }) it('never returns configured header values', async () => { @@ -197,6 +207,31 @@ describe('GET /api/v2/mcp-servers', () => { expect(raw).not.toContain('super-secret-token') expect(raw).not.toContain('"headers":') }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) }) describe('POST /api/v2/mcp-servers', () => { diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 73a18037501..e9a32f7251f 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -55,12 +55,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const rows = await listWorkspaceMcpServers({ workspaceId }) + const rows = await listWorkspaceMcpServers({ workspaceId, search, sortBy, sortOrder }) // The per-workspace server set is small and bounded → a single full page. return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 6cf0ae6f52f..8e1c5131c2e 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -147,7 +147,36 @@ describe('GET /api/v2/skills', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListSkills).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + search: undefined, + sort: { sortBy: 'createdAt', sortOrder: 'desc' }, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 5e1d6a825b2..1541ca2be7f 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -47,12 +47,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const skills = await listSkills({ workspaceId }) + const skills = await listSkills({ workspaceId, search, sort: { sortBy, sortOrder } }) // The per-workspace skill set is small and bounded → a single full page. return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index af7a12f403a..f13c00e3027 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -136,4 +136,29 @@ describe('GET /api/v2/tables', () => { expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 85df923214c..1fcafeff9fd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -49,12 +49,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const tables = await listTables(workspaceId) + const tables = await listTables(workspaceId, { folderId, search, sortBy, sortOrder }) const items = tables.map(toApiTable) // `listTables` returns the full bounded workspace set → single page. diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts new file mode 100644 index 00000000000..5c2b922e40b --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + * + * Public v2 workflow list: the search/sort/filter convention, and the keyset + * cursor's binding to the sort it was minted under. The assertions look at the + * WHERE/ORDER BY the route hands drizzle, because that is the whole point of + * the change — a search must narrow the query, not the result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + name: 'Daily digest', + description: null, + folderId: null, + workspaceId: WS, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`)) + +/** The condition nodes the route passed to `.where()` on the last query. */ +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +/** + * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw + * column, so the mocked `sql` fragment carries the column in its interpolated + * values rather than being the column itself. + */ +const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0] + +describe('GET /api/v2/workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + it('narrows the query with a case-insensitive substring match on the name', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + const res = await callList(`workspaceId=${WS}&search=digest`) + + expect(res.status).toBe(200) + const search = lastConditions().find((c) => c.type === 'ilike') + expect(search).toMatchObject({ column: schemaMock.workflow.name, pattern: '%digest%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + queueTableRows(schemaMock.workflow, []) + + await callList(`workspaceId=${WS}&search=${encodeURIComponent('100%_x')}`) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%100\\%\\_x%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}`) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('defaults to the workspace position ordering', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}`) + + const orderBy = lastOrderBy() + expect(orderBy.map((e: { type: string }) => e.type)).toEqual(['asc', 'asc', 'asc']) + expect(orderBy[0].column).toBe(schemaMock.workflow.sortOrder) + expect(truncatedColumnOf(orderBy[1])).toBe(schemaMock.workflow.createdAt) + expect(orderBy[2].column).toBe(schemaMock.workflow.id) + }) + + it('orders by the requested field and direction', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}&sortBy=name&sortOrder=desc`) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workflow.name }, + { type: 'desc', column: schemaMock.workflow.id }, + ]) + }) + + it('combines a filter with a cursor into one consistent page', async () => { + queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2', name: 'Zebra' })]) + + const first = await callList(`workspaceId=${WS}&search=a&sortBy=name&limit=1`) + const body = await first.json() + + expect(body.data).toHaveLength(1) + expect(body.nextCursor).not.toBeNull() + + queueTableRows(schemaMock.workflow, [buildRow({ id: 'wf_2', name: 'Zebra' })]) + const second = await callList( + `workspaceId=${WS}&search=a&sortBy=name&limit=1&cursor=${encodeURIComponent(body.nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = lastConditions() + // The filter survives the cursor page, and the keyset resumes from the last row. + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%a%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + it('terminates pagination once a filtered page is not full', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + const res = await callList(`workspaceId=${WS}&search=digest&limit=50`) + + expect((await res.json()).nextCursor).toBeNull() + }) + + it('400s when a cursor is replayed under a different sort', async () => { + queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2' })]) + + const first = await callList(`workspaceId=${WS}&sortBy=name&limit=1`) + const { nextCursor } = await first.json() + vi.clearAllMocks() + + const res = await callList( + `workspaceId=${WS}&sortBy=createdAt&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on a malformed cursor instead of silently restarting from page one', async () => { + const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index ffe19c9ebf1..0706f835c53 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -3,17 +3,34 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { + type V2WorkflowListItem, + type V2WorkflowSortBy, + v2ListWorkflowsContract, +} from '@/lib/api/contracts/v2/workflows' +import { + encodeKeyset, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CursorList, + v2CursorSortError, v2Error, v2RateLimitError, v2ValidationError, @@ -25,13 +42,40 @@ const logger = createLogger('V2WorkflowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ -interface WorkflowListCursor { - sortOrder: number - createdAt: string +type WorkflowRow = { id: string + name: string + sortOrder: number + runCount: number + createdAt: Date + updatedAt: Date } +/** + * The keysets behind the sortable workflow fields. `satisfies` makes the map + * total over the contract enum, so a new sortable field cannot ship without an + * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which + * is what keeps a page boundary inside a run of equal values stable. + * + * `position` keeps its historical three-part ordering: workflows share a + * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle + * every workspace's default list. + */ +const workflowId = textKey(workflow.id, (row) => row.id) +const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt) + +const WORKFLOW_SORTS = { + position: [ + numberKey(workflow.sortOrder, (row) => row.sortOrder), + workflowCreatedAt, + workflowId, + ], + name: [textKey(workflow.name, (row) => row.name), workflowId], + createdAt: [workflowCreatedAt, workflowId], + updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId], + runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId], +} satisfies Record[]> + export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -59,36 +103,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] - - if (params.folderId) { - conditions.push(eq(workflow.folderId, params.folderId)) - } - - if (params.deployedOnly) { - conditions.push(eq(workflow.isDeployed, true)) - } - - if (params.cursor) { - const cursorData = decodeCursor(params.cursor) - if (cursorData) { - const cursorCondition = or( - gt(workflow.sortOrder, cursorData.sortOrder), - and( - eq(workflow.sortOrder, cursorData.sortOrder), - gt(workflow.createdAt, new Date(cursorData.createdAt)) - ), - and( - eq(workflow.sortOrder, cursorData.sortOrder), - eq(workflow.createdAt, new Date(cursorData.createdAt)), - gt(workflow.id, cursorData.id) - ) - ) - if (cursorCondition) { - conditions.push(cursorCondition) - } - } - } + const sortKey = cursorSortKey(params.sortBy, params.sortOrder) + const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] + const decoded = decodeSortedCursor(params.cursor, sortKey) + if (decoded.status === 'invalid') return v2CursorSortError() + + // `null` here is a cursor whose values don't fit this sort — a client error, not an empty page. + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(keys, decoded.keys, params.sortOrder) : undefined + if (resumeAfter === null) return v2CursorSortError() + + const conditions = [ + eq(workflow.workspaceId, params.workspaceId), + isNull(workflow.archivedAt), + params.folderId ? eq(workflow.folderId, params.folderId) : undefined, + params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, + searchFilter(workflow.name, params.search), + resumeAfter, + ] const rows = await db .select({ @@ -107,21 +139,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflow) .where(and(...conditions)) - .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) .limit(params.limit + 1) const hasMore = rows.length > params.limit const data = rows.slice(0, params.limit) - let nextCursor: string | null = null - if (hasMore && data.length > 0) { - const last = data[data.length - 1] - nextCursor = encodeCursor({ - sortOrder: last.sortOrder, - createdAt: last.createdAt.toISOString(), - id: last.id, - }) - } + const last = data.at(-1) + const nextCursor = + hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null const formatted: V2WorkflowListItem[] = data.map((w) => ({ id: w.id, diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 205dd794bb7..7c411ec532d 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -6,7 +6,12 @@ import { } from '@/lib/api/contracts/credentials' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' /** @@ -78,9 +83,16 @@ export const v2CredentialWorkspaceQuerySchema = z.object({ }) export type V2CredentialWorkspaceQuery = z.output +/** A credential's natural name field is `displayName`, so that is what `search` matches. */ +export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const + +export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] + export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ type: workspaceCredentialTypeSchema.optional(), providerId: z.string().min(1, 'providerId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2CredentialSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), }) export type V2ListCredentialsQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index 7082c6d1c82..c2e6221e776 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 custom tool contracts. @@ -59,6 +64,18 @@ export const v2CustomToolWorkspaceQuerySchema = z.object({ }) export type V2CustomToolWorkspaceQuery = z.output +/** A custom tool's natural name field is `title`, so that is what `search` matches. */ +export const v2CustomToolSortFields = ['title', 'createdAt', 'updatedAt'] as const + +export type V2CustomToolSortBy = (typeof v2CustomToolSortFields)[number] + +export const v2ListCustomToolsQuerySchema = v2CustomToolWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2CustomToolSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListCustomToolsQuery = z.output + export const v2CreateCustomToolBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -97,7 +114,7 @@ export type V2UpdateCustomToolBody = z.input, id)`, so the cursor is stamped with the sort it was + * minted under and rejected if the request's sort has since changed. Filtering, + * ordering, and the page slice all happen in the query. */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, scope: v2FileScopeSchema.default('active'), + /** Restrict to one file folder. Omit to list the whole workspace. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), limit: z.coerce .number() .optional() diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts index 6f12fe25637..e59abdbdab1 100644 --- a/apps/sim/lib/api/contracts/v2/folders.ts +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -7,7 +7,12 @@ import { } from '@/lib/api/contracts/folders' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 folder contracts. @@ -73,9 +78,25 @@ export const v2FolderScopedQuerySchema = z.object({ }) export type V2FolderScopedQuery = z.output +/** + * Sortable folder fields. `position` is the tree's manual arrangement (the + * `sort_order` column), kept as the default so a bare list still comes back in + * the order the workspace arranged it. + */ +export const v2FolderSortFields = ['position', 'name', 'createdAt', 'updatedAt'] as const + +export type V2FolderSortBy = (typeof v2FolderSortFields)[number] + +/** + * List query. `search` narrows to folders whose name matches; the result stays + * a flat list either way, so a matching folder is returned without its + * ancestors — reconstruct a tree from `parentId` only on an unsearched list. + */ export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ scope: folderScopeSchema.default('active'), + search: v2SearchSchema, + ...v2SortFields(v2FolderSortFields, { sortBy: 'position', sortOrder: 'asc' }), }) export type V2ListFoldersQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 06f4064d2fb..d92f30c20a1 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -16,7 +16,12 @@ import { v1ListKnowledgeDocumentsQuerySchema, v1UpdateKnowledgeBaseBodySchema, } from '@/lib/api/contracts/v1/knowledge' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 knowledge contracts. @@ -146,16 +151,35 @@ export type V2KnowledgeSearchData = z.output export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) export type V2UploadKnowledgeDocumentQuery = z.output +export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] + +/** + * KB list query: v1's workspace scope plus the v2 search/sort convention and a + * folder filter. v1's own list query stays untouched — it does not implement + * these, and advertising a param a route ignores is worse than not having it. + */ +export const v2ListKnowledgeBasesQuerySchema = v1ListKnowledgeBasesQuerySchema.extend({ + /** Restrict to one knowledge-base folder. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), +}) + +export type V2ListKnowledgeBasesQuery = z.output + /** * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded * per-workspace list), so today the cursor list is a single full page * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list * surface uniform; real pagination can be added later behind the opaque cursor. + * Search, folder filter, and sort all run in that query, not over its result. */ export const v2ListKnowledgeBasesContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge', - query: v1ListKnowledgeBasesQuerySchema, + query: v2ListKnowledgeBasesQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeBaseSchema), diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 54324148896..96b40e38b13 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { createEnvVarPattern } from '@/executor/utils/reference-validation' /** @@ -115,6 +120,17 @@ export const v2McpServerWorkspaceQuerySchema = z.object({ }) export type V2McpServerWorkspaceQuery = z.output +export const v2McpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2McpServerSortBy = (typeof v2McpServerSortFields)[number] + +export const v2ListMcpServersQuerySchema = v2McpServerWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2McpServerSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListMcpServersQuery = z.output + export const v2CreateMcpServerBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -166,7 +182,7 @@ export type V2UpdateMcpServerBody = z.input export const v2ListMcpServersContract = defineRouteContract({ method: 'GET', path: '/api/v2/mcp-servers', - query: v2McpServerWorkspaceQuerySchema, + query: v2ListMcpServersQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2McpServerSchema), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index d0579054727..02a10f692d9 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -17,6 +17,43 @@ import { z } from 'zod' * Rate-limit state is carried in `X-RateLimit-*` response headers (not the * body). Usage limits are available from the dedicated usage endpoint rather * than being inlined into every response. + * + * ## Search, filtering, and sorting + * + * One convention, applied by every v2 list. It is deliberately the narrow + * scalar-param form the app's own list endpoints already speak — not a third + * dialect alongside the Logs filter set and the Tables predicate grammar. + * A list that needs a real expression tree (Tables) keeps its own `POST /query`. + * + * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match + * against the resource's *single* natural name field, and nothing else: + * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ + * skills, `title` for custom tools, `displayName` for credentials. It never + * matches ids, descriptions, or content. `%` and `_` in the term are matched + * literally, not as wildcards. Empty is rejected rather than silently + * ignored — omit the param instead. + * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a + * per-resource enum, never a free string, because the value selects a column + * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so + * an omitted sort is a defined order rather than whatever the planner + * returns. `position` names a resource's stored manual arrangement (the + * `sortOrder` *column* on workflows and folders) — it is spelled differently + * from the `sortOrder` *param* on purpose. + * - **Filters** — resource-specific and enumerated, reusing the names already + * on the surface (`scope`, `folderId`, `deployedOnly`, `type`, `providerId`, + * `resourceType`). No generic filter expression. + * + * Every one of these is pushed into SQL. No v2 list fetches a full result set + * to filter or sort it in memory. + * + * ## Sort and the opaque cursor + * + * On the lists that paginate ({@link v2CursorListResponse} with a non-null + * `nextCursor` — files and workflows), the cursor is a keyset over the *active* + * sort, so its keys change when the sort does. The sort is therefore encoded + * into the cursor and re-checked on the way back in: replaying a cursor under a + * different `sortBy`/`sortOrder` is a 400, not a silently duplicated or skipped + * page. Change the sort by restarting pagination without a cursor. */ /** Canonical v2 error envelope. */ @@ -37,3 +74,35 @@ export const v2CursorListResponse = (itemSchema: T) => data: z.array(itemSchema), nextCursor: z.string().nullable(), }) + +/** + * The v2 `search` term: a case-insensitive substring match on the resource's + * natural name field. Bounded at 200 characters — a longer term cannot match + * any of the name columns it is aimed at, and every one of these matches is an + * unindexed scan. + */ +export const v2SearchSchema = z + .string() + .trim() + .min(1, 'search cannot be empty') + .max(200, 'search is too long') + .optional() + +export const v2SortOrderSchema = z.enum(['asc', 'desc']) + +export type V2SortOrder = z.output + +/** + * The `sortBy` + `sortOrder` pair for one resource. `fields` is the closed set + * of sortable fields — the value reaches the query as a column, so it can never + * be a free string — and both params always resolve to the given defaults. + */ +export function v2SortFields( + fields: F, + defaults: { sortBy: F[number]; sortOrder: V2SortOrder } +) { + return { + sortBy: z.enum(fields).default(defaults.sortBy), + sortOrder: v2SortOrderSchema.default(defaults.sortOrder), + } +} diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 3bc7174ea81..003151aef9f 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -6,7 +6,12 @@ import { skillNameSchema, } from '@/lib/api/contracts/skills' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 skills contracts. @@ -64,6 +69,17 @@ export const v2SkillWorkspaceQuerySchema = z.object({ }) export type V2SkillWorkspaceQuery = z.output +export const v2SkillSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2SkillSortBy = (typeof v2SkillSortFields)[number] + +export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2SkillSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListSkillsQuery = z.output + export const v2CreateSkillBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -105,7 +121,7 @@ export type V2UpdateSkillBody = z.input export const v2ListSkillsContract = defineRouteContract({ method: 'GET', path: '/api/v2/skills', - query: v2SkillWorkspaceQuerySchema, + query: v2ListSkillsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2SkillSummarySchema), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 4c6f886ffe7..fc9d4046979 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -20,7 +20,12 @@ import { v1CreateTableRowsBodySchema, v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { TABLE_LIMITS } from '@/lib/table/constants' /** @@ -141,17 +146,37 @@ export const v2UpsertRowDataSchema = z.object({ }) export type V2UpsertRowData = z.output +export const v2TableSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2TableSortBy = (typeof v2TableSortFields)[number] + +/** + * Table list query: the workspace scope every table route shares, plus the v2 + * search/sort convention and a folder filter. Kept separate from + * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that + * schema and have no list params. + */ +export const v2ListTablesQuerySchema = v1ListTablesQuerySchema.extend({ + /** Restrict to one table folder. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), +}) + +export type V2ListTablesQuery = z.output + /** * Table list. `listTables` returns every table in the workspace (a small, * bounded per-workspace set), so today the cursor list is a single full page * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the * whole v2 list surface uniform, and real pagination can be added later behind - * the opaque cursor without an interface change. + * the opaque cursor without an interface change. Search, folder filter, and + * sort all run in that query, not over its result. */ export const v2ListTablesContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables', - query: v1ListTablesQuerySchema, + query: v2ListTablesQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2ApiTableSchema), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index b721f347b7c..76e944480b9 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -8,7 +8,12 @@ import { v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { cancelWorkflowExecutionReasonSchema, workflowExecutionParamsSchema, @@ -18,13 +23,41 @@ import { } from '@/lib/api/contracts/workflows' /** - * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list - * query and `[id]` param are unchanged); only the response envelope is upgraded - * to the canonical v2 shapes with concrete item/detail schemas. The - * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, - * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 - * carries rate-limit state in headers and usage on a dedicated endpoint). + * v2 workflows contracts. Request shapes are reused from v1 (the `[id]` param + * is unchanged, and the list query extends v1's with the v2 search/sort + * convention); only the response envelope is upgraded to the canonical v2 + * shapes with concrete item/detail schemas. The deploy/rollback/undeploy data + * payloads reuse the already-concrete v1 schemas, re-wrapped in + * `v2DataResponse` (the v1 `limits` body field is dropped — v2 carries + * rate-limit state in headers and usage on a dedicated endpoint). + */ + +/** + * Sortable workflow fields. `position` is the workspace's manual arrangement + * (the `sort_order` column the sidebar writes), kept as the default so a bare + * list still returns workflows in the order the workspace put them in. + */ +export const v2WorkflowSortFields = [ + 'position', + 'name', + 'createdAt', + 'updatedAt', + 'runCount', +] as const + +export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] + +/** + * List query: v1's workspace/folder/deployment filters plus the v2 search and + * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor + * carries the sort it was minted under and is rejected once that changes. */ +export const v2ListWorkflowsQuerySchema = v1ListWorkflowsQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), +}) + +export type V2ListWorkflowsQuery = z.output export const v2WorkflowListItemSchema = z.object({ id: z.string(), @@ -73,7 +106,7 @@ const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: export const v2ListWorkflowsContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows', - query: v1ListWorkflowsQuerySchema, + query: v2ListWorkflowsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowListItemSchema), diff --git a/apps/sim/lib/api/list-convention.test.ts b/apps/sim/lib/api/list-convention.test.ts new file mode 100644 index 00000000000..fcb2594dc26 --- /dev/null +++ b/apps/sim/lib/api/list-convention.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + * + * One test per v2 list-backing query, asserting the same two things everywhere: + * `search` becomes a bound case-insensitive substring predicate on that + * resource's natural name column, and `sortBy` selects the ordering columns. + * + * This is the surface-wide guard the convention needs. `list-query.test.ts` + * proves the generated SQL is parameterized and wildcard-escaped; what can still + * go wrong per resource is aiming the search at the wrong column, or a sort that + * never reaches `ORDER BY` — both visible only in the query each lib builds. + * + * Files (`queryWorkspaceFiles`) and workflows have their own suites, since they + * additionally paginate. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: vi.fn() })) +vi.mock('@/lib/billing/core/usage', () => ({ ensureUserStatsExists: vi.fn() })) +vi.mock('@/lib/billing/storage', () => ({ + applyStorageUsageDeltasInTx: vi.fn(), + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) +vi.mock('@/lib/table/billing', () => ({ + assertRowCapacity: vi.fn(), + notifyTableRowUsage: vi.fn(), +})) +vi.mock('@/lib/table/jobs/service', () => ({ + EMPTY_JOB_FIELDS: {}, + latestJobForTable: vi.fn(async () => null), + latestJobsForTables: vi.fn(async () => new Map()), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/realtime/notify', () => ({ + mergeEditIntoLiveFileDoc: vi.fn(), + notifyWorkspaceFilesChanged: vi.fn(), + notifyWorkspaceTablesChanged: vi.fn(), +})) +vi.mock('@/lib/skills/access', () => ({ getEditableSkillIds: vi.fn() })) +vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ + BUILTIN_SKILLS: [], + getBuiltinSkillById: vi.fn(), + isBuiltinSkillId: vi.fn(() => false), +})) + +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { getKnowledgeBases } from '@/lib/knowledge/service' +import { listWorkspaceMcpServers } from '@/lib/mcp/queries' +import { listTables } from '@/lib/table/service' +import { listWorkspaceCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listSkills } from '@/lib/workflows/skills/operations' + +const WS = 'workspace-1' + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +const searchNode = () => lastConditions().find((c) => c.type === 'ilike') + +interface ListCase { + name: string + /** Column the resource's `search` must match on. */ + column: unknown + /** Rows table to queue against, so the chain resolves. */ + table: unknown + run: (options: { + search?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + }) => Promise + /** A non-default sort, and the columns it must order by. */ + sort: { sortBy: string; sortOrder: 'asc' | 'desc'; columns: unknown[] } +} + +const CASES: ListCase[] = [ + { + name: 'folders', + column: schemaMock.folder.name, + table: schemaMock.folder, + run: ({ search, sortBy, sortOrder }) => + listFoldersForWorkspace(WS, 'active', 'workflow', { + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'name', + sortOrder: 'desc', + columns: [schemaMock.folder.name, schemaMock.folder.createdAt], + }, + }, + { + name: 'tables', + column: schemaMock.userTableDefinitions.name, + table: schemaMock.userTableDefinitions, + run: ({ search, sortBy, sortOrder }) => + listTables(WS, { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'updatedAt', + sortOrder: 'desc', + columns: [ + schemaMock.userTableDefinitions.updatedAt, + schemaMock.userTableDefinitions.createdAt, + ], + }, + }, + { + name: 'knowledge bases', + column: schemaMock.knowledgeBase.name, + table: schemaMock.knowledgeBase, + run: ({ search, sortBy, sortOrder }) => + getKnowledgeBases('user-1', WS, 'active', { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.knowledgeBase.name, schemaMock.knowledgeBase.createdAt], + }, + }, + { + name: 'credentials', + column: schemaMock.credential.displayName, + table: schemaMock.credential, + run: ({ search, sortBy, sortOrder }) => + listVisibleWorkspaceCredentials({ + workspaceId: WS, + userId: 'user-1', + workspaceAccess: { canAdmin: false }, + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'displayName', + sortOrder: 'asc', + columns: [schemaMock.credential.displayName, schemaMock.credential.id], + }, + }, + { + name: 'MCP servers', + column: schemaMock.mcpServers.name, + table: schemaMock.mcpServers, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceMcpServers({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.mcpServers.name, schemaMock.mcpServers.id], + }, + }, + { + name: 'custom tools', + column: schemaMock.customTools.title, + table: schemaMock.customTools, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceCustomTools({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'title', + sortOrder: 'asc', + columns: [schemaMock.customTools.title, schemaMock.customTools.id], + }, + }, + { + name: 'skills', + column: schemaMock.skill.name, + table: schemaMock.skill, + run: ({ search, sortBy, sortOrder }) => + listSkills({ + workspaceId: WS, + search, + sort: sortBy ? { sortBy: sortBy as never, sortOrder: sortOrder ?? 'desc' } : undefined, + }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.skill.name, schemaMock.skill.id], + }, + }, +] + +describe.each(CASES)('$name list query', (listCase) => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(listCase.table, []) + }) + + it('narrows the query with a case-insensitive substring match on its name column', async () => { + await listCase.run({ search: 'quarterly' }) + + expect(searchNode()).toMatchObject({ column: listCase.column, pattern: '%quarterly%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + await listCase.run({ search: '50%_off' }) + + expect(searchNode()).toMatchObject({ pattern: '%50\\%\\_off%' }) + }) + + it('adds no search condition when the caller did not search', async () => { + await listCase.run({}) + + expect(searchNode()).toBeUndefined() + }) + + it('orders by the requested field and direction', async () => { + const { sortBy, sortOrder, columns } = listCase.sort + + await listCase.run({ sortBy, sortOrder }) + + expect(lastOrderBy()).toEqual(columns.map((column) => ({ type: sortOrder, column }))) + }) +}) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts new file mode 100644 index 00000000000..4da992aa2f4 --- /dev/null +++ b/apps/sim/lib/api/list-query.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + * + * The v2 list convention's SQL half. These run against REAL drizzle (the global + * `drizzle-orm` mock is lifted for this file) and render the generated SQL, so + * the assertions are about the query that would actually be sent — the point + * being that a caller's `search` term only ever arrives as a bound parameter. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('drizzle-orm') + +import { integer, PgDialect, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { + encodeKeyset, + escapeLikePattern, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' + +const thing = pgTable('thing', { + id: text('id').primaryKey(), + name: text('name').notNull(), + size: integer('size').notNull(), + createdAt: timestamp('created_at').notNull(), +}) + +const dialect = new PgDialect() + +function render(fragment: Parameters[0]) { + return dialect.sqlToQuery(fragment) +} + +describe('escapeLikePattern', () => { + it('neutralizes the LIKE wildcards so a caller cannot widen its own match', () => { + expect(escapeLikePattern('100%')).toBe('100\\%') + expect(escapeLikePattern('a_b')).toBe('a\\_b') + expect(escapeLikePattern('back\\slash')).toBe('back\\\\slash') + }) + + it('leaves an ordinary term untouched', () => { + expect(escapeLikePattern('quarterly report')).toBe('quarterly report') + }) +}) + +describe('searchFilter', () => { + it('binds the caller term as a parameter instead of inlining it into the SQL', () => { + const { sql, params } = render(searchFilter(thing.name, "o'brien; drop table thing --")!) + + expect(sql).toBe('"thing"."name" ilike $1') + expect(params).toEqual(["%o'brien; drop table thing --%"]) + expect(sql).not.toContain('drop table') + }) + + it('escapes wildcards inside the bound pattern', () => { + const { params } = render(searchFilter(thing.name, '50%_off')!) + + expect(params).toEqual(['%50\\%\\_off%']) + }) + + it('is case-insensitive (ILIKE, not LIKE)', () => { + const { sql } = render(searchFilter(thing.name, 'Report')!) + + expect(sql).toContain('ilike') + }) + + it('drops out of the WHERE clause entirely when no term was given', () => { + expect(searchFilter(thing.name, undefined)).toBeUndefined() + }) +}) + +describe('listOrderBy', () => { + it('applies the direction to every key so the ordering is total', () => { + const [first, second] = listOrderBy([thing.name, thing.id], 'desc') + + expect(render(first).sql).toBe('"thing"."name" desc') + expect(render(second).sql).toBe('"thing"."id" desc') + }) +}) + +interface Row { + id: string + name: string + createdAt: Date +} + +const nameKey = textKey(thing.name, (r) => r.name) +const idKey = textKey(thing.id, (r) => r.id) +const createdKey = timestampKey(thing.createdAt, (r) => r.createdAt) + +describe('timestampKey', () => { + /** + * The regression this exists for: Postgres keeps microseconds, a cursor value + * round-trips through a millisecond-only JS Date, and comparing the raw column + * against the truncated value re-admits the page's own last row. + */ + it('orders on the millisecond-truncated column so the cursor can express the ordering', () => { + expect(render(createdKey.expr as never).sql).toBe( + `date_trunc('milliseconds', "thing"."created_at")` + ) + }) + + it('truncates the bound cursor value to match, binding it through the column encoder', () => { + const { sql: text, params } = render(createdKey.bind('2024-01-01T00:00:00.123Z')!) + + expect(text).toBe(`date_trunc('milliseconds', $1)`) + expect(params).toEqual(['2024-01-01T00:00:00.123Z']) + }) + + it('rejects a cursor value that is not a parseable timestamp', () => { + expect(createdKey.bind('not-a-date')).toBeNull() + expect(createdKey.bind(1700000000000)).toBeNull() + }) +}) + +describe('cursor key value validation', () => { + it('rejects a non-string for a text key', () => { + expect(nameKey.bind(42)).toBeNull() + expect(nameKey.bind('ok')).not.toBeNull() + }) + + it('rejects a non-finite or non-numeric value for a numeric key', () => { + const sizeKey = numberKey(thing.size, () => 0) + + expect(sizeKey.bind('12')).toBeNull() + expect(sizeKey.bind(Number.NaN)).toBeNull() + expect(sizeKey.bind(Number.POSITIVE_INFINITY)).toBeNull() + expect(sizeKey.bind(12)).not.toBeNull() + }) +}) + +describe('encodeKeyset / keysetColumns', () => { + it('reads the cursor values and the ordering expressions in key order', () => { + const row: Row = { id: 'file-7', name: 'data.csv', createdAt: new Date('2024-03-04T05:06:07Z') } + + expect(encodeKeyset([nameKey, idKey], row)).toEqual(['data.csv', 'file-7']) + expect(keysetColumns([nameKey, idKey])).toEqual([thing.name, thing.id]) + }) +}) + +describe('keysetAfter', () => { + it('expands lexicographically so a tie on a leading key falls through', () => { + const { sql: text, params } = render( + keysetAfter([nameKey, idKey], ['data.csv', 'file-7'], 'asc')! + ) + + expect(text).toBe('("thing"."name" > $1 or ("thing"."name" = $2 and "thing"."id" > $3))') + expect(params).toEqual(['data.csv', 'data.csv', 'file-7']) + }) + + it('flips the comparison for a descending sort', () => { + const { sql: text } = render(keysetAfter([nameKey, idKey], ['b', 'x'], 'desc')!) + + expect(text).toContain('"thing"."name" < $1') + expect(text).not.toContain('>') + }) + + it('binds every keyset value as a parameter', () => { + const { sql: text, params } = render(keysetAfter([idKey], ["'; delete from thing --"], 'asc')!) + + expect(text).toBe('"thing"."id" > $1') + expect(params).toEqual(["'; delete from thing --"]) + }) + + it('compares the truncated timestamp on both sides', () => { + const { sql: text, params } = render( + keysetAfter([createdKey, idKey], ['2024-01-01T00:00:00.123Z', 'file-7'], 'asc')! + ) + + expect(text).toBe( + `(date_trunc('milliseconds', "thing"."created_at") > date_trunc('milliseconds', $1) or ` + + `(date_trunc('milliseconds', "thing"."created_at") = date_trunc('milliseconds', $2) and ` + + `"thing"."id" > $3))` + ) + expect(params).toEqual(['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z', 'file-7']) + }) + + /** A caller controls the cursor's contents, so a bad value is a 400, not a 500 from SQL. */ + it('refuses a cursor carrying a value its key cannot hold', () => { + expect(keysetAfter([createdKey, idKey], ['not-a-date', 'file-7'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], [7, 'file-7'], 'asc')).toBeNull() + }) + + it('refuses a cursor with the wrong number of keys for the sort', () => { + expect(keysetAfter([nameKey, idKey], ['only-one'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], ['a', 'b', 'c'], 'asc')).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts new file mode 100644 index 00000000000..d5f74aa9660 --- /dev/null +++ b/apps/sim/lib/api/list-query.ts @@ -0,0 +1,176 @@ +import { + and, + asc, + type Column, + desc, + eq, + gt, + ilike, + lt, + or, + type SQL, + type SQLWrapper, + sql, +} from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' + +/** + * Runtime half of the v2 list convention declared in + * `lib/api/contracts/v2/shared.ts`: turns a validated `search` term and a + * validated `sortBy`/`sortOrder` pair into SQL. + * + * Nothing here accepts a caller string as SQL. `search` becomes a bound ILIKE + * parameter, a sort is only ever expressed as one of the keys the resource + * itself listed (the contract enum is what makes that lookup total), and a + * cursor's values are type-checked against their key before they are bound. + */ + +/** + * Escapes LIKE/ILIKE wildcards so `%`, `_`, and `\` in a caller's term match + * themselves. Postgres treats `\` as the default LIKE escape character, so no + * explicit `ESCAPE` clause is needed. + * + * `lib/table/sql.ts` carries its own copy for the JSONB predicate engine; the + * two are worth folding together, but that module is table-specific and pulls + * the whole column-type registry with it. + */ +export function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + +/** + * Case-insensitive substring predicate for a v2 `search` term, or `undefined` + * when the caller did not search (which drops out of an `and(...)`). + */ +export function searchFilter(column: Column, term: string | undefined): SQL | undefined { + if (term === undefined) return undefined + return ilike(column, `%${escapeLikePattern(term)}%`) +} + +/** A cursor key value, as it survives the base64-JSON round trip. */ +export type CursorKey = string | number + +/** Caller-facing message for a cursor that cannot be resumed under the requested sort. */ +export const INVALID_CURSOR_MESSAGE = + 'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.' + +/** + * One column of a keyset ordering, with the codec that moves its value through + * the opaque cursor. + * + * `bind` returning `null` is how a malformed cursor becomes a 400. The values + * inside a cursor are caller-controlled, so "the sort stamp and key count + * match" is not enough — a non-numeric `size` or an unparseable timestamp has + * to be rejected at the boundary instead of reaching the query as `NaN` or an + * `Invalid Date`, which surfaces as a 500. + */ +export interface KeysetKey { + /** The expression this key both orders and compares on. */ + expr: SQLWrapper + /** This key's cursor value for `row`. */ + encode: (row: Row) => CursorKey + /** The cursor value as bindable SQL, or `null` when this key cannot hold it. */ + bind: (value: CursorKey) => SQL | null +} + +/** A text key — names, titles, ids. */ +export function textKey(column: Column, read: (row: Row) => string): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'string' ? sql`${value}` : null), + } +} + +/** A numeric key — sizes, counts, manual positions. */ +export function numberKey(column: Column, read: (row: Row) => number): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'number' && Number.isFinite(value) ? sql`${value}` : null), + } +} + +/** + * A timestamp key, ordered and compared at millisecond precision. + * + * Postgres keeps microseconds and `defaultNow()` populates them, but a cursor + * value round-trips through a JS `Date`, which cannot represent them. Ordering + * on the raw column while comparing against a truncated cursor value re-admits + * the page's own last row — `stored > truncated` is true for it — which + * duplicates that row and stalls pagination outright at a page size of one. + * Truncating both sides makes the SQL ordering exactly the ordering a cursor + * can express, so the `id` tiebreaker is what actually separates rows inside a + * millisecond. + * + * `date_trunc` rules out an index-ordered scan, but none of the timestamp + * columns sorted here are indexed, so it costs nothing today. Adding an index + * to serve one of these sorts means indexing this same expression. + */ +export function timestampKey(column: Column, read: (row: Row) => Date): KeysetKey { + return { + expr: sql`date_trunc('milliseconds', ${column})`, + encode: (row) => read(row).toISOString(), + bind: (value) => { + if (typeof value !== 'string') return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + // Bound through the column so drizzle's own timestamp encoder serializes it. + return sql`date_trunc('milliseconds', ${sql.param(date, column)})` + }, + } +} + +export function sortDirection(order: V2SortOrder): typeof asc { + return order === 'asc' ? asc : desc +} + +/** + * `ORDER BY` for an ordered key list, every key taking the requested direction. + * On a paginated list these are the keyset's keys; on a single-page list they + * are just the sort plus its tiebreaker. + */ +export function listOrderBy(keys: readonly SQLWrapper[], order: V2SortOrder): SQL[] { + const direction = sortDirection(order) + return keys.map((key) => direction(key)) +} + +/** The `expr` of each keyset key, for `ORDER BY`. */ +export function keysetColumns(keys: readonly KeysetKey[]): SQLWrapper[] { + return keys.map((key) => key.expr) +} + +/** The cursor values for `row`, in key order. */ +export function encodeKeyset(keys: readonly KeysetKey[], row: Row): CursorKey[] { + return keys.map((key) => key.encode(row)) +} + +/** + * The `WHERE` half of the keyset: strictly after `values` in the requested + * direction, expanded lexicographically so ties on a leading key fall through + * to the next one. + * + * Returns `null` when the cursor does not fit this sort — wrong number of keys, + * or a value the key cannot hold. Callers render that as a 400 rather than + * paging from a nonsense position. + */ +export function keysetAfter( + keys: readonly KeysetKey[], + values: CursorKey[], + order: V2SortOrder +): SQL | null { + if (values.length !== keys.length) return null + + const bound: SQL[] = [] + for (const [i, key] of keys.entries()) { + const value = key.bind(values[i]) + if (value === null) return null + bound.push(value) + } + + const beyond = order === 'asc' ? gt : lt + const clauses = keys.map((key, i) => + and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i])) + ) + return or(...clauses) ?? null +} diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 397e96b9f59..ef0fccd4267 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -36,14 +39,38 @@ export interface VisibleWorkspaceCredential { * admins — every shared-type credential, plus the caller's own personal env * credentials. Encrypted secret material is never selected. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so credentials sharing a display name + * or a timestamp still come back in a stable order. + */ +const CREDENTIAL_SORTS = { + displayName: [credential.displayName, credential.id], + createdAt: [credential.createdAt, credential.id], + updatedAt: [credential.updatedAt, credential.id], +} satisfies Record + export async function listVisibleWorkspaceCredentials(params: { workspaceId: string userId: string workspaceAccess: Pick type?: WorkspaceCredentialType providerId?: string + /** Case-insensitive substring match on the credential display name. */ + search?: string + sortBy?: V2CredentialSortBy + sortOrder?: V2SortOrder }): Promise { - const { workspaceId, userId, workspaceAccess, type, providerId } = params + const { + workspaceId, + userId, + workspaceAccess, + type, + providerId, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = params const whereClauses = [eq(credential.workspaceId, workspaceId)] if (type) whereClauses.push(eq(credential.type, type)) @@ -84,7 +111,8 @@ export async function listVisibleWorkspaceCredentials(params: { eq(credentialMember.status, 'active') ) ) - .where(and(...whereClauses, accessClause)) + .where(and(...whereClauses, accessClause, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ ...rest, diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 092387cea90..98f616e1a21 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' -import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { V2FolderSortBy } from '@/lib/api/contracts/v2/folders' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' /** @@ -135,21 +138,52 @@ export async function resolveRestoredFolderId( return (await findActiveFolder(folderId, workspaceId, resourceType)) ? folderId : null } -/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so folders sharing a name or a + * `sortOrder` still come back in a stable order. + */ +const FOLDER_SORTS = { + position: [folder.sortOrder, folder.createdAt], + name: [folder.name, folder.createdAt], + createdAt: [folder.createdAt], + updatedAt: [folder.updatedAt, folder.createdAt], +} satisfies Record + +interface ListFoldersOptions { + /** Case-insensitive substring match on the folder name. */ + search?: string + sortBy?: V2FolderSortBy + sortOrder?: V2SortOrder +} + +/** + * Shared by `GET /api/folders`, the public v2 list, and the sidebar prefetch so + * the query never drifts between them. Search and sort are applied in the + * query; the in-app callers omit them and keep the default `position` ordering. + */ export async function listFoldersForWorkspace( workspaceId: string, scope: FolderQueryScope, - resourceType: FolderResourceType + resourceType: FolderResourceType, + options?: ListFoldersOptions ): Promise { const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) + const sortBy = options?.sortBy ?? 'position' + const sortOrder = options?.sortOrder ?? 'asc' const rows = await db .select() .from(folder) .where( - and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), scopeFilter) + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + scopeFilter, + searchFilter(folder.name, options?.search) + ) ) - .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) + .orderBy(...listOrderBy(FOLDER_SORTS[sortBy], sortOrder)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index ce59bc0087b..7399b83aebe 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -82,7 +82,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'owner' }) - ).rejects.not.toBeInstanceOf(KnowledgeBasePermissionError) + ).resolves.not.toThrow() expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index e4e8cc3707b..6131ae58fd0 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -10,7 +10,22 @@ import { import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' +import { + and, + type Column, + count, + eq, + exists, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from 'drizzle-orm' +import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { ensureUserStatsExists } from '@/lib/billing/core/usage' @@ -108,13 +123,38 @@ type KnowledgeBaseStorageMove = } /** - * Get knowledge bases that a user can access + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so knowledge bases sharing a + * name still come back in a stable order. + */ +const KNOWLEDGE_BASE_SORTS = { + name: [knowledgeBase.name, knowledgeBase.createdAt], + createdAt: [knowledgeBase.createdAt], + updatedAt: [knowledgeBase.updatedAt, knowledgeBase.createdAt], +} satisfies Record + +interface GetKnowledgeBasesOptions { + /** Restrict to one knowledge-base folder. */ + folderId?: string + /** Case-insensitive substring match on the knowledge base name. */ + search?: string + sortBy?: V2KnowledgeBaseSortBy + sortOrder?: V2SortOrder +} + +/** + * Get knowledge bases that a user can access. + * + * Filter and sort are applied in the query, so a search costs one narrowed scan + * rather than materializing every knowledge base the caller can reach. */ export async function getKnowledgeBases( userId: string, workspaceId?: string | null, - scope: KnowledgeBaseScope = 'active' + scope: KnowledgeBaseScope = 'active', + options?: GetKnowledgeBasesOptions ): Promise { + const { folderId, search, sortBy = 'createdAt', sortOrder = 'asc' } = options ?? {} const scopeCondition = scope === 'all' ? undefined @@ -161,6 +201,8 @@ export async function getKnowledgeBases( .where( and( scopeCondition, + folderId ? eq(knowledgeBase.folderId, folderId) : undefined, + searchFilter(knowledgeBase.name, search), workspaceId ? // When filtering by workspace or( @@ -183,7 +225,7 @@ export async function getKnowledgeBases( ) ) .groupBy(knowledgeBase.id) - .orderBy(knowledgeBase.createdAt) + .orderBy(...listOrderBy(KNOWLEDGE_BASE_SORTS[sortBy], sortOrder)) const kbIds = knowledgeBasesWithCounts.map((kb) => kb.id) diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 81a50f0b1d6..789a86a4454 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -1,6 +1,9 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNull } from 'drizzle-orm' +import type { V2McpServerSortBy } from '@/lib/api/contracts/v2/mcp-servers' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' /** * Workspace-scoped MCP server reads. The lifecycle functions in @@ -11,14 +14,36 @@ import { and, desc, eq, isNull } from 'drizzle-orm' export type McpServerRow = typeof mcpServers.$inferSelect /** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so servers sharing a name or a + * timestamp still come back in a stable order. + */ +const MCP_SERVER_SORTS = { + name: [mcpServers.name, mcpServers.id], + createdAt: [mcpServers.createdAt, mcpServers.id], + updatedAt: [mcpServers.updatedAt, mcpServers.id], +} satisfies Record + export async function listWorkspaceMcpServers(params: { workspaceId: string + /** Case-insensitive substring match on the server name. */ + search?: string + sortBy?: V2McpServerSortBy + sortOrder?: V2SortOrder }): Promise { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db .select() .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt))) - .orderBy(desc(mcpServers.createdAt)) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt), + searchFilter(mcpServers.name, params.search) + ) + ) + .orderBy(...listOrderBy(MCP_SERVER_SORTS[sortBy], sortOrder)) } /** A single live MCP server, or null when it does not exist in this workspace. */ diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 99f9cf82404..dd41b525317 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,7 +13,10 @@ import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { and, type Column, count, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -206,17 +209,47 @@ export async function getTableById( } } +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so tables sharing a name still + * come back in a stable order. + */ +const TABLE_SORTS = { + name: [userTableDefinitions.name, userTableDefinitions.createdAt], + createdAt: [userTableDefinitions.createdAt], + updatedAt: [userTableDefinitions.updatedAt, userTableDefinitions.createdAt], +} satisfies Record + +interface ListTablesOptions { + scope?: TableScope + /** Restrict to one table folder. */ + folderId?: string + /** Case-insensitive substring match on the table name. */ + search?: string + sortBy?: V2TableSortBy + sortOrder?: V2SortOrder +} + /** * Lists all tables in a workspace. * + * Filter and sort are applied in the query — a name search must not become + * "read every table in the workspace, then discard most of them". + * * @param workspaceId - Workspace ID to list tables for * @returns Array of table definitions */ export async function listTables( workspaceId: string, - options?: { scope?: TableScope } + options?: ListTablesOptions ): Promise { - const { scope = 'active' } = options ?? {} + const { + scope = 'active', + folderId, + search, + sortBy = 'createdAt', + sortOrder = 'asc', + } = options ?? {} const tables = await db .select({ id: userTableDefinitions.id, @@ -236,19 +269,18 @@ export async function listTables( }) .from(userTableDefinitions) .where( - scope === 'all' - ? eq(userTableDefinitions.workspaceId, workspaceId) - : scope === 'archived' - ? and( - eq(userTableDefinitions.workspaceId, workspaceId), - sql`${userTableDefinitions.archivedAt} IS NOT NULL` - ) - : and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) + and( + eq(userTableDefinitions.workspaceId, workspaceId), + scope === 'all' + ? undefined + : scope === 'archived' + ? isNotNull(userTableDefinitions.archivedAt) + : isNull(userTableDefinitions.archivedAt), + folderId ? eq(userTableDefinitions.folderId, folderId) : undefined, + searchFilter(userTableDefinitions.name, search) + ) ) - .orderBy(userTableDefinitions.createdAt) + .orderBy(...listOrderBy(TABLE_SORTS[sortBy], sortOrder)) const jobsByTable = await latestJobsForTables(tables.map((t) => t.id)) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index c77719e6860..6e141853a9a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,8 +9,23 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, type SQL } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { decrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, @@ -775,6 +790,33 @@ export async function getWorkspaceFileByName( return mapSingleWorkspaceFileRecord(files[0], workspaceId) } +/** Workspace-file rows for one scope: live, Recently Deleted, or both. */ +function workspaceFileScopeCondition(workspaceId: string, scope: WorkspaceFileScope) { + const base = [ + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + ] + if (scope === 'all') return and(...base) + return scope === 'archived' + ? and(...base, isNotNull(workspaceFiles.deletedAt)) + : and(...base, isNull(workspaceFiles.deletedAt)) +} + +/** Resolves `folderPath` for a page of rows, reading the folder tree only if any row needs it. */ +async function hydrateWorkspaceFilePaths( + files: (typeof workspaceFiles.$inferSelect)[], + workspaceId: string, + options?: { folders?: WorkspaceFileFolderRecord[]; hydrateFolderPaths?: boolean } +): Promise { + const needsFolderPaths = + files.some((file) => file.folderId) && (options?.hydrateFolderPaths ?? true) + const folders = needsFolderPaths + ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) + : [] + const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() + return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) +} + /** * List all files for a workspace */ @@ -783,37 +825,14 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { scope = 'active', hydrateFolderPaths = true } = options ?? {} + const { scope = 'active' } = options ?? {} const files = await db .select() .from(workspaceFiles) - .where( - scope === 'all' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') - ) - : scope === 'archived' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - sql`${workspaceFiles.deletedAt} IS NOT NULL` - ) - : and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) + .where(workspaceFileScopeCondition(workspaceId, scope)) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = files.some((file) => file.folderId) && hydrateFolderPaths - const folders = needsFolderPaths - ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) - : [] - const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - - return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + return hydrateWorkspaceFilePaths(files, workspaceId, options) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) if (options?.throwOnError) throw error @@ -821,6 +840,91 @@ export async function listWorkspaceFiles( } } +/** + * The keysets behind {@link queryWorkspaceFiles}' sortable fields. `satisfies` + * makes this total over the contract enum: a new sortable field in the contract + * fails to compile until it has a keyset here, rather than silently falling + * through to an unordered scan. + * + * Every key column is `NOT NULL`, and `id` closes each keyset so a page + * boundary inside a run of equal names/sizes/timestamps is still stable. + */ +const fileId = textKey(workspaceFiles.id, (row) => row.id) + +const WORKSPACE_FILE_SORTS = { + name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId], + size: [numberKey(workspaceFiles.size, (row) => row.size), fileId], + uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId], + updatedAt: [timestampKey(workspaceFiles.updatedAt, (row) => row.updatedAt), fileId], +} satisfies Record[]> + +export interface QueryWorkspaceFilesOptions { + scope?: WorkspaceFileScope + /** Restrict to one file folder. */ + folderId?: string + /** Case-insensitive substring match on the file name. */ + search?: string + sortBy: V2FileSortBy + sortOrder: V2SortOrder + limit: number + /** Keyset values from a cursor, in the sort's key order. */ + after?: CursorKey[] +} + +export interface QueryWorkspaceFilesResult { + files: WorkspaceFileRecord[] + /** Keyset values to resume from, or `null` when this page is the last one. */ + nextKeys: CursorKey[] | null +} + +/** + * One filtered, sorted, bounded page of a workspace's files. + * + * Distinct from {@link listWorkspaceFiles}, which materializes the whole scope + * for callers that genuinely need it. Here the filter, the ordering, and the + * slice are all in the query: a name search must not become "read every row, + * then discard almost all of them in JS". + * + * Throws rather than returning a short page — a swallowed storage error here is + * indistinguishable from "no more results" and would silently end pagination. + * A cursor that does not fit the requested sort is a classified `validation` + * failure, so the route renders it as a 400 rather than a 500. + */ +export async function queryWorkspaceFiles( + workspaceId: string, + options: QueryWorkspaceFilesOptions +): Promise { + const { scope = 'active', folderId, search, sortBy, sortOrder, limit, after } = options + const keys: readonly KeysetKey[] = WORKSPACE_FILE_SORTS[sortBy] + + let resumeAfter: SQL | undefined + if (after) { + const condition = keysetAfter(keys, after, sortOrder) + if (!condition) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + resumeAfter = condition + } + + const conditions = [ + workspaceFileScopeCondition(workspaceId, scope), + folderId ? eq(workspaceFiles.folderId, folderId) : undefined, + searchFilter(workspaceFiles.originalName, search), + resumeAfter, + ] + + const rows = await db + .select() + .from(workspaceFiles) + .where(and(...conditions)) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const files = await hydrateWorkspaceFilePaths(rows.slice(0, limit), workspaceId) + const last = files.at(-1) + + return { files, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } +} + /** * Normalize a workspace file reference to either a display name or canonical file ID. * Supports raw IDs, `files/{name}`, `files/{name}/content`, and `files/{name}/meta.json`. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts new file mode 100644 index 00000000000..21b5be978f6 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + * + * `queryWorkspaceFiles` — the paged, filtered, sorted read behind + * `GET /api/v2/files`. The assertions are on the query it builds, because the + * point of this function existing is that the scope filter, the name search, + * the ordering, and the page slice all happen in SQL rather than over a + * full-workspace result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/storage', () => ({ + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/uploads', () => ({ + getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFile: vi.fn(), + downloadFile: vi.fn(), + hasCloudStorage: vi.fn(() => false), + headObject: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(async () => null), + buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), + fileNameExistsInWorkspaceFolder: vi.fn(async () => false), + findWorkspaceFileFolderIdByPath: vi.fn(), + getWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(async () => []), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name), +})) + +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const WS = 'workspace-1' + +const DEFAULTS = { sortBy: 'uploadedAt', sortOrder: 'asc', limit: 100 } as const + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + key: 'workspace/ws/1-x-data.csv', + userId: 'user-1', + workspaceId: WS, + folderId: null, + context: 'workspace', + originalName: 'data.csv', + contentType: 'text/csv', + size: 1024, + deletedAt: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + contentUpdatedAt: new Date('2024-01-01T00:00:00Z'), + ...overrides, + } +} + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +describe('queryWorkspaceFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('narrows the query with a case-insensitive substring match on the file name', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: 'data' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + column: schemaMock.workspaceFiles.originalName, + pattern: '%data%', + }) + }) + + it('escapes LIKE wildcards in the search term', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: '50%_off' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%50\\%\\_off%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, DEFAULTS) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('filters to one folder in the query', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, folderId: 'fold_1' }) + + expect( + lastConditions().some( + (c) => + c.type === 'eq' && c.left === schemaMock.workspaceFiles.folderId && c.right === 'fold_1' + ) + ).toBe(true) + }) + + it('orders by the requested field, with id closing the keyset', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', sortOrder: 'desc' }) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workspaceFiles.originalName }, + { type: 'desc', column: schemaMock.workspaceFiles.id }, + ]) + }) + + it('bounds the page in SQL by fetching one row past the limit', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 25 }) + + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(26) + }) + + it('reports no further keys when the page is not full, terminating pagination', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 10 }) + + expect(files).toHaveLength(1) + expect(nextKeys).toBeNull() + }) + + it('returns the keyset of the last row when more results exist', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow(), + buildRow({ id: 'wf_2', originalName: 'b.csv' }), + ]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + limit: 1, + }) + + expect(files.map((f) => f.id)).toEqual(['wf_1']) + expect(nextKeys).toEqual(['data.csv', 'wf_1']) + }) + + it('resumes strictly after the cursor keys, alongside the filter', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow({ id: 'wf_2', originalName: 'data-2.csv' }), + ]) + + await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + search: 'data', + after: ['data.csv', 'wf_1'], + }) + + const conditions = lastConditions() + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%data%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + /** + * Cursor contents are caller-controlled, so a value the key cannot hold is a + * client error. Classified `validation` so the route renders 400, not 500. + */ + it('rejects a cursor whose values do not fit the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'uploadedAt', after: ['not-a-date', 'wf_1'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('rejects a cursor with the wrong number of keys for the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', after: ['data.csv'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 2b6a779776b..32e2ba8bf3c 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import { and, desc, eq, isNull, or } from 'drizzle-orm' +import { and, type Column, desc, eq, isNull, or } from 'drizzle-orm' +import type { V2CustomToolSortBy } from '@/lib/api/contracts/v2/custom-tools' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('CustomToolsOperations') @@ -136,12 +139,35 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st * scoped in every direction, so it uses these instead — a caller holding a * workspace key must never reach another user's personal tool. */ -export async function listWorkspaceCustomTools(params: { workspaceId: string }) { +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so tools sharing a timestamp still come + * back in a stable order. + */ +const CUSTOM_TOOL_SORTS = { + title: [customTools.title, customTools.id], + createdAt: [customTools.createdAt, customTools.id], + updatedAt: [customTools.updatedAt, customTools.id], +} satisfies Record + +export async function listWorkspaceCustomTools(params: { + workspaceId: string + /** Case-insensitive substring match on the tool title. */ + search?: string + sortBy?: V2CustomToolSortBy + sortOrder?: V2SortOrder +}) { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db .select() .from(customTools) - .where(eq(customTools.workspaceId, params.workspaceId)) - .orderBy(desc(customTools.createdAt)) + .where( + and( + eq(customTools.workspaceId, params.workspaceId), + searchFilter(customTools.title, params.search) + ) + ) + .orderBy(...listOrderBy(CUSTOM_TOOL_SORTS[sortBy], sortOrder)) } export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 4746cc0d5f7..daf82b2d285 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, type Column, desc, eq, ne } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2SkillSortBy } from '@/lib/api/contracts/v2/skills' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' import { getEditableSkillIds } from '@/lib/skills/access' import { @@ -32,6 +35,32 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski } } +type SkillRow = typeof skill.$inferSelect + +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so skills sharing a timestamp still + * come back in a stable order. + */ +const SKILL_SORTS = { + name: [skill.name, skill.id], + createdAt: [skill.createdAt, skill.id], + updatedAt: [skill.updatedAt, skill.id], +} satisfies Record + +/** The sort key {@link SKILL_SORTS} orders on, for one row. */ +function skillSortKey(row: SkillRow, sortBy: V2SkillSortBy): [string | number, string] { + if (sortBy === 'name') return [row.name, row.id] + return [(sortBy === 'createdAt' ? row.createdAt : row.updatedAt).getTime(), row.id] +} + +function compareSkills(a: SkillRow, b: SkillRow, sortBy: V2SkillSortBy): number { + const [aKey, aId] = skillSortKey(a, sortBy) + const [bKey, bId] = skillSortKey(b, sortBy) + if (aKey !== bKey) return aKey < bKey ? -1 : 1 + return aId < bId ? -1 : aId > bId ? 1 : 0 +} + /** * List skills for a workspace, ordered by createdAt desc. Built-in template * skills are prepended (they live in code, not the DB) so they appear wherever @@ -40,23 +69,53 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * Pass `includeBuiltins: false` to return only user-created skills. The * mothership uses this for the workspace skill inventory it sees, which lists * only user-created skills and never the code-only templates. + * + * `search` and `sort` serve the public list. The DB half of both runs in the + * query; the built-ins are a small code constant with no row to order, so they + * are filtered and merged in memory — the one place a v2 list cannot push its + * sort all the way down. Passing `sort` also re-orders the built-ins into the + * requested order instead of pinning them first, so the public list is sorted + * as documented; callers that omit it keep the historical builtins-first order. + * + * The merged ordering compares names with JS string order rather than the + * database collation. Only the handful of ASCII built-in names are placed by + * it, so the two agree in practice. */ -export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { +export async function listSkills(params: { + workspaceId: string + includeBuiltins?: boolean + /** Case-insensitive substring match on the skill name. */ + search?: string + sort?: { sortBy: V2SkillSortBy; sortOrder: V2SortOrder } +}): Promise { + const sortBy = params.sort?.sortBy ?? 'createdAt' + const sortOrder = params.sort?.sortOrder ?? 'desc' + const dbRows = await db .select() .from(skill) - .where(eq(skill.workspaceId, params.workspaceId)) - .orderBy(desc(skill.createdAt)) + .where(and(eq(skill.workspaceId, params.workspaceId), searchFilter(skill.name, params.search))) + .orderBy(...listOrderBy(SKILL_SORTS[sortBy], sortOrder)) if (params.includeBuiltins === false) { return dbRows } + /** + * Restricting `dbNames` to the searched rows is safe: a DB skill only shadows + * a built-in by sharing its name, and a name that matches the search on the + * built-in matches it on the DB row too. + */ const dbNames = new Set(dbRows.map((r) => r.name.toLowerCase())) - const builtins = BUILTIN_SKILLS.filter((b) => !dbNames.has(b.name.toLowerCase())).map((b) => - builtinSkillRow(params.workspaceId, b) - ) - return [...builtins, ...dbRows] + const term = params.search?.toLowerCase() + const builtins = BUILTIN_SKILLS.filter( + (b) => !dbNames.has(b.name.toLowerCase()) && (!term || b.name.toLowerCase().includes(term)) + ).map((b) => builtinSkillRow(params.workspaceId, b)) + + if (!params.sort) return [...builtins, ...dbRows] + + const direction = sortOrder === 'asc' ? 1 : -1 + return [...builtins, ...dbRows].sort((a, b) => direction * compareSkills(a, b, sortBy)) } /** A skill row tagged with whether the caller can edit it (always false on builtins). */ diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 455c90f66c8..c6612ef276f 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -12,6 +12,8 @@ export function createMockSql() { toSQL: () => ({ sql: strings.join('?'), params: values }), /** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */ as: (alias: string) => ({ ...fragment, alias }), + /** Mirrors drizzle's `sql``…`.mapWith(Number)` result decoder. */ + mapWith: (_decoder: unknown) => fragment, } return fragment }