improvement(search): search every folder, and document real API error bodies (#6861)

* improvement(search): search every folder, and document real API error bodies

Search on Files, Tables, and Knowledge was ANDed with the open folder, so a
query only ever matched that folder's direct children — and the query was not
cleared when you entered a folder, filtering the folder you just opened down to
the same matches. A non-empty query now searches the whole workspace, a
Location column names each result's folder, and opening a folder ends the
search.

Also gives GET /api/v2/files a `recursive` flag, and replaces the single shared
OpenAPI error example — which showed `BAD_REQUEST` under every status tab — with
one real body per status.

* fix(search): discard the search term on clear instead of masking it

`useSearchFilterValue` returned the debounced term whenever the input was
non-empty, so clearing only hid the settled needle. The mask lifted on the next
keystroke while the debounce still held the pre-clear term — opening a folder
and typing within the window searched the whole workspace for the query the
user had just abandoned.

A clear now resets the settled term rather than hiding it, adjusted during
render so the reset is visible to the render that follows the clear. The
initial state is seeded from the first value so a deep-linked `?search=` still
filters on the first render.
This commit is contained in:
Waleed
2026-08-19 13:55:53 -07:00
committed by GitHub
parent 936e1acfd8
commit 4f9d5f33b0
47 changed files with 2241 additions and 624 deletions
+1
View File
@@ -239,6 +239,7 @@ sim files list [options]
| Option | Required | Description |
| --- | --- | --- |
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
| `--recursive <value>` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. |
| `--scope <value>` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. |
| `--search <value>` | No | Case-insensitive substring match against the file name. |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. |
@@ -741,6 +741,7 @@ sim files list [options]
| Option | Required | Description |
| --- | --- | --- |
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
| `--recursive <value>` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. |
| `--scope <value>` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. |
| `--search <value>` | No | Case-insensitive substring match against the file name. |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. |
+48 -75
View File
@@ -313,6 +313,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -323,16 +329,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UsageLimitExceeded": {
"description": "The workspace has exceeded its usage or billing limits.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -343,6 +345,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -353,61 +364,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Conflict": {
"description": "The request conflicts with current resource state.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"RunIdConflict": {
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"PayloadTooLarge": {
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UnsupportedMediaType": {
"description": "The request uses an unsupported media type.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Locked": {
"description": "The resource is locked and cannot be modified.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -423,16 +385,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"ClientClosedRequest": {
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -443,6 +404,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -458,6 +425,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+86 -57
View File
@@ -59,12 +59,36 @@
"name": "folderPath",
"in": "query",
"required": false,
"description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"schema": {
"description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"$ref": "#/components/schemas/FolderPathInput"
}
},
{
"name": "recursive",
"in": "query",
"required": false,
"description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
"schema": {
"description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
"enum": [
"true",
"1",
"yes",
"on",
"y",
"enabled",
"false",
"0",
"no",
"off",
"n",
"disabled"
],
"type": "string"
}
},
{
"name": "scope",
"in": "query",
@@ -2102,6 +2126,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -2112,16 +2142,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UsageLimitExceeded": {
"description": "The workspace has exceeded its usage or billing limits.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -2132,6 +2158,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -2142,6 +2177,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -2152,21 +2193,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"RunIdConflict": {
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CONFLICT",
"message": "File already exists"
}
}
}
}
@@ -2177,26 +2209,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UnsupportedMediaType": {
"description": "The request uses an unsupported media type.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Locked": {
"description": "The resource is locked and cannot be modified.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "PAYLOAD_TOO_LARGE",
"message": "Request body is too large"
}
}
}
}
@@ -2212,16 +2230,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"ClientClosedRequest": {
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -2232,6 +2249,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -2247,6 +2270,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+72 -35
View File
@@ -2047,6 +2047,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -2057,6 +2063,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -2067,6 +2079,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "USAGE_LIMIT_EXCEEDED",
"message": "Usage limit exceeded. Please upgrade your plan to continue."
}
}
}
}
@@ -2077,6 +2095,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -2087,6 +2114,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -2097,21 +2130,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"RunIdConflict": {
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CONFLICT",
"message": "Upload has already been completed"
}
}
}
}
@@ -2122,6 +2146,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "PAYLOAD_TOO_LARGE",
"message": "Request body is too large"
}
}
}
}
@@ -2132,16 +2162,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Locked": {
"description": "The resource is locked and cannot be modified.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNSUPPORTED_MEDIA_TYPE",
"message": "Request body must be sent as application/json"
}
}
}
}
@@ -2157,16 +2183,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"ClientClosedRequest": {
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -2177,6 +2202,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -2192,6 +2223,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+48 -75
View File
@@ -435,6 +435,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -445,16 +451,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UsageLimitExceeded": {
"description": "The workspace has exceeded its usage or billing limits.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -465,6 +467,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -475,61 +486,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Conflict": {
"description": "The request conflicts with current resource state.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"RunIdConflict": {
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"PayloadTooLarge": {
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UnsupportedMediaType": {
"description": "The request uses an unsupported media type.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Locked": {
"description": "The resource is locked and cannot be modified.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -545,16 +507,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"ClientClosedRequest": {
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -565,6 +526,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -580,6 +547,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+60 -55
View File
@@ -2426,6 +2426,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -2436,16 +2442,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UsageLimitExceeded": {
"description": "The workspace has exceeded its usage or billing limits.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -2456,6 +2458,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -2466,6 +2477,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -2476,21 +2493,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"RunIdConflict": {
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CONFLICT",
"message": "API key name already exists"
}
}
}
}
@@ -2501,26 +2509,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UnsupportedMediaType": {
"description": "The request uses an unsupported media type.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"Locked": {
"description": "The resource is locked and cannot be modified.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "PAYLOAD_TOO_LARGE",
"message": "Request body is too large"
}
}
}
}
@@ -2536,16 +2530,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"ClientClosedRequest": {
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -2556,6 +2549,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -2571,6 +2570,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+69 -45
View File
@@ -3821,6 +3821,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -3831,16 +3837,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UsageLimitExceeded": {
"description": "The workspace has exceeded its usage or billing limits.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -3851,6 +3853,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -3861,6 +3872,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -3871,21 +3888,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"RunIdConflict": {
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CONFLICT",
"message": "A table named \"Orders\" already exists in this workspace"
}
}
}
}
@@ -3896,16 +3904,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UnsupportedMediaType": {
"description": "The request uses an unsupported media type.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "PAYLOAD_TOO_LARGE",
"message": "Request body is too large"
}
}
}
}
@@ -3916,6 +3920,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "LOCKED",
"message": "This table is insert-locked: new rows cannot be added.",
"details": {
"lock": "insert"
}
}
}
}
}
@@ -3931,16 +3944,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"ClientClosedRequest": {
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -3951,6 +3963,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -3966,6 +3984,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+91 -10
View File
@@ -2129,6 +2129,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request"
}
}
}
}
@@ -2139,6 +2145,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "UNAUTHORIZED",
"message": "API key required"
}
}
}
}
@@ -2149,6 +2161,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "USAGE_LIMIT_EXCEEDED",
"message": "Usage limit exceeded. Please upgrade your plan to continue."
}
}
}
}
@@ -2159,6 +2177,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "FORBIDDEN",
"message": "Insufficient workspace permissions",
"details": {
"code": "INSUFFICIENT_WORKSPACE_ROLE"
}
}
}
}
}
@@ -2169,6 +2196,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "NOT_FOUND",
"message": "Not found"
}
}
}
}
@@ -2179,6 +2212,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CONFLICT",
"message": "Webhook path already in use"
}
}
}
}
@@ -2194,6 +2233,16 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CONFLICT",
"message": "Run ID has already been used",
"details": {
"code": "RUN_ID_CONFLICT",
"runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01"
}
}
}
}
}
@@ -2204,16 +2253,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
}
}
}
},
"UnsupportedMediaType": {
"description": "The request uses an unsupported media type.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "PAYLOAD_TOO_LARGE",
"message": "Request body is too large"
}
}
}
}
@@ -2224,6 +2269,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "LOCKED",
"message": "Workflow is locked"
}
}
}
}
@@ -2239,6 +2290,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded",
"details": {
"retryAfter": "2026-01-01T00:00:30.000Z"
}
}
}
}
}
@@ -2249,6 +2309,15 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "CLIENT_CLOSED_REQUEST",
"message": "Client cancelled request",
"details": {
"runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01"
}
}
}
}
}
@@ -2259,6 +2328,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
}
}
@@ -2274,6 +2349,12 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2Error"
},
"example": {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable"
}
}
}
}
+9
View File
@@ -1,4 +1,5 @@
import {
listsSubfolders,
type V2File,
v2CreateFileContract,
v2ListFilesContract,
@@ -23,12 +24,19 @@ function fileCursorFilters(query: {
scope?: string
folderPath?: string
search?: string
recursive?: boolean
}) {
return cursorScopeKey(cursorRoute(v2ListFilesContract), {
workspaceId: query.workspaceId,
scope: query.scope,
folderPath: query.folderPath,
search: query.search,
/**
* Keyed on the resolved value, not the raw parameter: omitting `recursive` beside a
* search asks for the same page as sending `recursive=true`, so keying on the parameter
* would reject a cursor between two requests that select identical rows.
*/
recursive: String(listsSubfolders(query)),
})
}
@@ -44,6 +52,7 @@ export const GET = defineV2JsonRoute({
scope: query.scope,
folderPath: query.folderPath,
search: query.search,
recursive: listsSubfolders(query),
sortBy: query.sortBy,
sortOrder: query.sortOrder,
limit: query.limit,
+7 -37
View File
@@ -1,5 +1,10 @@
import { NextResponse } from 'next/server'
import type { ZodError } from 'zod'
import {
V2_ERROR_CODE_BY_STATUS,
V2_ERROR_STATUS_BY_CODE,
type V2ErrorCode,
} from '@/lib/api/contracts/v2/error-codes'
import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query'
import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server'
@@ -21,41 +26,6 @@ import type { RateLimitResult } from '@/app/api/v1/middleware'
* the HTTP envelope.
*/
export type V2ErrorCode =
| 'BAD_REQUEST'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'PAYLOAD_TOO_LARGE'
| 'UNSUPPORTED_MEDIA_TYPE'
| 'USAGE_LIMIT_EXCEEDED'
| 'LOCKED'
| 'RATE_LIMITED'
| 'CLIENT_CLOSED_REQUEST'
| 'INTERNAL_ERROR'
| 'SERVICE_UNAVAILABLE'
const STATUS_BY_CODE: Record<V2ErrorCode, number> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
USAGE_LIMIT_EXCEEDED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
PAYLOAD_TOO_LARGE: 413,
UNSUPPORTED_MEDIA_TYPE: 415,
LOCKED: 423,
RATE_LIMITED: 429,
CLIENT_CLOSED_REQUEST: 499,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
}
const V2_CODE_BY_HTTP_STATUS: Partial<Record<number, V2ErrorCode>> = Object.fromEntries(
Object.entries(STATUS_BY_CODE).map(([code, status]) => [status, code as V2ErrorCode])
)
/**
* Every v2 response is authed, per-caller data (ids/filters appear in query
* strings) — keep it out of shared HTTP caches unconditionally.
@@ -195,7 +165,7 @@ export function v2Error(
): NextResponse {
const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message }
if (options.details !== undefined) error.details = options.details
const status = options.status ?? STATUS_BY_CODE[code]
const status = options.status ?? V2_ERROR_STATUS_BY_CODE[code]
const retryAfterSeconds = options.omitRetryAfter
? undefined
: RETRY_AFTER_SECONDS_BY_STATUS[status]
@@ -215,7 +185,7 @@ export function v2Error(
/** Renders a trusted typed HTTP error without changing the v2 envelope. */
export function v2HttpError(error: HttpError): NextResponse {
const code = V2_CODE_BY_HTTP_STATUS[error.statusCode]
const code = V2_ERROR_CODE_BY_STATUS[error.statusCode]
if (!code) return v2Error('INTERNAL_ERROR', 'Internal server error')
return v2Error(code, error.message)
}
@@ -5,6 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes'
import {
V2_WORKFLOW_RUN_ID_HEADER,
v2ExecuteWorkflowContract,
@@ -45,7 +46,7 @@ import {
hasAgentStreamPolicy,
} from '@/lib/workflows/streaming/agent-stream-protocol'
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response'
import { v2Data, v2Error } from '@/app/api/v2/lib/response'
import {
PublicApiNotAllowedError,
validatePublicApiAllowed,
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes'
import {
V2_WORKFLOW_RUN_ID_HEADER,
v2ResumeWorkflowContract,
@@ -19,7 +20,7 @@ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
import { workflowOperations } from '@/lib/workflows/application/operations'
import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run'
import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution'
import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response'
import { v2Data, v2Error } from '@/app/api/v2/lib/response'
import { classifyExecutionError } from '@/executor/utils/errors'
const logger = createLogger('V2WorkflowResumeAPI')
@@ -0,0 +1,76 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { dropRowsCarriedByDraggedFolders } from '@/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop'
/**
* reports/ (a)
* └── 2024/ (b)
* └── q3/ (c)
* archive/ (d)
*/
const PARENT_BY_FOLDER: Record<string, string | null> = { a: null, b: 'a', c: 'b', d: null }
const FOLDER_BY_RESOURCE: Record<string, string | null> = {
'file-in-a': 'a',
'file-in-c': 'c',
'file-in-d': 'd',
'file-at-root': null,
}
const DESCENDANTS = new Map<string, Set<string>>([
['a', new Set(['b', 'c'])],
['b', new Set(['c'])],
['c', new Set()],
['d', new Set()],
])
const accessors = {
descendantsByFolderId: DESCENDANTS,
getFolderParentId: (id: string) => PARENT_BY_FOLDER[id],
getResourceFolderId: (id: string) => FOLDER_BY_RESOURCE[id],
}
const prune = (folderIds: string[], resourceIds: string[]) =>
dropRowsCarriedByDraggedFolders({ folderIds, resourceIds }, accessors)
describe('dropRowsCarriedByDraggedFolders', () => {
it('leaves a move with no folders untouched', () => {
expect(prune([], ['file-in-a', 'file-in-d'])).toEqual({
folderIds: [],
resourceIds: ['file-in-a', 'file-in-d'],
})
})
it('drops a file that the dragged folder directly contains', () => {
expect(prune(['a'], ['file-in-a', 'file-in-d'])).toEqual({
folderIds: ['a'],
resourceIds: ['file-in-d'],
})
})
it('drops a file nested deeper inside the dragged folder', () => {
expect(prune(['a'], ['file-in-c'])).toEqual({ folderIds: ['a'], resourceIds: [] })
})
it('drops a descendant folder dragged alongside its ancestor', () => {
expect(prune(['a', 'c'], [])).toEqual({ folderIds: ['a'], resourceIds: [] })
expect(prune(['a', 'b', 'c'], [])).toEqual({ folderIds: ['a'], resourceIds: [] })
})
it('keeps unrelated folders and root-level files', () => {
expect(prune(['a', 'd'], ['file-at-root'])).toEqual({
folderIds: ['a', 'd'],
resourceIds: ['file-at-root'],
})
})
it('never drops the only dragged folder', () => {
expect(prune(['c'], [])).toEqual({ folderIds: ['c'], resourceIds: [] })
})
it('can empty the move entirely when every row rides along', () => {
expect(prune(['a'], ['file-in-a'])).toEqual({ folderIds: ['a'], resourceIds: [] })
expect(prune(['a', 'b'], ['file-in-c'])).toEqual({ folderIds: ['a'], resourceIds: [] })
})
})
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
folderLocationLabel,
isSearchingResources,
scopeFolderedItems,
} from '@/app/workspace/[workspaceId]/components/folders/folder-search-scope'
interface Item {
id: string
name: string
description?: string | null
folderId: string | null
}
const ITEMS: Item[] = [
{ id: 'root-report', name: 'report.pdf', folderId: null },
{ id: 'a-report', name: 'report.pdf', folderId: 'a' },
{ id: 'b-budget', name: 'budget.xlsx', folderId: 'b' },
{ id: 'a-notes', name: 'notes.md', description: 'quarterly report', folderId: 'a' },
]
const scope = (currentFolderId: string | null, search: string) =>
scopeFolderedItems(ITEMS, {
currentFolderId,
search,
getParentId: (item) => item.folderId,
getSearchText: (item) => [item.name],
}).map((item) => item.id)
describe('scopeFolderedItems', () => {
it('shows only direct children when there is no query', () => {
expect(scope(null, '')).toEqual(['root-report'])
expect(scope('a', '')).toEqual(['a-report', 'a-notes'])
})
it('treats a whitespace-only query as no query', () => {
expect(scope('a', ' ')).toEqual(['a-report', 'a-notes'])
})
it('searches every folder, not just the open one', () => {
expect(scope('b', 'report')).toEqual(['root-report', 'a-report'])
})
it('finds nested matches from the workspace root', () => {
expect(scope(null, 'budget')).toEqual(['b-budget'])
})
it('matches case-insensitively', () => {
expect(scope(null, 'BUDGET')).toEqual(['b-budget'])
})
it('matches any of several fields independently', () => {
const ids = scopeFolderedItems(ITEMS, {
currentFolderId: null,
search: 'quarterly',
getParentId: (item) => item.folderId,
getSearchText: (item) => [item.name, item.description],
}).map((item) => item.id)
expect(ids).toEqual(['a-notes'])
})
it('never lets a query straddle two fields', () => {
const ids = scopeFolderedItems([{ id: 'x', name: 'ab', description: 'cd', folderId: null }], {
currentFolderId: null,
search: 'bc',
getParentId: (item) => item.folderId,
getSearchText: (item) => [item.name, item.description],
})
expect(ids).toEqual([])
})
it('tolerates absent fields', () => {
const ids = scopeFolderedItems(ITEMS, {
currentFolderId: null,
search: 'report',
getParentId: (item) => item.folderId,
getSearchText: (item) => [item.description],
}).map((item) => item.id)
expect(ids).toEqual(['a-notes'])
})
})
describe('isSearchingResources', () => {
it('ignores whitespace-only queries', () => {
expect(isSearchingResources('')).toBe(false)
expect(isSearchingResources(' ')).toBe(false)
expect(isSearchingResources('a')).toBe(true)
})
})
describe('folderLocationLabel', () => {
const folders = new Map([
['a', { id: 'a', name: 'Projects', parentId: null }],
['b', { id: 'b', name: 'Q3', parentId: 'a' }],
['orphan', { id: 'orphan', name: 'Lost', parentId: 'gone' }],
])
it('names the root when the item sits at the root', () => {
expect(folderLocationLabel(null, folders, 'Files')).toBe('Files')
expect(folderLocationLabel(undefined, folders, 'Files')).toBe('Files')
})
it('joins the ancestor chain root-first', () => {
expect(folderLocationLabel('a', folders, 'Files')).toBe('Projects')
expect(folderLocationLabel('b', folders, 'Files')).toBe('Projects / Q3')
})
it('says it does not know rather than claiming a partial path or the root', () => {
expect(folderLocationLabel('orphan', folders, 'Files')).toBe('Unknown')
expect(folderLocationLabel('missing', folders, 'Files')).toBe('Unknown')
})
})
@@ -0,0 +1,111 @@
import type { BreadcrumbFolder } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
import { breadcrumbFolderChain } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
import type {
ResourceCell,
ResourceColumn,
} from '@/app/workspace/[workspaceId]/components/resource/resource'
/**
* Whether the list is showing search results rather than a folder's contents.
*
* The distinction drives more than the filter: while searching, a row's folder is no
* longer implied by the page, so the list has to say where each row lives, and an empty
* result is a failed search rather than an empty place.
*/
export function isSearchingResources(search: string): boolean {
return search.trim().length > 0
}
/**
* The rows a foldered list should show.
*
* With no query the list is a place: the open folder's direct children, and nothing else.
* With a query it stops being a place and searches the whole workspace, because a name you
* only half-remember is precisely the case where you do not know which folder it is in.
* Intersecting the two — matching the query only against the open folder — answers a
* question nobody asks, and is indistinguishable from "no such file" when the file exists
* one level down.
*
* Callers pass an already-debounced query; this runs on every keystroke's worth of state.
*/
export function scopeFolderedItems<T>(
items: readonly T[],
{
currentFolderId,
search,
getParentId,
getSearchText,
}: {
/** The open folder, or `null` at the workspace root. */
currentFolderId: string | null
search: string
getParentId: (item: T) => string | null
/**
* The item's searchable fields. Matched one at a time rather than joined, so a query can
* never straddle two of them — concatenating a name and a description would let the tail
* of one and the head of the other match text that appears nowhere.
*/
getSearchText: (item: T) => readonly (string | null | undefined)[]
}
): T[] {
if (!isSearchingResources(search)) {
return items.filter((item) => getParentId(item) === currentFolderId)
}
const needle = search.trim().toLowerCase()
return items.filter((item) =>
getSearchText(item).some((field) => field?.toLowerCase().includes(needle))
)
}
/**
* What the location column shows when a row's folder cannot be resolved to a full path —
* its ancestor chain is broken, so the honest answer is that we do not know where it is.
*
* Deliberately not `rootLabel`: this column exists to answer "where does this row live", and
* naming the workspace root there is a specific wrong answer rather than a missing one. The
* root case is a row with no folder at all, which is genuinely at the root.
*/
export const UNKNOWN_FOLDER_LOCATION = 'Unknown'
/**
* Where a row lives, for the list's location column — ancestor names root-first joined by
* `/`, or `rootLabel` for a row sitting at the workspace root.
*
* A chain that does not reach the root yields {@link UNKNOWN_FOLDER_LOCATION}, matching
* {@link breadcrumbFolderChain}'s own rule that a partial path is not a shorter path, it is
* a wrong one. Reachable when an ancestor folder was archived out from under the row.
*/
export function folderLocationLabel<T extends BreadcrumbFolder>(
folderId: string | null | undefined,
folderById: ReadonlyMap<string, T>,
rootLabel: string
): string {
if (!folderId) return rootLabel
const chain = breadcrumbFolderChain(folderId, folderById)
return chain.length > 0 ? chain.map((folder) => folder.name).join(' / ') : UNKNOWN_FOLDER_LOCATION
}
/**
* The column naming each row's folder, carried only while searching: results span every
* folder, so a name alone no longer says where a row lives. When the list is a folder's
* contents the breadcrumb already says it.
*
* Defined here rather than per page so one conceptual column has one header and one width,
* and beside {@link folderLocationLabel} because that is what fills it. Each page appends it
* to its own columns at module scope, so the table swaps between two stable arrays rather
* than building one per render.
*/
export const FOLDER_LOCATION_COLUMN: ResourceColumn = {
id: 'location',
header: 'Location',
widthMultiplier: 1.1,
}
/**
* The location cell a row carries while the list is not searching.
*
* {@link FOLDER_LOCATION_COLUMN} is absent then, so nothing renders this — which is the
* point: resolving an ancestor chain per row to fill a column that is not on screen is work
* every row throws away. Shared and empty so the skipped cell still has a stable identity.
*/
export const EMPTY_LOCATION_CELL: ResourceCell = {}
@@ -7,6 +7,13 @@ export type { FolderRowOptions } from './folder-row'
export { folderRow } from './folder-row'
export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id'
export { folderRowId, parseFolderedRowId, splitFolderedRowIds } from './folder-row-id'
export {
EMPTY_LOCATION_CELL,
FOLDER_LOCATION_COLUMN,
folderLocationLabel,
isSearchingResources,
scopeFolderedItems,
} from './folder-search-scope'
export type {
FolderedHeaderResourceType,
FolderedResourceHeaderMeta,
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useQueryStates } from 'nuqs'
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
import {
@@ -15,17 +15,36 @@ import {
export interface UseFolderNavigationOptions {
resourceType: ServedFolderResourceType
workspaceId?: string
/**
* Runs before {@link FolderNavigation.openFolder} moves, for state the destination
* invalidates — in practice, clearing the list's search. Not run by
* {@link FolderNavigation.setCurrentFolderId}.
*/
onBeforeOpenFolder?: () => void
}
export interface FolderNavigation extends FolderAncestors {
/** The open folder, or `null` at the workspace root. */
currentFolderId: string | null
/**
* Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose
* to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen
* navigation, such as the second and later spring-opens within a single drag.
* Moves to a folder without side effects. For writes that are not a chosen navigation —
* a spring-open mid-drag, which is undone when the drag ends without a drop, or the heal
* below. Pass `{ history: 'replace' }` to keep such a write out of the back stack.
*/
setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void
/**
* Opens a folder because the user chose to, running {@link
* UseFolderNavigationOptions.onBeforeOpenFolder} first.
*
* Separate from {@link FolderNavigation.setCurrentFolderId} because opening a folder ends a
* search — the results span every folder, so the one the user picked out of them is a
* destination, not a narrower place to keep searching — while a spring-open must not, or an
* abandoned drag would discard the search that produced the row being dragged.
*
* Defaults to the param group's `history: 'push'`: a chosen folder is a destination, and
* Back returns to the results that led there.
*/
openFolder: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void
}
/**
@@ -40,6 +59,7 @@ export interface FolderNavigation extends FolderAncestors {
export function useFolderNavigation({
resourceType,
workspaceId,
onBeforeOpenFolder,
}: UseFolderNavigationOptions): FolderNavigation {
const [{ folderId: currentFolderId }, setFolderParams] = useQueryStates(
folderNavParsers,
@@ -60,6 +80,22 @@ export function useFolderNavigation({
[setFolderParams]
)
const onBeforeOpenFolderRef = useRef(onBeforeOpenFolder)
onBeforeOpenFolderRef.current = onBeforeOpenFolder
/**
* Both writes land in one URL update: nuqs batches same-tick writes across param groups and
* escalates the batch to `push` when any of them pushes, so clearing a `history: 'replace'`
* search alongside the folder change stays a single history entry.
*/
const openFolder = useCallback(
(folderId: string | null, options?: { history?: 'push' | 'replace' }) => {
onBeforeOpenFolderRef.current?.()
void setFolderParams({ folderId }, options)
},
[setFolderParams]
)
/**
* Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a
* link from someone whose workspace it was not.
@@ -84,5 +120,5 @@ export function useFolderNavigation({
void setFolderParams({ folderId: null }, { history: 'replace' })
}, [foldersResolved, currentFolderId, folderById, setFolderParams])
return { ...ancestry, currentFolderId, setCurrentFolderId }
return { ...ancestry, currentFolderId, setCurrentFolderId, openFolder }
}
@@ -72,6 +72,47 @@ export interface FolderedRowMove {
resourceIds: string[]
}
/**
* Drops every row that one of the dragged folders already carries.
*
* Moving a folder takes its contents with it, so naming both a folder and something inside it
* would move the parent AND separately pull the child out of it: the two land as siblings and
* the hierarchy the user dragged is gone. The folder wins, because it is the thing they
* grabbed the outside of.
*
* Only reachable since search began returning rows from across the workspace — a list showing
* one folder's direct children can never show a row alongside its own ancestor.
*/
export function dropRowsCarriedByDraggedFolders(
move: FolderedRowMove,
{
descendantsByFolderId,
getFolderParentId,
getResourceFolderId,
}: {
descendantsByFolderId: Map<string, Set<string>>
getFolderParentId: (folderId: string) => string | null | undefined
getResourceFolderId: (resourceId: string) => string | null | undefined
}
): FolderedRowMove {
const draggedFolderIds = new Set(move.folderIds)
if (draggedFolderIds.size === 0) return move
const isCarried = (ownerFolderId: string | null | undefined): boolean => {
if (!ownerFolderId) return false
for (const folderId of draggedFolderIds) {
if (ownerFolderId === folderId) return true
if (descendantsByFolderId.get(folderId)?.has(ownerFolderId)) return true
}
return false
}
return {
folderIds: move.folderIds.filter((id) => !isCarried(getFolderParentId(id))),
resourceIds: move.resourceIds.filter((id) => !isCarried(getResourceFolderId(id))),
}
}
export interface UseFolderRowDragDropOptions {
/**
* This list's private drag MIME. Each surface owns one so a drag started in another list is
@@ -121,6 +162,21 @@ export interface UseFolderRowDragDropOptions {
* empty folder, which has no row to drop on.
*/
currentFolderId?: string | null
/**
* The folder the list body drops into, or `undefined` when the body does not stand for a
* folder at all and must decline.
*
* It differs from {@link currentFolderId} whenever the visible rows are not that folder's
* contents. Dropping on the blank area below the rows would then file a row into a folder
* the drop UI never names — harmless when every row already lives there (the target
* declines the no-op), destructive when they do not. A row drop is unaffected: that target
* names its own destination.
*
* Required, and deliberately without a default: a destructuring default fires on an
* explicit `undefined` too, so `= currentFolderId` would silently swallow the very value a
* searching caller passes to decline and leave the guard unreachable.
*/
bodyDropFolderId: string | null | undefined
/**
* OS file drops, which Files accepts and the other lists do not.
*
@@ -157,6 +213,7 @@ export function useFolderRowDragDrop({
selection,
onSpringOpenFolder,
currentFolderId = null,
bodyDropFolderId,
externalDrop,
}: UseFolderRowDragDropOptions): FolderRowDragDrop {
const [activeDropTarget, setActiveDropTarget] = useState<ActiveDropTarget | null>(null)
@@ -192,6 +249,9 @@ export function useFolderRowDragDrop({
const currentFolderIdRef = useRef(currentFolderId)
currentFolderIdRef.current = currentFolderId
const bodyDropFolderIdRef = useRef(bodyDropFolderId)
bodyDropFolderIdRef.current = bodyDropFolderId
const dragGhost = useRowDragGhost()
/** Returns the list to its resting state once a drag is over, however it ended. */
@@ -207,9 +267,10 @@ export function useFolderRowDragDrop({
/**
* Splits the drag into the rows that would actually move into `targetFolderId`, dropping any
* row already sitting directly there. `null` when the drop is illegal outright — the target is
* one of the dragged folders or inside one, which would orphan a subtree into itself — or when
* nothing would actually change.
* row already sitting directly there and any row a dragged folder already carries (see
* {@link dropRowsCarriedByDraggedFolders}). `null` when the drop is illegal outright — the
* target is one of the dragged folders or inside one, which would orphan a subtree into
* itself — or when nothing would actually change.
*
* Takes a folder id rather than a row id because the destination is not always a row: the
* list body files into the folder currently open, which has no row of its own, and `null`
@@ -235,8 +296,12 @@ export function useFolderRowDragDrop({
resourceIds.push(source.id)
}
if (folderIds.length === 0 && resourceIds.length === 0) return null
return { folderIds, resourceIds }
const moved = dropRowsCarriedByDraggedFolders(
{ folderIds, resourceIds },
{ descendantsByFolderId, getFolderParentId, getResourceFolderId }
)
if (moved.folderIds.length === 0 && moved.resourceIds.length === 0) return null
return moved
},
[]
)
@@ -437,9 +502,11 @@ export function useFolderRowDragDrop({
* early return would leave the body overlay showing from the previous folder. Setting
* the same value repeatedly is free — React bails on an unchanged state write.
*/
const targetFolderId = bodyDropFolderIdRef.current
const canDrop =
targetFolderId !== undefined &&
sourceRowIds.length > 0 &&
resolveMoveToFolder(currentFolderIdRef.current, sourceRowIds) !== null
resolveMoveToFolder(targetFolderId, sourceRowIds) !== null
setActiveDropTarget((current) =>
canDrop ? armDropTarget(current, { kind: 'body' }) : null
)
@@ -454,17 +521,19 @@ export function useFolderRowDragDrop({
},
onDrop: (e: DragEvent<HTMLDivElement>) => {
if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return
/**
* Read from the ref, not the closure. This config is memoized, and during a drag the
* only dep that routinely changes is the hovered row — so after a spring-open into an
* empty folder, which has no rows to hover, a captured folder id would still name
* the folder the drag came FROM and file the rows back into it.
*/
const targetFolderId = bodyDropFolderIdRef.current
/** The body never armed, so a release here is a miss rather than a move. */
if (targetFolderId === undefined) return
e.preventDefault()
e.stopPropagation()
const sourceRowIds =
readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current
/**
* Read from the ref, not the closure. This config is memoized, and during a drag the
* only dep that routinely changes is the hovered row — so after a spring-open into an
* empty folder, which has no rows to hover, a captured `currentFolderId` would still
* name the folder the drag came FROM and file the rows back into it.
*/
const targetFolderId = currentFolderIdRef.current
const move =
sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null
if (move) springNav.markDropHandled()
@@ -1,4 +1,7 @@
export { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty'
export {
isResourceListEmpty,
resourceListState,
} from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty'
export { ResourceNotFound } from '@/app/workspace/[workspaceId]/components/resource/resource-not-found'
export { ConversationListItem } from './conversation-list-item'
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
@@ -2,4 +2,5 @@ export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/re
export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state'
export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state'
export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state'
export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results'
export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state'
@@ -0,0 +1,49 @@
import { Chip } from '@sim/emcn'
import { EmptyState } from '@/components/empty-state/empty-state'
interface ResourceNoResultsProps {
/**
* The query the rows were actually filtered by — the debounced value, never the instant
* URL one, or the copy names a query the list has not run yet. Empty when only filters
* narrowed the list.
*/
search: string
/** Applied filter chips, so the copy can name filters as the thing that matched nothing. */
filterCount: number
/** Clears the query and every filter, restoring the open folder's contents. */
onClear: () => void
}
/**
* What a foldered list shows when a search or filter matched nothing.
*
* Distinct from the resource's zero-data graphic, which invites you to create your first
* item and would be a lie here — see {@link resourceListState}. Without this the table
* renders an unexplained blank, which reads as a broken page rather than an empty result.
*
* Both descriptions state a scope the user cannot otherwise see, which is what earns them a
* line at all: a search spans every folder, so a bare "no results" while standing inside one
* invites exactly the wrong conclusion — that they should go looking elsewhere themselves —
* while filters narrow only the open folder. The search branch wins when both are set,
* because the wider scope is the more surprising of the two.
*/
export function ResourceNoResults({ search, filterCount, onClear }: ResourceNoResultsProps) {
const trimmed = search.trim()
return (
<EmptyState
title={trimmed ? `No results for “${trimmed}”` : 'No results'}
description={
trimmed ? 'Searched every folder in this workspace.' : 'Filters apply to this folder only.'
}
action={
<Chip variant='border' onClick={onClear}>
{trimmed && filterCount > 0
? 'Clear search and filters'
: trimmed
? 'Clear search'
: 'Clear filters'}
</Chip>
}
/>
)
}
@@ -42,7 +42,24 @@ interface ResourceListEmptyInput {
* - The folder tree still resolving, for the same reason as the rows themselves: a
* workspace whose only contents are folders reads as empty until they land.
*/
export function isResourceListEmpty({
export function isResourceListEmpty(input: ResourceListEmptyInput): boolean {
return resourceListState(input) === 'empty'
}
/**
* What a resource list should render in place of rows.
*
* - `rows` — rows are showing, or none have settled yet, so the table renders itself.
* - `empty` — the workspace genuinely holds nothing; the zero-data graphic is the true answer.
* - `no-results` — a search or filter matched nothing. Distinct from `empty`, because the
* copy that invites you to create your first item would be a lie, and distinct from `rows`,
* because rendering neither leaves an unexplained blank table.
*
* One function rather than two predicates: the two states share every "the rows have actually
* arrived" condition, and when those lived in both places a new condition could be added to
* one and silently left off the other.
*/
export function resourceListState({
rowCount,
isLoading,
isPlaceholderData,
@@ -51,15 +68,11 @@ export function isResourceListEmpty({
filterCount,
folderId = null,
foldersResolved = true,
}: ResourceListEmptyInput): boolean {
return (
rowCount === 0 &&
!isLoading &&
!isPlaceholderData &&
!error &&
foldersResolved &&
folderId === null &&
!search.trim() &&
filterCount === 0
)
}: ResourceListEmptyInput): 'rows' | 'empty' | 'no-results' {
const settled = rowCount === 0 && !isLoading && !isPlaceholderData && !error && foldersResolved
if (!settled) return 'rows'
const narrowed = Boolean(search.trim()) || filterCount > 0
if (narrowed) return 'no-results'
/** An empty subfolder is not an empty workspace, so it gets neither state. */
return folderId === null ? 'empty' : 'rows'
}
@@ -59,10 +59,10 @@ import type {
import {
EMPTY_CELL_PLACEHOLDER,
FILTER_SECTION_LABEL_CLASS,
isResourceListEmpty,
OwnerAvatar,
ownerCell,
Resource,
resourceListState,
selectionLabel,
timeCell,
useResourceRowSelection,
@@ -75,18 +75,26 @@ import {
breadcrumbFolderChain,
buildDescendantIndex,
buildMoveOptionsExcludingSubtrees,
EMPTY_LOCATION_CELL,
FOLDER_LOCATION_COLUMN,
FOLDERED_RESOURCE_HEADERS,
folderBreadcrumbItems,
folderedResourceListHref,
folderLocationLabel,
folderRowId,
isSearchingResources,
parseFolderedRowId,
parseMoveOptionValue,
scopeFolderedItems,
sortResources,
splitFolderedRowIds,
useFolderRowDragDrop,
} from '@/app/workspace/[workspaceId]/components/folders'
import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar'
import { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import {
FilesEmptyState,
ResourceNoResults,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal'
import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -136,10 +144,10 @@ import {
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
import { useUrlSort } from '@/hooks/use-url-sort'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
@@ -193,6 +201,13 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'updated', header: 'Last Updated' },
]
/**
* Deliberately absent from {@link filesSortParams}, so the location column is not offered in
* the sort menu — those columns are a URL contract, and ordering by path is not worth
* persisting in a shared link before anyone asks for it.
*/
const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN]
const MIME_TYPE_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'application/zip': 'ZIP',
@@ -368,7 +383,30 @@ export function Files() {
(value, options) => setFileFilters({ search: value }, options),
{ debounceMs: FILES_SEARCH_DEBOUNCE_MS }
)
const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS)
const debouncedSearchTerm = useSearchFilterValue(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS)
/**
* Files' equivalent of `useFolderNavigation`'s `openFolder`, which Tables and Knowledge
* use — kept local because this page owns its own param group and has to clear `new` in
* the same batch. Change the two together.
*
* Opens a folder, clearing any active query on the way in. Search spans every folder, so a
* folder in the results is a destination the user picked out of them — not a narrower place
* to keep searching. Carrying the term across would filter the folder they just opened down
* to the same matches they were already looking at, which is how this read as "the folder is
* empty".
*
* The writes land in one URL update: nuqs batches same-tick writes across param groups and
* escalates the batch to `push`, so this stays a single history entry and Back returns to
* the results that led here.
*/
const navigateToFolder = useCallback(
(folderId: string | null, options?: { history?: 'push' | 'replace' }) => {
setSearchTerm('')
void setFilesParams({ folderId, new: null }, options)
},
[setSearchTerm, setFilesParams]
)
const {
sort: sortColumn,
@@ -528,21 +566,31 @@ export function Files() {
return totalSize
}, [files, folders])
const visibleFolders = useMemo(() => {
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
const needle = debouncedSearchTerm.trim().toLowerCase()
return needle
? siblings.filter((folder) => folder.name.toLowerCase().includes(needle))
: siblings
}, [folders, currentFolderId, debouncedSearchTerm])
/**
* A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. A
* matching folder anywhere in the workspace is a result in its own right, since opening it
* is often what the user was looking for.
*/
const isSearching = isSearchingResources(debouncedSearchTerm)
const visibleFolders = useMemo(
() =>
scopeFolderedItems(folders, {
currentFolderId,
search: debouncedSearchTerm,
getParentId: (folder) => folder.parentId ?? null,
getSearchText: (folder) => [folder.name],
}),
[folders, currentFolderId, debouncedSearchTerm]
)
const filteredFiles = useMemo(() => {
const needle = debouncedSearchTerm.trim().toLowerCase()
let result = needle
? files.filter(
(f) => (f.folderId ?? null) === currentFolderId && f.name.toLowerCase().includes(needle)
)
: files.filter((f) => (f.folderId ?? null) === currentFolderId)
let result = scopeFolderedItems(files, {
currentFolderId,
search: debouncedSearchTerm,
getParentId: (f) => f.folderId ?? null,
getSearchText: (f) => [f.name],
})
if (typeFilter.length > 0) {
result = result.filter((f) => {
@@ -666,6 +714,16 @@ export function Files() {
created: timeCell(folder.createdAt),
owner: ownerCell(folder.userId, membersById),
updated: timeCell(folder.updatedAt),
/**
* A folder's location is its parent's path, not its own. Built only while
* searching: the column is absent otherwise, so resolving an ancestor chain
* per row would be work every row throws away.
*/
location: isSearching
? {
label: folderLocationLabel(folder.parentId, folderById, FILES_HEADER.rootLabel),
}
: EMPTY_LOCATION_CELL,
},
}
}
@@ -690,10 +748,13 @@ export function Files() {
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, membersById),
updated: timeCell(file.updatedAt),
location: isSearching
? { label: folderLocationLabel(file.folderId, folderById, FILES_HEADER.rootLabel) }
: EMPTY_LOCATION_CELL,
},
}
}),
[sortedEntries, membersById, folderSizeMap]
[sortedEntries, membersById, folderSizeMap, folderById, isSearching]
)
const rows: ResourceRow[] = useMemo(() => {
@@ -743,6 +804,14 @@ export function Files() {
async (filesToUpload: File[], targetFolderId = currentFolderId) => {
if (!workspaceId || filesToUpload.length === 0 || !canEdit) return
/**
* Uploads land in a folder, but a live query is showing results from across the
* workspace and an uploaded name rarely matches it — the new rows would not render and
* the upload would read as having failed. Cleared up front so the list is already
* showing the destination as the progress counter runs.
*/
setSearchTerm('')
const oversized: string[] = []
const sizeFiltered = filesToUpload.filter((f) => {
if (f.size > MAX_WORKSPACE_FILE_SIZE) {
@@ -807,7 +876,7 @@ export function Files() {
setUploadProgress({ completed: 0, total: 0, currentPercent: 0 })
}
},
[workspaceId, canEdit, currentFolderId, notifyLimit]
[workspaceId, canEdit, currentFolderId, notifyLimit, setSearchTerm]
)
const rowDragDropConfig = useFolderRowDragDrop({
@@ -830,10 +899,20 @@ export function Files() {
.catch((error) => logger.error('Failed to move items:', error))
},
selection: { selectedRowIds, visibleRowIds, replaceSelection },
/**
* Moves the folder without touching the query, unlike every other navigation here.
*
* A spring-open is a step inside a drag, not a destination the user chose, and it is
* undone when the drag ends without a drop. Clearing the query on the way in would be
* clearing it on the way back out too — the restore runs through this same callback —
* so an abandoned drag would silently discard the search that produced the row being
* dragged, with `history: 'replace'` leaving nothing for Back to recover.
*/
onSpringOpenFolder: (folderId, options) => {
void setFilesParams({ folderId, new: null }, options)
},
currentFolderId,
bodyDropFolderId: isSearching ? undefined : currentFolderId,
/**
* The one thing this list does that the others do not. Folder rows still highlight and
* spring open for an OS file drag — filing an upload into a nested folder is the same
@@ -1171,12 +1250,19 @@ export function Files() {
name,
parentId: currentFolderId,
})
/**
* The new folder goes into the open folder, but a live query is showing results from
* across the workspace and "New folder" almost never matches it — the row would not
* render and the rename it opens would have nothing to attach to, so creating would
* read as having done nothing at all.
*/
setSearchTerm('')
listRename.startRename(folderRowId(folder.id), folder.name)
} catch (error) {
logger.error('Failed to create folder:', error)
toast.error(toError(error).message)
}
}, [workspaceId, folders, currentFolderId, listRename.startRename])
}, [workspaceId, folders, currentFolderId, listRename.startRename, setSearchTerm])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
@@ -1202,7 +1288,7 @@ export function Files() {
const item = contextMenuItemRef.current
if (!item) return
if (item.kind === 'folder') {
void setFilesParams({ folderId: item.folder.id, new: null })
navigateToFolder(item.folder.id)
closeContextMenu()
return
}
@@ -1212,7 +1298,7 @@ export function Files() {
: `/workspace/${workspaceId}/files/${item.file.id}`
)
closeContextMenu()
}, [closeContextMenu, router, workspaceId, setFilesParams])
}, [closeContextMenu, router, workspaceId, navigateToFolder])
const handleContextMenuDownload = useCallback(() => {
const item = contextMenuItemRef.current
@@ -1448,7 +1534,7 @@ export function Files() {
if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) {
const parsed = parseFolderedRowId(rowId)
if (parsed.kind === 'folder') {
void setFilesParams({ folderId: parsed.id, new: null })
navigateToFolder(parsed.id)
return
}
const file = fileByIdRef.current.get(parsed.id)
@@ -1456,14 +1542,20 @@ export function Files() {
setExtractTargetId(file.id)
return
}
/**
* The file's own folder, not the open one. A search result usually lives elsewhere,
* and this param is what the viewer returns to on close or delete — carrying the open
* folder would send the user to a folder the file was never in.
*/
const fileFolderId = file?.folderId ?? null
router.push(
currentFolderId
? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}`
fileFolderId
? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${fileFolderId}`
: `/workspace/${workspaceId}/files/${parsed.id}`
)
}
},
[router, workspaceId, currentFolderId, setFilesParams]
[router, workspaceId, navigateToFolder]
)
const handleExtract = async () => {
@@ -1546,13 +1638,6 @@ export function Files() {
]
)
const handleNavigateToListFolder = useCallback(
(folderId: string | null) => {
void setFilesParams({ folderId, new: null })
},
[setFilesParams]
)
const listFolderChain = useMemo(
() => breadcrumbFolderChain(currentFolderId, folderById),
[currentFolderId, folderById]
@@ -1587,7 +1672,7 @@ export function Files() {
rootLabel: FILES_HEADER.rootLabel,
rootIcon: FILES_HEADER.rootIcon,
breadcrumbs: listFolderChain,
onNavigate: handleNavigateToListFolder,
onNavigate: navigateToFolder,
currentFolderEditing:
openListFolder && breadcrumbRename.editingId === openListFolder.id
? {
@@ -1614,7 +1699,7 @@ export function Files() {
[
listFolderChain,
openListFolder,
handleNavigateToListFolder,
navigateToFolder,
canEdit,
userPermissions.isLoading,
breadcrumbRename.editingId,
@@ -1817,7 +1902,7 @@ export function Files() {
return tags
}, [typeFilter, sizeFilter, uploadedByFilter, membersById])
const showEmptyState = isResourceListEmpty({
const listState = resourceListState({
rowCount: rows.length,
isLoading,
isPlaceholderData,
@@ -1828,6 +1913,11 @@ export function Files() {
foldersResolved,
})
const clearSearchAndFilters = () => {
setSearchTerm('')
void setFileFilters({ type: null, size: null, uploadedBy: null })
}
if (fileIdFromRoute && !selectedFile && isLoading) {
return (
<Resource>
@@ -1928,14 +2018,20 @@ export function Files() {
filter={filterConfig}
/>
<Resource.Table
columns={COLUMNS}
columns={isSearching ? SEARCH_COLUMNS : COLUMNS}
rows={rows}
emptyState={
showEmptyState ? (
listState === 'empty' ? (
<FilesEmptyState
onUpload={handleUploadClick}
uploadDisabled={uploading || !canEdit}
/>
) : listState === 'no-results' ? (
<ResourceNoResults
search={debouncedSearchTerm}
filterCount={filterTags.length}
onClear={clearSearchAndFilters}
/>
) : undefined
}
selectable={selectableConfig}
@@ -24,11 +24,11 @@ import type {
import {
EMPTY_CELL_PLACEHOLDER,
FILTER_SECTION_LABEL_CLASS,
isResourceListEmpty,
OwnerAvatar,
ownerCell,
Resource,
reportBulkOutcome,
resourceListState,
selectionLabel,
timeCell,
useResourceRowSelection,
@@ -41,21 +41,29 @@ import {
buildDescendantIndex,
buildMoveOptions,
buildMoveOptionsExcludingSubtrees,
EMPTY_LOCATION_CELL,
FOLDER_LOCATION_COLUMN,
FOLDERED_RESOURCE_HEADERS,
FolderContextMenu,
folderBreadcrumbItems,
folderLocationLabel,
folderRow,
folderRowId,
isSearchingResources,
nextUntitledFolderName,
parseFolderedRowId,
parseMoveOptionValue,
scopeFolderedItems,
sortResources,
splitFolderedRowIds,
useFolderNavigation,
useFolderRowDragDrop,
} from '@/app/workspace/[workspaceId]/components/folders'
import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar'
import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import {
KnowledgeEmptyState,
ResourceNoResults,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
CreateBaseModal,
@@ -69,7 +77,6 @@ import {
knowledgeSortParams,
knowledgeUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/search-params'
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -84,10 +91,10 @@ import {
} from '@/hooks/queries/kb/knowledge'
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
import { useUrlSort } from '@/hooks/use-url-sort'
import type { WorkflowFolder } from '@/stores/folders/types'
@@ -112,6 +119,8 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'updated', header: 'Last Updated' },
]
const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN]
const KNOWLEDGE_BASE_ICON = <Database className='size-[14px]' />
const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [
@@ -230,6 +239,7 @@ export function Knowledge() {
const {
currentFolderId,
setCurrentFolderId,
openFolder,
ancestors: breadcrumbs,
folders,
folderById,
@@ -237,6 +247,8 @@ export function Knowledge() {
} = useFolderNavigation({
resourceType: FOLDER_RESOURCE_TYPE,
workspaceId,
/** Declared below; only ever called from a click, long after this render initializes it. */
onBeforeOpenFolder: () => setSearchQuery(''),
})
const createFolder = useCreateFolder()
@@ -261,7 +273,7 @@ export function Knowledge() {
const setSearchQuery = useDebouncedSearchSetter((value, options) =>
setKnowledgeFilters({ search: value }, options)
)
const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS)
const debouncedSearchQuery = useSearchFilterValue(urlSearchQuery, SEARCH_DEBOUNCE_MS)
const {
sort: sortColumn,
@@ -454,29 +466,39 @@ export function Knowledge() {
* Files page. The resource filters (connectors/content/owner) describe properties a folder
* does not have, so folders answer only to the search term.
*/
const visibleFolders = useMemo(() => {
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
const needle = debouncedSearchQuery.trim().toLowerCase()
return needle
? siblings.filter((folder) => folder.name.toLowerCase().includes(needle))
: siblings
}, [folders, currentFolderId, debouncedSearchQuery])
/** A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. */
const isSearching = isSearchingResources(debouncedSearchQuery)
const visibleFolders = useMemo(
() =>
scopeFolderedItems(folders, {
currentFolderId,
search: debouncedSearchQuery,
getParentId: (folder) => folder.parentId ?? null,
getSearchText: (folder) => [folder.name],
}),
[folders, currentFolderId, debouncedSearchQuery]
)
const processedKBs = useMemo(() => {
/**
* A `folderId` that no longer names an active folder — a base restored on its own out of
* Recently Deleted while its folder stayed archived, or a cascade that failed partway —
* would otherwise match no level at all and leave the base unreachable from every view.
* Fall it back to the root instead — but only once `foldersResolved` says the index is the
* complete set for THIS workspace. Gating on a loading flag instead would treat an errored
* fetch, a disabled query, or the previous workspace's cached folders as "no such folder"
* and drag every foldered base to the root.
*/
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => {
const folderId = kb.folderId ?? null
const effectiveFolderId =
!foldersResolved || !folderId || folderById.has(folderId) ? folderId : null
return effectiveFolderId === currentFolderId
let result = scopeFolderedItems(knowledgeBases, {
currentFolderId,
search: debouncedSearchQuery,
/**
* A `folderId` that no longer names an active folder — a base restored on its own out of
* Recently Deleted while its folder stayed archived, or a cascade that failed partway —
* would otherwise match no level at all and leave the base unreachable from every view.
* Fall it back to the root instead — but only once `foldersResolved` says the index is the
* complete set for THIS workspace. Gating on a loading flag instead would treat an errored
* fetch, a disabled query, or the previous workspace's cached folders as "no such folder"
* and drag every foldered base to the root.
*/
getParentId: (kb) => {
const folderId = kb.folderId ?? null
return !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null
},
/** A base is findable by its description as well as its name. */
getSearchText: (kb) => [kb.name, kb.description],
})
if (connectorFilter.length > 0) {
@@ -589,6 +611,16 @@ export function Knowledge() {
created: timeCell(item.folder.createdAt),
owner: ownerCell(item.folder.userId, membersById),
updated: timeCell(item.folder.updatedAt),
/** A folder's location is its parent's path, not its own. */
location: isSearching
? {
label: folderLocationLabel(
item.folder.parentId,
folderById,
ROOT_BREADCRUMB_LABEL
),
}
: EMPTY_LOCATION_CELL,
},
})
}
@@ -612,10 +644,13 @@ export function Knowledge() {
created: timeCell(base.createdAt),
owner: ownerCell(base.userId, membersById),
updated: timeCell(base.updatedAt),
location: isSearching
? { label: folderLocationLabel(base.folderId, folderById, ROOT_BREADCRUMB_LABEL) }
: EMPTY_LOCATION_CELL,
},
}
}),
[sortedEntries, membersById]
[sortedEntries, membersById, folderById, isSearching]
)
/**
@@ -710,7 +745,7 @@ export function Knowledge() {
const parsed = parseFolderedRowId(rowId)
if (parsed.kind === 'folder') {
setCurrentFolderId(parsed.id)
openFolder(parsed.id)
return
}
@@ -719,7 +754,7 @@ export function Knowledge() {
const urlParams = new URLSearchParams({ kbName: kb.name })
router.push(`/workspace/${workspaceId}/knowledge/${parsed.id}?${urlParams.toString()}`)
},
[router, workspaceId, setCurrentFolderId]
[router, workspaceId, openFolder]
)
const handleRowContextMenu = useCallback(
@@ -830,8 +865,8 @@ export function Knowledge() {
const handleOpenFolder = useCallback(() => {
const folder = activeFolderRef.current
if (folder) setCurrentFolderId(folder.id)
}, [setCurrentFolderId])
if (folder) openFolder(folder.id)
}, [openFolder])
const handleCopyFolderId = useCallback(() => {
const folder = activeFolderRef.current
@@ -858,16 +893,17 @@ export function Knowledge() {
setActiveFolder(null)
// Deleting the folder you are standing in leaves the list pointed at an archived
// folder, which renders as an empty page with a dead breadcrumb — step out to its
// parent instead.
// parent instead. Not `openFolder`: this is a forced correction, so it must neither
// clear an active search nor push a back-stack entry aimed at the deleted folder.
if (currentFolderIdRef.current === folder.id) {
setCurrentFolderId(folder.parentId)
setCurrentFolderId(folder.parentId, { history: 'replace' })
}
} catch (deleteError) {
logger.error('Failed to delete folder', deleteError)
toast.error(getErrorMessage(deleteError, 'Failed to delete folder'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, setCurrentFolderId])
}, [workspaceId, openFolder])
const descendantsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders])
@@ -1100,6 +1136,7 @@ export function Knowledge() {
selection: { selectedRowIds, visibleRowIds, replaceSelection },
onSpringOpenFolder: setCurrentFolderId,
currentFolderId,
bodyDropFolderId: isSearching ? undefined : currentFolderId,
})
const headerActions: ResourceAction[] = useMemo(
@@ -1127,7 +1164,7 @@ export function Knowledge() {
rootLabel: ROOT_BREADCRUMB_LABEL,
rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon,
breadcrumbs,
onNavigate: setCurrentFolderId,
onNavigate: openFolder,
currentFolderEditing:
breadcrumbRename.editingId && breadcrumbRename.editingId === currentFolderId
? {
@@ -1161,7 +1198,7 @@ export function Knowledge() {
[
breadcrumbs,
currentFolderId,
setCurrentFolderId,
openFolder,
canEdit,
breadcrumbRename.editingId,
breadcrumbRename.editValue,
@@ -1340,7 +1377,7 @@ export function Knowledge() {
return tags
}, [connectorFilter, contentFilter, ownerFilter, members])
const showEmptyState = isResourceListEmpty({
const listState = resourceListState({
rowCount: rows.length,
isLoading,
isPlaceholderData,
@@ -1351,6 +1388,11 @@ export function Knowledge() {
foldersResolved,
})
const clearSearchAndFilters = () => {
setSearchQuery('')
void setKnowledgeFilters({ connector: null, content: null, owner: null })
}
return (
<>
<Resource onContextMenu={handleContentContextMenu}>
@@ -1368,11 +1410,17 @@ export function Knowledge() {
filter={filterConfig}
/>
<Resource.Table
columns={COLUMNS}
columns={isSearching ? SEARCH_COLUMNS : COLUMNS}
rows={rows}
emptyState={
showEmptyState ? (
listState === 'empty' ? (
<KnowledgeEmptyState onCreate={handleOpenCreateModal} createDisabled={!canEdit} />
) : listState === 'no-results' ? (
<ResourceNoResults
search={debouncedSearchQuery}
filterCount={filterTags.length}
onClear={clearSearchAndFilters}
/>
) : undefined
}
selectable={canEdit ? selectableConfig : undefined}
@@ -1,18 +0,0 @@
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
/**
* Filter knowledge bases by search query
*/
export function filterKnowledgeBases(
knowledgeBases: KnowledgeBaseData[],
searchQuery: string
): KnowledgeBaseData[] {
if (!searchQuery.trim()) {
return knowledgeBases
}
const query = searchQuery.trim().toLowerCase()
return knowledgeBases.filter(
(kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query)
)
}
@@ -23,11 +23,11 @@ import type {
import {
EMPTY_CELL_PLACEHOLDER,
FILTER_SECTION_LABEL_CLASS,
isResourceListEmpty,
OwnerAvatar,
ownerCell,
Resource,
reportBulkOutcome,
resourceListState,
selectionLabel,
timeCell,
useResourceRowSelection,
@@ -40,21 +40,29 @@ import {
buildDescendantIndex,
buildMoveOptions,
buildMoveOptionsExcludingSubtrees,
EMPTY_LOCATION_CELL,
FOLDER_LOCATION_COLUMN,
FOLDERED_RESOURCE_HEADERS,
FolderContextMenu,
folderBreadcrumbItems,
folderLocationLabel,
folderRow,
folderRowId,
isSearchingResources,
nextUntitledFolderName,
parseFolderedRowId,
parseMoveOptionValue,
scopeFolderedItems,
sortResources,
splitFolderedRowIds,
useFolderNavigation,
useFolderRowDragDrop,
} from '@/app/workspace/[workspaceId]/components/folders'
import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar'
import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import {
ResourceNoResults,
TablesEmptyState,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -85,10 +93,10 @@ import {
} from '@/hooks/queries/tables'
import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree'
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
import { useUrlSort } from '@/hooks/use-url-sort'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
@@ -104,6 +112,8 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'updated', header: 'Last Updated' },
]
const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN]
/** This list's private drag MIME, so a drag started on another list is never mistaken for one
* of these rows. */
const TABLE_ROW_DRAG_MIME = 'application/x-sim-workspace-table-rows'
@@ -154,6 +164,7 @@ export function Tables() {
const {
currentFolderId,
setCurrentFolderId,
openFolder,
ancestors: folderChain,
folders,
folderById,
@@ -161,6 +172,8 @@ export function Tables() {
} = useFolderNavigation({
resourceType: 'table',
workspaceId,
/** Declared below; only ever called from a click, long after this render initializes it. */
onBeforeOpenFolder: () => setSearchTerm(''),
})
/**
@@ -250,7 +263,7 @@ export function Tables() {
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
setTableFilters({ search: value }, options)
)
const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS)
const debouncedSearchTerm = useSearchFilterValue(urlSearchTerm, SEARCH_DEBOUNCE_MS)
const setRowCountFilter = useCallback(
(next: string[]) => setTableFilters({ rows: next }),
@@ -306,32 +319,39 @@ export function Tables() {
*/
const descendantFolderIds = useMemo(() => buildDescendantIndex(folders), [folders])
const visibleFolders = useMemo(() => {
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
const needle = debouncedSearchTerm.trim().toLowerCase()
return needle
? siblings.filter((folder) => folder.name.toLowerCase().includes(needle))
: siblings
}, [folders, currentFolderId, debouncedSearchTerm])
/** A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. */
const isSearching = isSearchingResources(debouncedSearchTerm)
const visibleFolders = useMemo(
() =>
scopeFolderedItems(folders, {
currentFolderId,
search: debouncedSearchTerm,
getParentId: (folder) => folder.parentId ?? null,
getSearchText: (folder) => [folder.name],
}),
[folders, currentFolderId, debouncedSearchTerm]
)
const processedTables = useMemo(() => {
const query = debouncedSearchTerm.trim().toLowerCase()
/**
* A `folderId` that no longer names an active folder — restored on its own out
* of Recently Deleted while its folder stayed archived — would otherwise match
* no level at all and leave the table unreachable from every view. Fall it back
* to the root instead — but only once `foldersResolved` says the index is the complete
* set for THIS workspace. Gating on a loading flag instead would treat an errored fetch,
* a disabled query, or the previous workspace's cached folders as "no such folder" and
* drag every foldered table to the root.
*/
let result = tables.filter((t) => {
const folderId = t.folderId ?? null
const effectiveFolderId =
!foldersResolved || !folderId || folderById.has(folderId) ? folderId : null
return effectiveFolderId === currentFolderId
let result = scopeFolderedItems(tables, {
currentFolderId,
search: debouncedSearchTerm,
/**
* A `folderId` that no longer names an active folder — restored on its own out
* of Recently Deleted while its folder stayed archived — would otherwise match
* no level at all and leave the table unreachable from every view. Fall it back
* to the root instead — but only once `foldersResolved` says the index is the complete
* set for THIS workspace. Gating on a loading flag instead would treat an errored fetch,
* a disabled query, or the previous workspace's cached folders as "no such folder" and
* drag every foldered table to the root.
*/
getParentId: (t) => {
const folderId = t.folderId ?? null
return !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null
},
getSearchText: (t) => [t.name],
})
if (query) result = result.filter((t) => t.name.toLowerCase().includes(query))
if (rowCountFilter.length > 0) {
result = result.filter((t) => {
@@ -428,6 +448,10 @@ export function Tables() {
created: timeCell(item.folder.createdAt),
owner: ownerCell(item.folder.userId, membersById),
updated: timeCell(item.folder.updatedAt),
/** A folder's location is its parent's path, not its own. */
location: isSearching
? { label: folderLocationLabel(item.folder.parentId, folderById, ROOT_LABEL) }
: EMPTY_LOCATION_CELL,
},
})
}
@@ -452,10 +476,13 @@ export function Tables() {
created: timeCell(table.createdAt),
owner: ownerCell(table.createdBy, membersById),
updated: timeCell(table.updatedAt),
location: isSearching
? { label: folderLocationLabel(table.folderId, folderById, ROOT_LABEL) }
: EMPTY_LOCATION_CELL,
},
}
}),
[sortedEntries, membersById]
[sortedEntries, membersById, folderById, isSearching]
)
/**
@@ -586,11 +613,11 @@ export function Tables() {
breadcrumbs: folderChain,
rootLabel: ROOT_LABEL,
rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon,
onNavigate: setCurrentFolderId,
onNavigate: openFolder,
currentFolderActions,
currentFolderEditing,
}),
[folderChain, setCurrentFolderId, currentFolderActions, currentFolderEditing]
[folderChain, openFolder, currentFolderActions, currentFolderEditing]
)
const searchConfig: SearchConfig = useMemo(
@@ -738,7 +765,7 @@ export function Tables() {
return tags
}, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter])
const showEmptyState = isResourceListEmpty({
const listState = resourceListState({
rowCount: rows.length,
isLoading,
isPlaceholderData,
@@ -749,6 +776,11 @@ export function Tables() {
foldersResolved,
})
const clearSearchAndFilters = () => {
setSearchTerm('')
void setTableFilters({ rows: null, owner: null })
}
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -768,12 +800,12 @@ export function Tables() {
if (isRowContextMenuOpen || listRename.editingId === rowId) return
const parsed = parseFolderedRowId(rowId)
if (parsed.kind === 'folder') {
setCurrentFolderId(parsed.id)
openFolder(parsed.id)
return
}
router.push(`/workspace/${workspaceId}/tables/${parsed.id}`)
},
[isRowContextMenuOpen, listRename.editingId, router, workspaceId, setCurrentFolderId]
[isRowContextMenuOpen, listRename.editingId, router, workspaceId, openFolder]
)
const resolveRowItem = useCallback(
@@ -1023,6 +1055,7 @@ export function Tables() {
selection: { selectedRowIds, visibleRowIds, replaceSelection },
onSpringOpenFolder: setCurrentFolderId,
currentFolderId,
bodyDropFolderId: isSearching ? undefined : currentFolderId,
})
const handleDelete = async () => {
@@ -1069,9 +1102,11 @@ export function Tables() {
id: activeFolder.id,
})
// The open folder just disappeared — fall back to its parent rather than
// leaving a `?folderId=` pointing at an archived folder.
// leaving a `?folderId=` pointing at an archived folder. Not `openFolder`:
// this is a forced correction, so it must neither clear an active search nor
// push a back-stack entry aimed at the folder that was just deleted.
if (currentFolderId === activeFolder.id) {
setCurrentFolderId(activeFolder.parentId)
setCurrentFolderId(activeFolder.parentId, { history: 'replace' })
}
setIsDeleteFolderDialogOpen(false)
setActiveFolder(null)
@@ -1285,14 +1320,20 @@ export function Tables() {
filter={filterConfig}
/>
<Resource.Table
columns={COLUMNS}
columns={isSearching ? SEARCH_COLUMNS : COLUMNS}
rows={rows}
emptyState={
showEmptyState ? (
listState === 'empty' ? (
<TablesEmptyState
onCreate={handleCreateTable}
createDisabled={uploading || !canEdit || createTable.isPending}
/>
) : listState === 'no-results' ? (
<ResourceNoResults
search={debouncedSearchTerm}
filterCount={filterTags.length}
onClear={clearSearchAndFilters}
/>
) : undefined
}
selectable={canEdit ? selectableConfig : undefined}
@@ -1361,7 +1402,7 @@ export function Tables() {
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpen={() => {
if (activeFolder) setCurrentFolderId(activeFolder.id)
if (activeFolder) openFolder(activeFolder.id)
closeRowContextMenu()
}}
onRename={() => {
@@ -0,0 +1,114 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
const DELAY_MS = 200
const mountedRoots: Root[] = []
/** Drives the hook the way a search box does: re-render with each new input value. */
function renderSearchFilterValue(initial: string) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const root = createRoot(document.createElement('div'))
mountedRoots.push(root)
let result = ''
function Probe({ value }: { value: string }) {
result = useSearchFilterValue(value, DELAY_MS)
return null
}
act(() => root.render(<Probe value={initial} />))
return {
get current() {
return result
},
type(value: string) {
act(() => root.render(<Probe value={value} />))
},
settle() {
act(() => {
vi.advanceTimersByTime(DELAY_MS)
})
},
wait(ms: number) {
act(() => {
vi.advanceTimersByTime(ms)
})
},
}
}
describe('useSearchFilterValue', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
act(() => {
for (const root of mountedRoots.splice(0)) root.unmount()
})
vi.useRealTimers()
})
it('filters on a deep-linked term from the first render', () => {
expect(renderSearchFilterValue('report').current).toBe('report')
})
it('starts empty and applies a typed term only once it settles', () => {
const probe = renderSearchFilterValue('')
expect(probe.current).toBe('')
probe.type('rep')
expect(probe.current).toBe('')
probe.settle()
expect(probe.current).toBe('rep')
})
it('drops the term the instant it is cleared, without waiting out the window', () => {
const probe = renderSearchFilterValue('report')
probe.type('')
expect(probe.current).toBe('')
})
/**
* The regression this hook exists to prevent, and the one masking alone did not: opening a
* folder clears the term, and typing again inside the same debounce window must not resurrect
* the term from before the clear — the list would search the whole workspace for something
* the user had already abandoned.
*/
it('never resurrects the pre-clear term when the user types again straight away', () => {
const probe = renderSearchFilterValue('report')
probe.type('')
probe.wait(DELAY_MS / 4)
probe.type('b')
expect(probe.current).toBe('')
probe.wait(DELAY_MS / 4)
expect(probe.current).toBe('')
probe.settle()
expect(probe.current).toBe('b')
})
it('keeps showing the previous term while a longer one is still being typed', () => {
const probe = renderSearchFilterValue('')
probe.type('re')
probe.settle()
expect(probe.current).toBe('re')
probe.type('rep')
expect(probe.current).toBe('re')
probe.settle()
expect(probe.current).toBe('rep')
})
it('treats a whitespace-only term as cleared', () => {
const probe = renderSearchFilterValue('report')
probe.type(' ')
expect(probe.current).toBe('')
probe.settle()
expect(probe.current).toBe('')
})
})
+45
View File
@@ -0,0 +1,45 @@
'use client'
import { useEffect, useState } from 'react'
/**
* The search term a list should actually filter by: debounced while the user types, but
* discarded the moment the term is cleared.
*
* A plain trailing debounce keeps filtering by the old term for a full window after a clear.
* Typing can afford that — nobody expects results before they stop typing — but clearing
* cannot, because clearing is how a list stops being a search. On a foldered list the term is
* cleared as part of opening a folder, so a trailing edge leaves the previous whole-workspace
* results on screen after the breadcrumb has already changed, and the rows snap a fifth of a
* second later. The click reads as having done nothing, twice.
*
* Masking the settled term while the input is empty is not enough, because the mask lifts as
* soon as the user types again: between that keystroke and the end of its own window the hook
* would hand back the term from *before* the clear, and the list would search the whole
* workspace for something the user had already abandoned. So a clear resets the settled term
* rather than hiding it, and the next term applies only once it settles on its own.
*/
export function useSearchFilterValue(value: string, delayMs: number): string {
const isSearching = value.trim().length > 0
/** Seeded from the first value so a deep-linked `?search=` filters on the first render. */
const [settled, setSettled] = useState(() => (value.trim() ? value : ''))
const [wasSearching, setWasSearching] = useState(isSearching)
/**
* Adjusted during render rather than in an effect so the reset is already visible to the
* render that follows the clear — an effect would land a frame later, which is the same
* stale window in a smaller costume. See `.claude/rules/sim-hooks.md`, "State shape".
*/
if (wasSearching !== isSearching) {
setWasSearching(isSearching)
if (!isSearching) setSettled('')
}
useEffect(() => {
if (!isSearching) return
const timer = setTimeout(() => setSettled(value), delayMs)
return () => clearTimeout(timer)
}, [value, isSearching, delayMs])
return isSearching ? settled : ''
}
@@ -0,0 +1,47 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { v2ListFilesQuerySchema } from '@/lib/api/contracts/v2/files'
const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64'
/** Parses what a query string actually delivers: strings, never booleans. */
function parseRecursive(raw: string | undefined) {
const result = v2ListFilesQuerySchema.safeParse({
workspaceId: WORKSPACE_ID,
...(raw === undefined ? {} : { recursive: raw }),
})
return result.success ? result.data.recursive : { error: true as const }
}
describe('v2ListFilesQuerySchema recursive', () => {
it('omits to undefined so the route can pick a default from the search', () => {
expect(parseRecursive(undefined)).toBeUndefined()
})
/**
* The bug this guards: `z.coerce.boolean()` is `Boolean(input)` over a query string, so
* every non-empty spelling — `false` included — arrives as `true`, silently inverting the
* one value a caller sends explicitly to turn recursion off.
*/
it('reads "false" as false, not as a non-empty string', () => {
expect(parseRecursive('false')).toBe(false)
expect(parseRecursive('0')).toBe(false)
expect(parseRecursive('no')).toBe(false)
})
it('reads the true spellings as true', () => {
expect(parseRecursive('true')).toBe(true)
expect(parseRecursive('1')).toBe(true)
expect(parseRecursive('yes')).toBe(true)
})
/** Case-sensitive by design: an unpublished spelling is a 400, never a silent default. */
it('rejects spellings outside the published vocabulary', () => {
expect(parseRecursive('True')).toEqual({ error: true })
expect(parseRecursive('TRUE')).toEqual({ error: true })
expect(parseRecursive('maybe')).toEqual({ error: true })
expect(parseRecursive('')).toEqual({ error: true })
})
})
@@ -132,7 +132,16 @@ const CURSOR_BINDINGS: Record<string, readonly string[]> = {
'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'],
'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/files': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/files': [
'workspaceId',
'scope',
'folderPath',
'search',
'sortBy',
'sortOrder',
/** Decides whether `folderPath` covers one folder or its whole subtree. */
'recursive',
],
'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/knowledge/[id]/documents': [
'workspaceId',
@@ -0,0 +1,44 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
V2_ERROR_CODE_BY_STATUS,
V2_ERROR_STATUS_BY_CODE,
type V2ErrorCode,
} from '@/lib/api/contracts/v2/error-codes'
describe('v2 error codes', () => {
/**
* The property the OpenAPI layer depends on: a documented error response derives its
* `error.code` from the status it is declared under, so two codes sharing a status would
* make one of those examples name a code that status never carries.
*/
it('maps each code onto a status no other code claims', () => {
const claimedBy = new Map<number, V2ErrorCode>()
for (const [code, status] of Object.entries(V2_ERROR_STATUS_BY_CODE) as [
V2ErrorCode,
number,
][]) {
expect(claimedBy.get(status), `${status} is claimed by more than one code`).toBeUndefined()
claimedBy.set(status, code)
}
expect(claimedBy.size).toBe(Object.keys(V2_ERROR_STATUS_BY_CODE).length)
})
it('inverts without losing an entry', () => {
for (const [code, status] of Object.entries(V2_ERROR_STATUS_BY_CODE) as [
V2ErrorCode,
number,
][]) {
expect(V2_ERROR_CODE_BY_STATUS[status]).toBe(code)
}
})
it('uses statuses in the HTTP error range', () => {
for (const status of Object.values(V2_ERROR_STATUS_BY_CODE)) {
expect(status).toBeGreaterThanOrEqual(400)
expect(status).toBeLessThan(600)
}
})
})
@@ -0,0 +1,63 @@
/**
* The closed set of `error.code` values the public v2 API emits, and the HTTP status each
* one is sent with.
*
* Lives in the contract layer rather than beside the response helpers because two consumers
* need it and they must not drift: the runtime (`app/api/v2/lib/response.ts`, which renders
* the envelope) and the published OpenAPI documents (`contracts/v2/openapi/shared.ts`, which
* document one example per status). Documenting a code the runtime cannot emit — or a status
* paired with the wrong code — is how the reference stops describing the API.
*/
export type V2ErrorCode =
| 'BAD_REQUEST'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'PAYLOAD_TOO_LARGE'
| 'UNSUPPORTED_MEDIA_TYPE'
| 'USAGE_LIMIT_EXCEEDED'
| 'LOCKED'
| 'RATE_LIMITED'
| 'CLIENT_CLOSED_REQUEST'
| 'INTERNAL_ERROR'
| 'SERVICE_UNAVAILABLE'
/**
* The status each code is sent with. `Record<V2ErrorCode, number>` is the completeness gate:
* adding a code fails to compile until it declares its status.
*/
export const V2_ERROR_STATUS_BY_CODE: Record<V2ErrorCode, number> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
USAGE_LIMIT_EXCEEDED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
PAYLOAD_TOO_LARGE: 413,
UNSUPPORTED_MEDIA_TYPE: 415,
LOCKED: 423,
RATE_LIMITED: 429,
CLIENT_CLOSED_REQUEST: 499,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
}
/**
* The inverse map.
*
* The pairing is one-to-one, which is what lets a documented error response derive its code
* from the status it is declared under instead of restating it. That property is load-bearing
* for the API reference, so it is asserted in `error-codes.test.ts` rather than trusted — a
* second code claiming a status would silently drop an entry here and make one documented
* example name the wrong code.
*
* This describes how a code chooses its status, not every status the surface can send. A
* route may pass an explicit status alongside a code — `POST /workflows/{id}/execute` answers
* `408` with `BAD_REQUEST` — so a status absent from this map is one no documented error
* response may claim, which is what the OpenAPI layer enforces.
*/
export const V2_ERROR_CODE_BY_STATUS: Partial<Record<number, V2ErrorCode>> = Object.fromEntries(
Object.entries(V2_ERROR_STATUS_BY_CODE).map(([code, status]) => [status, code as V2ErrorCode])
)
+38 -1
View File
@@ -9,7 +9,9 @@ import {
import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares'
import { defineRouteContract } from '@/lib/api/contracts/types'
import {
V2_FALSE_VALUES,
V2_FOLDER_FILTER_MISS,
V2_TRUE_VALUES,
v2CreateFolderBodySchema,
v2CursorListResponse,
v2DataResponse,
@@ -306,7 +308,30 @@ export const v2ListFilesQuerySchema = z
/** Restrict to one file folder. Omit to list the whole workspace. */
folderPath: v2FolderPathInputSchema
.optional()
.describe(`Restrict results to files directly inside this folder. ${V2_FOLDER_FILTER_MISS}`),
.describe(
`Restrict results to files inside this folder — its direct children, or its whole subtree when \`recursive\` is true. ${V2_FOLDER_FILTER_MISS}`
),
/**
* Descend into subfolders. Meaningful only alongside `folderPath`: with no folder filter
* the listing already spans the workspace.
*
* Defaults to `true` when `search` is set and `false` otherwise, so the two verbs this
* endpoint serves each get the scope they imply — listing a folder shows that folder,
* searching one looks through everything in it. Send it explicitly to force either.
*
* `z.stringbool({ case: 'sensitive' })` rather than `z.coerce.boolean()`, which is
* `Boolean(input)` over a query string and so reads `recursive=false` as `true` — see
* `booleanQueryFlagSchema` in `contracts/primitives.ts`. Matches the sibling `recursive`
* on folder delete: the accepted spellings are closed, published as an enum, and
* case-sensitive, so an unpublished spelling is a `400` rather than a silent default.
*/
recursive: z
.stringbool({ case: 'sensitive' })
.optional()
.describe(
'Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.'
)
.meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }),
scope: v2FileScopeSchema
.default('active')
.describe(
@@ -325,6 +350,18 @@ export const v2ListFilesQuerySchema = z
export type V2ListFilesQuery = z.output<typeof v2ListFilesQuerySchema>
/**
* Resolves the `recursive` default the schema above promises: true alongside a search, false
* otherwise, and whatever the caller sent when they sent one.
*
* Lives beside the `.describe()` that publishes the rule to every SDK and CLI rather than in
* the route that applies it. The promise and the implementation were two modules apart with
* nothing binding them, which is how a documented default drifts from the served one.
*/
export function listsSubfolders(query: { recursive?: boolean; search?: string }): boolean {
return query.recursive ?? query.search !== undefined
}
/** Download/delete both target a single file within a workspace-scoped query. */
export const v2FileWorkspaceQuerySchema = z
.object({
@@ -24,7 +24,6 @@ import {
} from '@/lib/api/contracts/v2/files'
import {
documentedSchema,
ERROR_RESPONSES,
type ErrorResponseId,
FOLDER_TREE_TOO_LARGE,
FULL_SET_LIST,
@@ -39,6 +38,7 @@ import {
V2_ERROR_SCHEMA,
WORKSPACE_API_KEY_DENIED,
WORKSPACE_ERRORS,
withErrorExamples,
withRequestBodyErrors,
} from '@/lib/api/contracts/v2/openapi/shared'
import {
@@ -902,6 +902,8 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({
...V2_COMMON_HEADERS,
},
errorSchema: V2_ERROR_SCHEMA,
errorResponses: ERROR_RESPONSES,
errorResponses: withErrorExamples({
Conflict: { message: 'File already exists' },
}),
routes,
})
@@ -24,7 +24,6 @@ import {
} from '@/lib/api/contracts/v2/knowledge'
import {
documentedSchema,
ERROR_RESPONSES,
type ErrorResponseId,
FOLDER_TREE_TOO_LARGE,
FULL_SET_LIST,
@@ -37,6 +36,7 @@ import {
V2_ERROR_SCHEMA,
WORKSPACE_API_KEY_DENIED,
WORKSPACE_ERRORS,
withErrorExamples,
withRequestBodyErrors,
} from '@/lib/api/contracts/v2/openapi/shared'
import {
@@ -781,6 +781,8 @@ export const knowledgeOpenApiDocument = defineOpenApiDocument({
securitySchemes: V2_API_KEY_SECURITY_SCHEMES,
headers: V2_COMMON_HEADERS,
errorSchema: V2_ERROR_SCHEMA,
errorResponses: ERROR_RESPONSES,
errorResponses: withErrorExamples({
Conflict: { message: 'Upload has already been completed' },
}),
routes,
})
@@ -22,7 +22,6 @@ import {
} from '@/lib/api/contracts/v2/mcp-servers'
import {
documentedSchema,
ERROR_RESPONSES,
type ErrorResponseId,
FULL_SET_LIST,
HEAD_MIRRORS_GET,
@@ -34,6 +33,7 @@ import {
V2_COMMON_HEADERS,
V2_ERROR_SCHEMA,
WORKSPACE_API_KEY_DENIED,
withErrorExamples,
withRequestBodyErrors,
} from '@/lib/api/contracts/v2/openapi/shared'
import {
@@ -1158,6 +1158,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({
securitySchemes: V2_API_KEY_SECURITY_SCHEMES,
headers: V2_COMMON_HEADERS,
errorSchema: V2_ERROR_SCHEMA,
errorResponses: ERROR_RESPONSES,
errorResponses: withErrorExamples({
Conflict: { message: 'API key name already exists' },
}),
routes,
})
+170 -45
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { V2_ERROR_CODE_BY_STATUS } from '@/lib/api/contracts/v2/error-codes'
import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared'
import type {
OpenApiErrorResponse,
@@ -7,6 +8,47 @@ import type {
OpenApiSecurityScheme,
} from '@/lib/api/openapi/types'
interface ErrorResponseOptions {
/**
* The `message` a caller actually receives for this status. Every one below is a literal
* the runtime emits — the generic path for that status, not a domain's phrasing of it —
* so the reference can be read as the response rather than as an illustration.
*/
message: string
/** `error.details`, for the statuses that populate it. Omitted where none is sent. */
details?: unknown
headers?: readonly string[]
}
/**
* One documented error response, with `error.code` derived from the status rather than
* restated beside it.
*
* Deriving is what keeps the reference honest: a hand-written pair can drift into naming a
* code the status never carries, and that mistake reads as authoritative. The v2 codes map
* one-to-one onto statuses ({@link V2_ERROR_CODE_BY_STATUS} throws if that ever stops being
* true), so the status is
* enough to determine the code.
*/
function errorResponse(
status: number,
description: string,
{ message, details, headers }: ErrorResponseOptions
): OpenApiErrorResponse {
const code = V2_ERROR_CODE_BY_STATUS[status]
if (!code) {
throw new Error(
`No v2 error code is sent with status ${status}; documenting it would publish a response the API cannot produce.`
)
}
return {
status,
description,
...(headers ? { headers } : {}),
example: { error: { code, message, ...(details === undefined ? {} : { details }) } },
}
}
export const RATE_LIMIT_HEADERS = [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
@@ -46,40 +88,79 @@ const FORBIDDEN_DESCRIPTION =
'The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.'
export const ERROR_RESPONSES = {
BadRequest: {
status: 400,
description:
'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.',
},
Unauthorized: { status: 401, description: 'The API key is missing or invalid.' },
UsageLimitExceeded: {
status: 402,
description: 'The workspace has exceeded its usage or billing limits.',
},
Forbidden: { status: 403, description: FORBIDDEN_DESCRIPTION },
NotFound: { status: 404, description: 'The requested resource was not found.' },
Conflict: { status: 409, description: 'The request conflicts with current resource state.' },
RunIdConflict: {
status: 409,
description:
'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.',
headers: ['X-Run-Id'],
},
PayloadTooLarge: {
status: 413,
description:
'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.',
},
UnsupportedMediaType: {
status: 415,
description: 'The request uses an unsupported media type.',
},
Locked: { status: 423, description: 'The resource is locked and cannot be modified.' },
RateLimited: {
status: 429,
description: 'The caller exceeded the request rate limit.',
BadRequest: errorResponse(
400,
'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.',
{
/**
* The fallback `getValidationErrorMessage` uses when an issue carries no message of its
* own; a real failure usually names the offending field instead (`limit must be at
* least 1`). The generic form is documented because this response is shared by every
* operation, and `details` is omitted because only the validation path populates it —
* the cursor and malformed-JSON 400s send none.
*/
message: 'Invalid request',
}
),
Unauthorized: errorResponse(401, 'The API key is missing or invalid.', {
message: 'API key required',
}),
UsageLimitExceeded: errorResponse(
402,
'The workspace has exceeded its usage or billing limits.',
{ message: 'Usage limit exceeded. Please upgrade your plan to continue.' }
),
Forbidden: errorResponse(403, FORBIDDEN_DESCRIPTION, {
message: 'Insufficient workspace permissions',
/** The description tells callers to branch on this, so the example has to show it. */
details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' },
}),
NotFound: errorResponse(404, 'The requested resource was not found.', {
/**
* The surface-wide literal. A resource route answers with its own noun instead — `Workflow
* not found`, `Table not found` — which this response cannot name because every domain
* shares it.
*/
message: 'Not found',
}),
Conflict: errorResponse(409, 'The request conflicts with current resource state.', {
/**
* The workflows phrasing, which is the default because that document does not override
* it. Nothing in the response layer supplies a 409 message, so every other document
* carrying this status names its own through {@link withErrorExamples}.
*/
message: 'Webhook path already in use',
}),
RunIdConflict: errorResponse(
409,
'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.',
{
message: 'Run ID has already been used',
details: { code: 'RUN_ID_CONFLICT', runId: '0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01' },
headers: ['X-Run-Id'],
}
),
PayloadTooLarge: errorResponse(
413,
'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.',
{ message: 'Request body is too large' }
),
UnsupportedMediaType: errorResponse(415, 'The request uses an unsupported media type.', {
message: 'Request body must be sent as application/json',
}),
Locked: errorResponse(423, 'The resource is locked and cannot be modified.', {
/**
* Also domain-supplied. Workflows and tables are the only documents that carry a `423`,
* and tables names its own four lock kinds through {@link withErrorExamples}.
*/
message: 'Workflow is locked',
}),
RateLimited: errorResponse(429, 'The caller exceeded the request rate limit.', {
message: 'API rate limit exceeded',
/** Mirrors `Retry-After`, which the description already sends callers to. */
details: { retryAfter: '2026-01-01T00:00:30.000Z' },
headers: ['Retry-After'],
},
}),
/**
* Published on exactly one operation, and deliberately not on the rest.
*
@@ -98,22 +179,66 @@ export const ERROR_RESPONSES = {
* leaves nothing behind to reconcile. Publish a 499 on a new operation only
* when the same is true of it.
*/
ClientClosedRequest: {
status: 499,
description:
'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.',
},
InternalError: { status: 500, description: 'An unexpected server error occurred.' },
ServiceUnavailable: {
status: 503,
description:
'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.',
headers: ['Retry-After'],
},
ClientClosedRequest: errorResponse(
499,
'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.',
{
message: 'Client cancelled request',
/** The run this abort may have left running — the reason the status is published. */
details: { runId: '0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01' },
}
),
InternalError: errorResponse(500, 'An unexpected server error occurred.', {
/**
* Hardcoded on every path. `v2ErrorForOrchestration` replaces a domain's `internal`
* message with this literal, so a 500 never leaks one — the reference showing a
* descriptive message here would suggest callers can parse something they never get.
*/
message: 'Internal server error',
}),
ServiceUnavailable: errorResponse(
503,
'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.',
{ message: 'Service temporarily unavailable', headers: ['Retry-After'] }
),
} as const satisfies Readonly<Record<string, OpenApiErrorResponse>>
export type ErrorResponseId = keyof typeof ERROR_RESPONSES
/**
* {@link ERROR_RESPONSES} with a document's own body for the statuses whose message is the
* domain's rather than the surface's.
*
* Most statuses read the same everywhere — a `401` is `API key required` whatever you were
* asking for. `409` and `423` are not: nothing in the response layer supplies them, so the
* only real strings are each domain's, and one shared example necessarily shows four of the
* seven documents a message they never send. Tables answering `Workflow is locked` is the
* same class of wrongness as every status answering `BAD_REQUEST`, just smaller.
*
* Status, description, and headers are kept — a document may restate what its errors *say*,
* never what they *mean* — and the code is re-derived, so an override cannot introduce the
* mismatch this whole mechanism exists to prevent.
*/
export function withErrorExamples(
overrides: Partial<Record<ErrorResponseId, { message: string; details?: unknown }>>
): Record<ErrorResponseId, OpenApiErrorResponse> {
const entries = Object.entries(ERROR_RESPONSES) as [ErrorResponseId, OpenApiErrorResponse][]
return Object.fromEntries(
entries.map(([id, response]) => {
const override = overrides[id]
if (!override) return [id, response]
return [
id,
errorResponse(response.status, response.description, {
message: override.message,
details: override.details,
headers: response.headers,
}),
]
})
) as Record<ErrorResponseId, OpenApiErrorResponse>
}
/**
* The three sets below are the base shapes every workspace-scoped resource
* operation in the v2 API actually emits, so they live here once rather than
@@ -1,6 +1,5 @@
import {
documentedSchema,
ERROR_RESPONSES,
type ErrorResponseId,
FOLDER_TREE_TOO_LARGE,
FULL_SET_LIST,
@@ -13,6 +12,7 @@ import {
V2_COMMON_HEADERS,
V2_ERROR_SCHEMA,
WORKSPACE_ERRORS,
withErrorExamples,
withRequestBodyErrors,
} from '@/lib/api/contracts/v2/openapi/shared'
import {
@@ -1603,6 +1603,13 @@ export const tablesOpenApiDocument = defineOpenApiDocument({
securitySchemes: V2_API_KEY_SECURITY_SCHEMES,
headers: V2_COMMON_HEADERS,
errorSchema: V2_ERROR_SCHEMA,
errorResponses: ERROR_RESPONSES,
errorResponses: withErrorExamples({
Conflict: { message: 'A table named "Orders" already exists in this workspace' },
Locked: {
message: 'This table is insert-locked: new rows cannot be added.',
/** Names which of the four locks refused the write, so a caller can say which. */
details: { lock: 'insert' },
},
}),
routes,
})
+13
View File
@@ -50,6 +50,19 @@ export interface OpenApiErrorResponse {
status: number
description: string
headers?: readonly string[]
/**
* The body this status is documented with, validated against the document's `errorSchema`
* at generation time.
*
* Required, because the alternative is what every status rendered before it existed: the
* error schema's single shared example, so the reference answered `400 BAD_REQUEST` under
* the `401`, `404`, and `500` tabs alike. A reference that confidently shows the wrong
* body is worse than one that shows none.
*
* The document's own layer assembles it — only that layer knows how its codes pair with
* statuses — and this generator checks it parses.
*/
example: unknown
}
export interface OpenApiSuccessMetadata {
+1 -1
View File
@@ -15,7 +15,7 @@ export type FolderChildrenIndex = ReadonlyMap<string, string[]>
* it on every call, which is O(rows) each time, and a workspace's tree is
* bounded only by `MAX_FOLDERS_PER_WORKSPACE`.
*/
export function indexFolderChildren(folders: readonly FolderNode[]): FolderChildrenIndex {
export function indexFolderChildren(folders: Iterable<FolderNode>): FolderChildrenIndex {
const childrenByParent = new Map<string, string[]>()
for (const folder of folders) {
@@ -14,7 +14,7 @@ import {
getPostgresErrorCode,
} from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { and, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm'
import { and, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm'
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
import type { V2FileSortBy } from '@/lib/api/contracts/v2/files'
import type { ListSortOrder } from '@/lib/api/list-query'
@@ -1252,7 +1252,14 @@ export interface QueryWorkspaceFilesOptions {
scope?: WorkspaceFileScope
/** Restrict to one file folder. */
/** `undefined` lists every folder, `null` lists only root files. */
folderId?: string | null
/**
* The folder to match: one id, `null` for the workspace root, or several ids for a folder
* and its descendants. Omit to match every folder.
*
* An empty array matches nothing — the honest answer for an empty set of folders, and the
* shape Drizzle already emits (`false`) for an empty `IN`.
*/
folderId?: string | null | readonly string[]
/** Case-insensitive substring match on the file name. */
search?: string
sortBy: V2FileSortBy
@@ -1268,6 +1275,16 @@ export interface QueryWorkspaceFilesResult {
nextKeys: CursorKey[] | null
}
/** The folder predicate for {@link QueryWorkspaceFilesOptions.folderId}'s three shapes. */
function workspaceFileFolderCondition(
folderId: string | null | readonly string[] | undefined
): SQL | undefined {
if (folderId === undefined) return undefined
if (folderId === null) return isNull(workspaceFiles.folderId)
if (Array.isArray(folderId)) return inArray(workspaceFiles.folderId, folderId)
return eq(workspaceFiles.folderId, folderId as string)
}
/**
* One filtered, sorted, bounded page of a workspace's files.
*
@@ -1297,11 +1314,7 @@ export async function queryWorkspaceFiles(
const conditions = [
workspaceFileScopeCondition(workspaceId, scope),
folderId === undefined
? undefined
: folderId === null
? isNull(workspaceFiles.folderId)
: eq(workspaceFiles.folderId, folderId),
workspaceFileFolderCondition(folderId),
searchFilter(workspaceFiles.originalName, search),
resumeAfter,
]
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loadWorkspace: vi.fn(),
loadFolderIndex: vi.fn(),
queryFiles: vi.fn(),
resolvePermission: vi.fn(),
recordAudit: vi.fn(),
}))
vi.mock('@sim/platform-authz/workspace', () => ({
permissionSatisfies: () => true,
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
}))
vi.mock('@sim/audit', () => ({
AuditAction: {},
AuditResourceType: { FILE: 'FILE' },
recordAudit: mocks.recordAudit,
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({
listWorkspaceFiles: vi.fn(),
loadActiveWorkspaceContext: mocks.loadWorkspace,
queryWorkspaceFiles: mocks.queryFiles,
}))
vi.mock('@/lib/public-shares/share-manager', () => ({ getWorkspaceShares: vi.fn() }))
vi.mock('@/lib/folders/queries', async () => {
const { resolveFolderPathFilter } =
await vi.importActual<typeof import('@/lib/folders/queries')>('@/lib/folders/queries')
return { loadActiveFolderPathIndex: mocks.loadFolderIndex, resolveFolderPathFilter }
})
import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files'
/**
* Projects / (a)
* └── Q3 (b)
* └── Drafts (c)
* Archive (d)
*/
const ROWS = [
{ id: 'a', name: 'Projects', parentId: null },
{ id: 'b', name: 'Q3', parentId: 'a' },
{ id: 'c', name: 'Drafts', parentId: 'b' },
{ id: 'd', name: 'Archive', parentId: null },
]
/** `files.list` accepts a session principal — see `ALL_COPILOT_PRINCIPAL_POLICY`. */
const principal = {
kind: 'session' as const,
userId: 'user-1',
workspaceId: 'workspace-1',
}
function buildIndex() {
const rowById = new Map(ROWS.map((row) => [row.id, row]))
const pathById = new Map([
['a', '/Projects'],
['b', '/Projects/Q3'],
['c', '/Projects/Q3/Drafts'],
['d', '/Archive'],
])
const idByPath = new Map([...pathById].map(([id, path]) => [path, id]))
return { rowById, pathById, idByPath }
}
const baseInput = {
workspaceId: 'workspace-1',
sortBy: 'uploadedAt' as const,
sortOrder: 'asc' as const,
limit: 100,
}
type PageInput = Parameters<typeof queryWorkspaceFilePage.execute>[0]['input']
async function execute(input: Partial<PageInput> = {}) {
return queryWorkspaceFilePage.execute({
input: { ...baseInput, ...input } as PageInput,
principal,
})
}
/** The folder scoping the use case handed to the query layer. */
async function run(input: Partial<PageInput> = {}) {
await execute(input)
return mocks.queryFiles.mock.calls.at(-1)?.[1]
}
describe('queryWorkspaceFilePage folder scoping', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.resolvePermission.mockResolvedValue('admin')
mocks.loadWorkspace.mockResolvedValue({
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'billing-owner',
})
mocks.loadFolderIndex.mockResolvedValue(buildIndex())
mocks.queryFiles.mockResolvedValue({ files: [], nextKeys: null })
})
it('applies no folder predicate when folderPath is omitted', async () => {
const options = await run()
expect(options.folderId).toBeUndefined()
})
it('matches one folder when not recursive', async () => {
const options = await run({ folderPath: '/Projects' })
expect(options.folderId).toBe('a')
})
it('matches the whole subtree when recursive', async () => {
const options = await run({ folderPath: '/Projects', recursive: true })
expect(options.folderId).toEqual(['a', 'b', 'c'])
})
it('stops at the subtree it was asked for', async () => {
const options = await run({ folderPath: '/Projects/Q3', recursive: true })
expect(options.folderId).toEqual(['b', 'c'])
})
it('includes a leaf folder itself', async () => {
const options = await run({ folderPath: '/Projects/Q3/Drafts', recursive: true })
expect(options.folderId).toEqual(['c'])
})
it('treats a recursive root filter as the whole workspace, not root-level files', async () => {
const options = await run({ folderPath: '/', recursive: true })
expect(options.folderId).toBeUndefined()
})
it('still means root-level files only when the root filter is not recursive', async () => {
const options = await run({ folderPath: '/' })
expect(options.folderId).toBeNull()
})
it('returns an empty page for a folder that does not resolve', async () => {
const result = await execute({ folderPath: '/Nope', recursive: true })
expect(result).toEqual({ files: [], nextKeys: null })
expect(mocks.queryFiles).not.toHaveBeenCalled()
})
})
@@ -2,6 +2,7 @@ import type { CursorKey } from '@/lib/api/list-query'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants'
import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries'
import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree'
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
import {
listWorkspaceFiles,
@@ -21,6 +22,12 @@ export interface QueryWorkspaceFilePageInput {
/** Lifecycle set to page over. Omission preserves the active-only default. */
scope?: 'active' | 'archived'
folderPath?: string
/**
* Whether `folderPath` covers its whole subtree rather than its direct children. Ignored
* without a `folderPath`, which already spans the workspace. The surface decides the
* default — see the v2 route, where a `search` implies a recursive look.
*/
recursive?: boolean
search?: string
sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt'
sortOrder: 'asc' | 'desc'
@@ -28,6 +35,30 @@ export interface QueryWorkspaceFilePageInput {
after?: CursorKey[]
}
/**
* Which folders a page covers, in the shape {@link queryWorkspaceFiles} takes: one id, `null`
* for the workspace root, several ids for a subtree, or `undefined` for the whole workspace.
*
* `unfiltered` (no `folderPath`) and a recursive filter on the root both mean the whole
* workspace, so both drop the folder predicate. A recursive filter on a real folder names
* every folder in its subtree, which the query takes as one `IN (...)` over the index already
* loaded for the path lookup — no second read, and no recursive CTE.
*/
function resolveFolderScope(
folderIndex: Awaited<ReturnType<typeof loadActiveFolderPathIndex>>,
folderFilter: ReturnType<typeof resolveFolderPathFilter>,
recursive: boolean | undefined
): string | null | string[] | undefined {
if (folderFilter.kind !== 'folder') return undefined
if (!recursive) return folderFilter.folderId
if (folderFilter.folderId === null) return undefined
const childrenByParent = indexFolderChildren(folderIndex.rowById.values())
return [
folderFilter.folderId,
...collectDescendantFolderIdsFrom(childrenByParent, folderFilter.folderId),
]
}
async function resolveListWorkspaceFileContext(workspaceId: string) {
const workspace = await loadActiveWorkspaceContext(workspaceId)
if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found')
@@ -68,7 +99,7 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({
const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, {
scope: input.scope,
folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined,
folderId: resolveFolderScope(folderIndex, folderFilter, input.recursive),
search: input.search,
sortBy: input.sortBy,
sortOrder: input.sortOrder,
+33 -1
View File
@@ -3461,6 +3461,19 @@ type ListFilesQueryRef0 = string
export type ListFilesQuery = {
workspaceId: string
folderPath?: ListFilesQueryRef0
recursive?:
| 'true'
| '1'
| 'yes'
| 'on'
| 'y'
| 'enabled'
| 'false'
| '0'
| 'no'
| 'off'
| 'n'
| 'disabled'
scope?: 'active' | 'archived'
search?: string
sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt'
@@ -7492,7 +7505,26 @@ export const V2_OPERATIONS = {
folderPath: {
kind: 'string',
describe:
'Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.',
'Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.',
},
recursive: {
kind: 'enum',
values: [
'true',
'1',
'yes',
'on',
'y',
'enabled',
'false',
'0',
'no',
'off',
'n',
'disabled',
] as const,
describe:
'Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.',
},
scope: {
kind: 'enum',
+103 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { defineRouteContract } from '../../apps/sim/lib/api/contracts/types'
import {
V2_ERROR_STATUS_BY_CODE,
type V2ErrorCode,
} from '../../apps/sim/lib/api/contracts/v2/error-codes'
import { billingOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/billing'
import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/files-audit'
import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows'
@@ -53,6 +57,24 @@ function operation(
}
}
/** A minimal route, for assertions about document-level output rather than the route itself. */
function simpleRoute(): OpenApiRouteDefinition {
const response = z.object({ ok: z.boolean().describe('Whether the call succeeded.') }).meta({
id: 'SimpleResponse',
title: 'Simple response',
description: 'Response body.',
})
return defineOpenApiRoute(
defineRouteContract({
method: 'GET',
path: '/simple',
response: { mode: 'json', schema: response },
}),
operation('simple', { description: 'Simple.' }),
{ response }
)
}
function document(routes: readonly OpenApiRouteDefinition[]) {
return defineOpenApiDocument({
output: 'unused.json',
@@ -75,8 +97,22 @@ function document(routes: readonly OpenApiRouteDefinition[]) {
headers: { Location: { schema: LOCATION_HEADER_SCHEMA } },
errorSchema: ERROR_SCHEMA,
errorResponses: {
Unauthorized: { status: 401, description: 'Unauthorized.' },
RateLimited: { status: 429, description: 'Rate limited.' },
Unauthorized: {
status: 401,
description: 'Unauthorized.',
example: { error: { code: 'UNAUTHORIZED', message: 'API key required' } },
},
RateLimited: {
status: 429,
description: 'Rate limited.',
example: { error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' } },
},
/** Declared but referenced by no operation below, so it must not be published. */
NotFound: {
status: 404,
description: 'Not found.',
example: { error: { code: 'NOT_FOUND', message: 'Not found' } },
},
},
routes,
})
@@ -635,4 +671,69 @@ describe('OpenAPI generator', () => {
'Content-Length': { $ref: '#/components/headers/Content-Length' },
})
})
it('gives each error response its own example beside the shared schema ref', () => {
const spec = generateOpenApiDocument(document([simpleRoute()]))
const responses = (spec.components as JsonObject).responses as JsonObject
const contentFor = (id: string) =>
((responses[id] as JsonObject).content as JsonObject)['application/json'] as JsonObject
expect(contentFor('Unauthorized').schema).toEqual({ $ref: '#/components/schemas/TestError' })
expect(contentFor('RateLimited').schema).toEqual({ $ref: '#/components/schemas/TestError' })
expect(contentFor('Unauthorized').example).toEqual({
error: { code: 'UNAUTHORIZED', message: 'API key required' },
})
expect(contentFor('RateLimited').example).toEqual({
error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' },
})
})
it('rejects an error example that does not fit the error schema', () => {
expect(() =>
generateOpenApiDocument({
...document([simpleRoute()]),
errorResponses: {
Unauthorized: {
status: 401,
description: 'Unauthorized.',
example: { error: { code: 'UNAUTHORIZED' } },
},
RateLimited: {
status: 429,
description: 'Rate limited.',
example: { error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' } },
},
},
})
).toThrow(/Unauthorized example/)
})
it('publishes only the error responses its operations reference', () => {
const spec = generateOpenApiDocument(document([simpleRoute()]))
const responses = (spec.components as JsonObject).responses as JsonObject
/** `NotFound` is defined on the document but no operation declares it. */
expect(Object.keys(responses).sort()).toEqual(['RateLimited', 'Unauthorized'])
})
it('publishes a distinct example under every documented error status', () => {
const spec = generateOpenApiDocument(workflowsOpenApiDocument)
const responses = (spec.components as JsonObject).responses as JsonObject
const byStatus = new Map<number, Set<string>>()
for (const response of Object.values(responses) as JsonObject[]) {
const content = (response.content as JsonObject)['application/json'] as JsonObject
const example = content.example as { error: { code: string } }
const status = V2_ERROR_STATUS_BY_CODE[example.error.code as V2ErrorCode]
expect(status, `${example.error.code} is not a v2 error code`).toBeDefined()
const codes = byStatus.get(status) ?? new Set<string>()
codes.add(example.error.code)
byStatus.set(status, codes)
}
/** Every status documents exactly one code — the property the derivation relies on. */
for (const [status, codes] of byStatus) {
expect([...codes], `status ${status}`).toHaveLength(1)
}
})
})
+31 -2
View File
@@ -692,7 +692,20 @@ function validateSecurity(
}
}
function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObject): JsonObject {
/**
* The error responses this document's operations actually reference, as `components.responses`.
*
* Only the referenced ones are built. An error response carries a worked example, and the
* message in one is often the domain's rather than the surface's — a `423` reads `Workflow is
* locked` in one document and `This table is insert-locked` in another. Emitting the whole set
* everywhere would ship each document a body for a status it never answers, phrased by a
* domain it does not contain.
*/
function errorComponents(
definition: OpenApiDocumentDefinition,
schemas: JsonObject,
referencedErrors: ReadonlySet<string>
): JsonObject {
const generated = generateSchema(
definition.errorSchema,
'output',
@@ -707,6 +720,7 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj
)
const responses: JsonObject = {}
for (const [id, response] of Object.entries(definition.errorResponses)) {
if (!referencedErrors.has(id)) continue
nonEmpty(id, 'Error response id')
nonEmpty(response.description, `${id} description`)
invariant(
@@ -718,6 +732,11 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj
for (const header of response.headers ?? []) {
invariant(definition.headers[header], `${id} references unknown response header ${header}`)
}
/**
* Held to the same standard as every other documented example: it must parse against
* the error schema, so a documented body cannot describe a shape the API never sends.
*/
validateExamples(definition.errorSchema, [response.example], 'output', `${id} example`)
responses[id] = {
description: response.description,
...(referencedHeaders(response.headers)
@@ -726,6 +745,13 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${generated.name}` },
/**
* Sits beside the `$ref` rather than on the shared schema: one schema serves every
* status, so a schema-level example is necessarily one status's body shown under
* all of them. A Media Type Object example overrides the schema's, which is what
* makes each status tab show its own.
*/
example: response.example,
},
},
}
@@ -742,7 +768,10 @@ export function generateOpenApiDocument(definition: OpenApiDocumentDefinition):
validateSecurity(definition.security, definition, 'OpenAPI document')
const schemas: JsonObject = {}
const responses = errorComponents(definition, schemas)
const referencedErrors = new Set(
definition.routes.flatMap((route) => [...route.operation.errors])
)
const responses = errorComponents(definition, schemas, referencedErrors)
const paths: JsonObject = {}
const operationIds = new Set<string>()
const routeKeys = new Set<string>()