diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3f50df8b4b0..51701951d47 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -96,7 +96,15 @@ "rowCount": 2, "maxRows": 100000, "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z" + "updatedAt": "2026-01-15T10:30:00.000Z", + "folderId": null, + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": false + }, + "job": null } ], "nextCursor": null @@ -197,7 +205,15 @@ "rowCount": 0, "maxRows": 100000, "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z" + "updatedAt": "2026-01-15T10:30:00.000Z", + "folderId": null, + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": false + }, + "job": null } } } @@ -353,6 +369,139 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "operationId": "updateTable", + "summary": "Update Table", + "description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.\n\n**Partial-success semantics.** The three operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the lock feature gate, the admin check, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails a later operation after an earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"locks\"`, `\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"customers\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTableBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "ws_123", + "name": "customers" + } + }, + "move": { + "summary": "Move to the workspace root", + "value": { + "workspaceId": "ws_123", + "folderId": null + } + }, + "lock": { + "summary": "Lock deletes (workspace admin)", + "value": { + "workspaceId": "ws_123", + "locks": { + "deleteLocked": true + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "customers", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + } + ] + }, + "rowCount": 42, + "maxRows": 100000, + "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": true + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z", + "job": null + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/tables/{tableId}/columns": { @@ -1495,1004 +1644,3825 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." - } - }, - "parameters": { - "TableId": { - "name": "tableId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "description": "The unique identifier of the table." - }, - "RowId": { - "name": "rowId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, - "description": "The unique identifier of the row." - }, - "WorkspaceIdQuery": { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1 - }, - "description": "The unique identifier of the workspace that owns the table." - }, - "LimitQuery": { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum rows to return (1-1000, default 100).", - "schema": { - "type": "integer", - "default": 100, - "minimum": 1, - "maximum": 1000 - } - }, - "CursorQuery": { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", - "schema": { - "type": "string", - "minLength": 1 - } - } - }, - "headers": { - "RateLimitLimit": { - "description": "Maximum number of requests permitted in the current rate-limit window.", - "schema": { - "type": "integer" - } - }, - "RateLimitRemaining": { - "description": "Number of requests remaining in the current rate-limit window.", - "schema": { - "type": "integer" - } - }, - "RateLimitReset": { - "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time" - } - }, - "RetryAfter": { - "description": "Number of seconds to wait before retrying the request.", - "schema": { - "type": "integer" - } - } }, - "schemas": { - "V2Error": { - "type": "object", - "description": "Canonical v2 error envelope.", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "description": "Machine-readable error code.", - "example": "BAD_REQUEST" - }, - "message": { - "type": "string", - "description": "Human-readable error message." + "/api/v2/tables/{tableId}/restore": { + "post": { + "operationId": "restoreTable", + "summary": "Restore Table", + "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table\u2019s name \u2014 rename that table first, then retry.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/restore\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" }, - "details": { - "description": "Optional structured error details, such as per-field validation issues." + "example": { + "workspaceId": "ws_123" } } } - } - }, - "Column": { - "type": "object", - "description": "A column definition in a table schema.", - "required": ["name", "type"], - "properties": { - "id": { - "type": "string", - "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", - "example": "col_a1b2c3" - }, - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "email" - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." + }, + "responses": { + "200": { + "description": "The restored table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "customers", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + } + ] + }, + "rowCount": 42, + "maxRows": 100000, + "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": true + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z", + "job": null + } + } + } + } + } }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "workflowGroupId": { - "type": "string", - "description": "Set when the column is the output of a workflow group." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." + "404": { + "$ref": "#/components/responses/NotFound" }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + "409": { + "$ref": "#/components/responses/Conflict" }, - "currencyCode": { - "type": "string", - "pattern": "^[A-Za-z]{3}$", - "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", - "example": "USD" + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" } } - }, - "ColumnInput": { - "type": "object", - "description": "Column definition supplied when creating a table or adding a column.", - "required": ["name", "type"], - "properties": { - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "email" + } + }, + "/api/v2/tables/{tableId}/views": { + "get": { + "operationId": "listTableViews", + "summary": "List Views", + "description": "Every saved view on the table, oldest first. A table carries a bounded set of views, so this is a single full page and `nextCursor` is always null. References to columns that no longer exist are pruned from each config on read.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table\u2019s saved views.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewListEnvelope" + }, + "example": { + "data": [ + { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + ], + "nextCursor": null + } + } + } }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "id": { - "type": "string", - "description": "Stable column id. Server-assigned \u2014 normally omit." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SelectOption" + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableView", + "summary": "Create View", + "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary \u2014 rows it hides stay readable through the row and query endpoints.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"Active customers\",\"config\":{}}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateViewBody" + }, + "example": { + "workspaceId": "ws_123", + "name": "Active customers", + "config": { + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + } + } + } + } + }, + "responses": { + "201": { + "description": "The created view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, - "description": "Declared options for a `select` column; absent on other types." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "currencyCode": { - "type": "string", - "pattern": "^[A-Za-z]{3}$", - "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", - "example": "USD" + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/views/{viewId}": { + "get": { + "operationId": "getTableView", + "summary": "Get View", + "description": "One saved view, with references to deleted columns pruned from its config.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableView", + "summary": "Update View", + "description": "Rename a view, replace or merge its config, or promote it to the table\u2019s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table\u2019s existing default in the same transaction.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"isDefault\":true}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateViewBody" + }, + "examples": { + "promote": { + "summary": "Make this the table\u2019s default view", + "value": { + "workspaceId": "ws_123", + "isDefault": true + } + }, + "replaceConfig": { + "summary": "Replace the saved filter", + "value": { + "workspaceId": "ws_123", + "config": { + "filter": { + "any": [ + { + "field": "col_a1b2c3", + "op": "isNotEmpty" + } + ] + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableView", + "summary": "Delete View", + "description": "Remove a saved view. Deleting the table\u2019s default simply leaves the table unfiltered; no rows are affected.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The view was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteViewEnvelope" + }, + "example": { + "data": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/groups": { + "get": { + "operationId": "listTableWorkflowGroups", + "summary": "List Workflow Groups", + "description": "The table\u2019s workflow and enrichment groups \u2014 the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table\u2019s workflow groups.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupListEnvelope" + }, + "example": { + "data": [ + { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "dependencies": { + "columns": ["col_a1b2c3"] + }, + "outputs": [ + { + "blockId": "blk_agent1", + "path": "content", + "columnName": "summary" + } + ], + "deploymentMode": "deployed", + "autoRun": true + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns/run": { + "post": { + "operationId": "runTableColumns", + "summary": "Run Column Groups", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) \u2014 never both. Omit both to run every row. Starting a run clears the target groups\u2019 cells to pending, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns/run\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"groupIds\":[\"grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204\"]}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunColumnBody" + }, + "examples": { + "everyRow": { + "summary": "Run a group across the whole table", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] + } + }, + "backfillFiltered": { + "summary": "Backfill only unfinished rows matching a predicate, capped at 500", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"], + "runMode": "incomplete", + "filter": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "limit": { + "type": "rows", + "max": 500 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}": { + "post": { + "operationId": "runRowEnrichment", + "summary": "Run Enrichment For One Row", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** \u2014 the response acknowledges the dispatch; read the row back for the result.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/GroupId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" + }, + "example": { + "workspaceId": "ws_123" + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/find": { + "post": { + "operationId": "findTableRows", + "summary": "Find Rows", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row\u2019s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor \u2014 when `truncated` is true, narrow the predicate rather than paging.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/find\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"q\":\"acme\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsBody" + }, + "examples": { + "wholeTable": { + "summary": "Search every cell", + "value": { + "workspaceId": "ws_123", + "q": "acme" + } + }, + "withinFilter": { + "summary": "Search inside a filtered, sorted view", + "value": { + "workspaceId": "ws_123", + "q": "acme", + "predicate": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "name", + "direction": "asc" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The matching cells.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsEnvelope" + }, + "example": { + "data": { + "matches": [ + { + "ordinal": 12, + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "column": "company" + } + ], + "truncated": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/import-csv": { + "post": { + "operationId": "createTableFromCsv", + "summary": "Create Table From CSV", + "description": "Create a table from a CSV or TSV file. The column schema is inferred from the file\u2019s first rows and the table is named after the file.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part, so an unauthorized upload is rejected before its bytes are read. Rows stream in as they are parsed, so a file larger than memory still imports; a failure part way through drops the half-populated table rather than leaving it behind.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/import-csv\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"workspaceId=YOUR_WORKSPACE_ID\" \\\n -F \"file=@contacts.csv\"" + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 10 MB are rejected with 413 \u2014 use the async import instead.", + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateTableFromCsvForm" + } + } + } + }, + "responses": { + "201": { + "description": "The created table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/jobs": { + "get": { + "operationId": "listTableJobs", + "summary": "List Export Jobs", + "description": "Export jobs across a workspace \u2014 running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/jobs?workspaceId=YOUR_WORKSPACE_ID&type=export\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobTypeQuery" + } + ], + "responses": { + "200": { + "description": "The workspace\u2019s export jobs.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableJobListEnvelope" + }, + "example": { + "data": [ + { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "tableName": "customers", + "status": "ready", + "rowsProcessed": 12043, + "format": "csv", + "hasResult": true, + "error": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/import": { + "post": { + "operationId": "importTableCsv", + "summary": "Import CSV", + "description": "Import a CSV or TSV into an existing table, appending or replacing its rows.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part. Omit `mapping` to auto-map CSV headers to same-named columns; pass `createColumns` to have unmatched headers created as new columns, with types inferred from the file. The response reports what was written AND what was not (`skippedHeaders`, `unmappedColumns`), so a partial mapping is visible without diffing the schema.\n\nThe table\u2019s single write-job slot is held for the whole import, so a concurrent import or delete gets 409. Files over 10 MB must use `POST /import-async`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"workspaceId=YOUR_WORKSPACE_ID\" \\\n -F \"mode=append\" \\\n -F \"file=@contacts.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 10 MB are rejected with 413 \u2014 use the async import instead.", + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ImportTableForm" + } + } + } + }, + "responses": { + "200": { + "description": "The import summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportTableEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "mode": "append", + "insertedCount": 250, + "mappedColumns": ["Email", "Full Name"], + "skippedHeaders": ["Notes"], + "unmappedColumns": ["created_by"], + "sourceFile": "contacts.csv" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/import-async": { + "post": { + "operationId": "importTableCsvAsync", + "summary": "Import CSV (Background)", + "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"fileKey\":\"workspace/YOUR_WORKSPACE_ID/imports/contacts.csv\",\"fileName\":\"contacts.csv\",\"mode\":\"append\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "fileKey": "workspace/ws_123/imports/contacts.csv", + "fileName": "contacts.csv", + "mode": "append" + } + } + } + }, + "responses": { + "200": { + "description": "The import was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "importId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export": { + "get": { + "operationId": "exportTable", + "summary": "Export Table", + "description": "Stream the whole table as a CSV or JSON file attachment.\n\nThe only endpoint whose success body is the file itself rather than the `{ data }` envelope. Rows are written as they are read, so nothing is buffered \u2014 but once the stream has started a failure can only tear the connection down. Large tables should use `POST /export-async`, which survives a dropped connection and leaves a re-downloadable result.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export?workspaceId=YOUR_WORKSPACE_ID&format=csv\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o table.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/ExportFormatQuery" + } + ], + "responses": { + "200": { + "description": "The table contents. CSV carries a header row of column names; JSON is an array of name-keyed row objects.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + }, + "Content-Disposition": { + "description": "Attachment filename, derived from the table name.", + "schema": { + "type": "string", + "example": "attachment; filename=\"customers.csv\"" + } + } + }, + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export-async": { + "post": { + "operationId": "exportTableAsync", + "summary": "Export Table (Background)", + "description": "Start a background export. Export jobs are read-only, so they bypass the one-write-job-per-table gate and can run alongside an import or delete.\n\nReturns as soon as the job is queued. Poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download` once the job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"format\":\"csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "format": "csv" + } + } + } + }, + "responses": { + "200": { + "description": "The export was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export/download": { + "get": { + "operationId": "downloadTableExport", + "summary": "Download Export", + "description": "Resolve a finished export job to a short-lived presigned download URL.\n\nThe failure modes are deliberately distinct: a job that is not an export of this table is 404, one still running is 409 (retry later), and one whose file has aged out of storage is 410 (start a new export). A caller polling to completion needs to tell \"not yet\" from \"never again\".", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export/download?workspaceId=YOUR_WORKSPACE_ID&jobId=YOUR_JOB_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobIdQuery" + } + ], + "responses": { + "200": { + "description": "The presigned download URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportDownloadEnvelope" + }, + "example": { + "data": { + "url": "https://storage.sim.ai/workspace/ws_123/exports/customers.csv?X-Amz-Signature=...", + "fileName": "customers.csv" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "410": { + "$ref": "#/components/responses/Gone" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/job/cancel": { + "post": { + "operationId": "cancelTableJob", + "summary": "Cancel Job", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place \u2014 there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/job/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"jobId\":\"YOUR_JOB_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobBody" + }, + "example": { + "workspaceId": "ws_123", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + }, + "responses": { + "200": { + "description": "The cancel outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobEnvelope" + }, + "example": { + "data": { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "canceled": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/cancel-runs": { + "post": { + "operationId": "cancelTableRuns", + "summary": "Cancel Column Runs", + "description": "Stop in-flight and pending workflow or enrichment cell runs \u2014 the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row\u2019s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/cancel-runs\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"scope\":\"all\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsBody" + }, + "examples": { + "everything": { + "summary": "Stop every run on the table", + "value": { + "workspaceId": "ws_123", + "scope": "all" + } + }, + "oneRow": { + "summary": "Stop one row\u2019s runs", + "value": { + "workspaceId": "ws_123", + "scope": "row", + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "How many runs were stopped.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsEnvelope" + }, + "example": { + "data": { + "cancelled": 17 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ViewId": { + "name": "viewId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "description": "The unique identifier of the saved view." + }, + "GroupId": { + "name": "groupId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + }, + "description": "The unique identifier of the workflow or enrichment group." + }, + "JobIdQuery": { + "name": "jobId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "example": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + }, + "description": "The export job to resolve." + }, + "ExportFormatQuery": { + "name": "format", + "in": "query", + "required": false, + "description": "Serialization for the exported file. Defaults to `csv`.", + "schema": { + "enum": ["csv", "json"], + "default": "csv" + } + }, + "JobTypeQuery": { + "name": "type", + "in": "query", + "required": true, + "description": "Job kind to list. Only `export` is supported today; the parameter is required so widening it later cannot silently change what an existing caller receives.", + "schema": { + "enum": ["export"] + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "id": { + "type": "string", + "description": "Stable column id. Server-assigned \u2014 normally omit." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "folderId", + "locks", + "createdAt", + "updatedAt", + "job" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder holding the table, or null when it sits at the workspace root." + }, + "locks": { + "$ref": "#/components/schemas/TableLocks" + }, + "job": { + "oneOf": [ + { + "$ref": "#/components/schemas/TableJobState" + }, + { + "type": "null" + } + ], + "description": "In-flight background job, or null when the table is idle." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] + } + } + } + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + ], + "description": "The column definition to add." + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowsEnvelope": { + "type": "object", + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowEnvelope": { + "type": "object", + "description": "Result of a single-row delete.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } } } }, - "Table": { + "UpsertRowEnvelope": { "type": "object", - "description": "A user-defined table with a typed column schema.", - "required": [ - "id", - "name", - "description", - "schema", - "rowCount", - "maxRows", - "createdAt", - "updatedAt" - ], + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], "properties": { - "id": { - "type": "string", - "description": "Unique table identifier.", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "name": { - "type": "string", - "description": "Table name.", - "example": "contacts" - }, - "description": { - "type": ["string", "null"], - "description": "Optional description of the table. Null when not set.", - "example": "Customer contact records" - }, - "schema": { + "data": { "type": "object", - "description": "Table schema definition.", - "required": ["columns"], + "required": ["row", "operation"], "properties": { - "columns": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + }, + "Predicate": { + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "oneOf": [ + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { "type": "array", - "description": "Array of column definitions for the table.", + "minItems": 1, + "maxItems": 100, "items": { - "$ref": "#/components/schemas/Column" + "$ref": "#/components/schemas/PredicateNode" } } } }, - "rowCount": { - "type": "integer", - "description": "Current number of rows in the table." - }, - "maxRows": { - "type": "integer", - "description": "Maximum rows allowed by the current billing plan." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was last modified." + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/PredicateNode" + } + } + } } - } + ] }, - "RowData": { - "type": "object", - "additionalProperties": true, - "description": "Row cells keyed by column name. Each value is typed per its column definition.", - "example": { - "email": "jane@example.com", - "name": "Jane Doe", - "age": 30 - } + "PredicateNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/Predicate" + }, + { + "$ref": "#/components/schemas/Condition" + } + ] }, - "Row": { + "Condition": { "type": "object", - "description": "A single row in a table.", - "required": ["id", "data", "createdAt", "updatedAt"], + "required": ["field", "op"], + "additionalProperties": false, "properties": { - "id": { + "field": { "type": "string", - "description": "Unique row identifier.", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, - "data": { - "$ref": "#/components/schemas/RowData" + "maxLength": 128, + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was created." + "op": { + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was last modified." + "value": { + "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." } } }, - "CreateTableBody": { + "SelectOption": { "type": "object", - "description": "Payload to create a new table.", - "required": ["workspaceId", "name", "schema"], + "required": ["id", "name"], "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "description": "The workspace that will own the table." + "description": "Stable option id \u2014 the value stored in cells." }, "name": { "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 128, - "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "contacts" + "maxLength": 100, + "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." + } + } + }, + "TableLocks": { + "type": "object", + "description": "Per-table governance flags. Every flag is present. Changing them requires workspace admin.", + "required": ["schemaLocked", "insertLocked", "updateLocked", "deleteLocked"], + "properties": { + "schemaLocked": { + "type": "boolean", + "description": "Blocks column adds, edits, and deletes." }, - "description": { - "type": "string", - "maxLength": 500, - "description": "Optional description of the table." + "insertLocked": { + "type": "boolean", + "description": "Blocks new rows." }, - "schema": { - "type": "object", - "required": ["columns"], - "description": "The table's column schema.", - "properties": { - "columns": { - "type": "array", - "minItems": 1, - "maxItems": 50, - "description": "Column definitions. A table must have between 1 and 50 columns.", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/ColumnInput" - }, - { - "type": "object", - "properties": { - "workflowGroupId": { - "type": "string", - "description": "Advanced: binds the column to a workflow group's output." - } - } - } - ] - } - } - } + "updateLocked": { + "type": "boolean", + "description": "Blocks cell writes to existing rows." }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + "deleteLocked": { + "type": "boolean", + "description": "Blocks row deletes and archiving the table." } } }, - "AddColumnBody": { + "TableLocksPatch": { "type": "object", - "description": "Payload to add a column to a table.", - "required": ["workspaceId", "column"], + "description": "Lock flags to change. Omitted flags are left as they are.", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "schemaLocked": { + "type": "boolean", + "description": "Blocks column adds, edits, and deletes." }, - "column": { - "allOf": [ - { - "$ref": "#/components/schemas/ColumnInput" - }, - { - "type": "object", - "properties": { - "position": { - "type": "integer", - "minimum": 0, - "description": "Zero-based insert position in the column order. Appended at the end when omitted." - } - } - } - ], - "description": "The column definition to add." + "insertLocked": { + "type": "boolean", + "description": "Blocks new rows." + }, + "updateLocked": { + "type": "boolean", + "description": "Blocks cell writes to existing rows." + }, + "deleteLocked": { + "type": "boolean", + "description": "Blocks row deletes and archiving the table." } } }, - "UpdateColumnBody": { + "UpdateTableBody": { "type": "object", - "description": "Payload to update an existing column by name.", - "required": ["workspaceId", "columnName", "updates"], + "description": "Rename, move, and/or re-lock a table. Every field beyond `workspaceId` is optional, but at least one must be present.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "columnName": { + "name": { "type": "string", - "description": "The current name of the column to update.", - "example": "phone" + "minLength": 1, + "description": "New table name." }, - "updates": { - "type": "object", - "description": "Fields to change. Provide at least one.", - "properties": { - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "New column name.", - "example": "phone_number" - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "New data type for the column." - }, - "required": { - "type": "boolean", - "description": "Whether the column requires a value on insert." - }, - "unique": { - "type": "boolean", - "description": "Whether values in this column must be unique across all rows." - }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." - }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." - }, - "currencyCode": { - "type": "string", - "pattern": "^[A-Za-z]{3}$", - "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", - "example": "USD" - } - } + "folderId": { + "type": ["string", "null"], + "description": "Folder to move the table into. Pass null to move it to the workspace root; omit to leave the placement untouched." + }, + "locks": { + "$ref": "#/components/schemas/TableLocksPatch" } } }, - "DeleteColumnBody": { + "WorkspaceScopedBody": { "type": "object", - "description": "Payload to delete a column by name.", - "required": ["workspaceId", "columnName"], + "description": "Endpoints whose only input is the workspace the table must belong to.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." + } + } + }, + "SortSpec": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first. Fields are column names.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { + "type": "string" + }, + "direction": { + "enum": ["asc", "desc"] + } + } + } + }, + "ViewConfig": { + "type": "object", + "description": "A view\u2019s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "properties": { + "columnWidths": { + "type": "object", + "description": "Pixel widths keyed by column id.", + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0 + } }, - "columnName": { + "columnOrder": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Left-to-right column order, as column ids." + }, + "pinnedColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids pinned while scrolling horizontally." + }, + "hiddenColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids hidden by the view. A deny-list \u2014 a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" + } + } + }, + "View": { + "type": "object", + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only \u2014 a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "required": [ + "id", + "tableId", + "name", + "config", + "isDefault", + "createdBy", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string", - "description": "The name of the column to delete.", - "example": "phone_number" + "description": "Unique view identifier.", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "tableId": { + "type": "string", + "description": "The table the view belongs to." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "config": { + "$ref": "#/components/schemas/ViewConfig" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this view is the table\u2019s default. At most one view per table is." + }, + "createdBy": { + "type": ["string", "null"], + "description": "User who saved the view, or null when that user no longer exists." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "CreateRowSingleBody": { + "CreateViewBody": { "type": "object", - "description": "Insert a single row.", - "required": ["workspaceId", "data"], + "description": "Save a filter/sort/layout preset as a named view.", + "required": ["workspaceId", "name", "config"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "data": { - "$ref": "#/components/schemas/RowData" - }, - "afterRowId": { + "name": { "type": "string", "minLength": 1, - "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + "description": "Display name for the view." }, - "beforeRowId": { - "type": "string", - "minLength": 1, - "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + "config": { + "$ref": "#/components/schemas/ViewConfig" } } }, - "CreateRowBatchBody": { + "UpdateViewBody": { "type": "object", - "description": "Insert multiple rows in one request.", - "required": ["workspaceId", "rows"], + "description": "Change a saved view. At least one of `name`, `config`, `configPatch`, or `isDefault` is required; `config` and `configPatch` are mutually exclusive.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "rows": { + "name": { + "type": "string", + "minLength": 1, + "description": "New display name." + }, + "config": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Replaces the stored config wholesale. Use when dropping a removed filter must persist." + }, + "configPatch": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Shallow-merged into the stored config server-side, so two overlapping partial writes cannot clobber each other from stale snapshots." + }, + "isDefault": { + "type": "boolean", + "description": "Promote this view to the table\u2019s default. Setting it demotes the table\u2019s existing default in the same transaction." + } + } + }, + "ViewEnvelope": { + "type": "object", + "description": "A single view wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["view"], + "properties": { + "view": { + "$ref": "#/components/schemas/View" + } + } + } + } + }, + "ViewListEnvelope": { + "type": "object", + "description": "Saved views wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], + "properties": { + "data": { "type": "array", - "minItems": 1, - "maxItems": 1000, - "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", "items": { - "$ref": "#/components/schemas/RowData" + "$ref": "#/components/schemas/View" } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null \u2014 a table carries a bounded set of views, so the list is a single full page." } } }, - "CreateRowsBody": { - "description": "Either a single-row payload or a batch payload.", - "oneOf": [ - { - "$ref": "#/components/schemas/CreateRowSingleBody" - }, - { - "$ref": "#/components/schemas/CreateRowBatchBody" + "DeleteViewEnvelope": { + "type": "object", + "description": "Delete confirmation carrying the id of the removed view.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The view that was deleted." + } + } } - ] + } }, - "UpdateRowsByFilterBody": { + "WorkflowGroup": { "type": "object", - "description": "Bulk-update rows matching a filter.", - "required": ["workspaceId", "filter", "data"], + "description": "A workflow or enrichment group: a backing workflow (or registry enrichment) plus the output columns its runs populate. Authored in the workflow builder; exposed here so a caller can discover the group ids the run endpoints take.", + "required": ["id", "workflowId", "outputs"], "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "description": "Group id \u2014 pass to the run endpoints." }, - "filter": { - "$ref": "#/components/schemas/Predicate" + "workflowId": { + "type": "string", + "description": "Backing workflow id for manual groups; empty string for enrichment groups." }, - "data": { - "$ref": "#/components/schemas/RowData" + "enrichmentId": { + "type": "string", + "description": "Registry enrichment id, present on enrichment groups." }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of matching rows to update." + "name": { + "type": "string", + "description": "Display name." + }, + "type": { + "enum": ["manual", "enrichment"], + "description": "Provenance of the group. Defaults to manual when absent." + }, + "dependencies": { + "type": "object", + "description": "Columns whose values must be present before the group is eligible to run.", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "outputs": { + "type": "array", + "description": "Which produced value flows into which column.", + "items": { + "type": "object", + "required": ["blockId", "path", "columnName"], + "properties": { + "blockId": { + "type": "string", + "description": "Source block in the workflow. Empty on enrichment outputs." + }, + "path": { + "type": "string", + "description": "Path into the block output. Empty on enrichment outputs." + }, + "outputId": { + "type": "string", + "description": "Enrichment output id, on enrichment groups." + }, + "columnName": { + "type": "string", + "description": "Column the value is written to." + } + } + } + }, + "inputMappings": { + "type": "array", + "description": "Which table column supplies each workflow Start-block input.", + "items": { + "type": "object", + "required": ["inputName", "columnName"], + "properties": { + "inputName": { + "type": "string" + }, + "columnName": { + "type": "string" + } + } + } + }, + "deploymentMode": { + "enum": ["live", "deployed"], + "description": "Which workflow state per-cell runs execute against. Defaults to live (the editable draft)." + }, + "autoRun": { + "type": "boolean", + "description": "When false the group never auto-fires; it runs only on an explicit request. Defaults to true." } } }, - "DeleteRowsByFilterBody": { + "WorkflowGroupListEnvelope": { "type": "object", - "description": "Delete rows matching a filter.", - "required": ["workspaceId", "filter"], + "description": "Workflow groups wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." - }, - "filter": { - "$ref": "#/components/schemas/Predicate" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroup" + } }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of matching rows to delete." + "nextCursor": { + "type": ["string", "null"], + "description": "Always null \u2014 groups are bounded per table, so the list is a single full page." } } }, - "DeleteRowsByIdsBody": { + "RunColumnBody": { "type": "object", - "description": "Delete an explicit list of rows by id.", - "required": ["workspaceId", "rowIds"], + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) \u2014 never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "required": ["workspaceId", "groupIds"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, + "groupIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Groups to run, from `GET /api/v2/tables/{tableId}/groups`." + }, + "runMode": { + "enum": ["all", "incomplete"], + "default": "all", + "description": "`all` re-runs every dep-satisfied row. `incomplete` restricts to rows whose group has never run or whose last run failed or aborted." + }, "rowIds": { "type": "array", "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Run only these rows. Mutually exclusive with `filter`." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", "maxItems": 1000, - "description": "Row ids to delete. Up to 1000 ids per request.", "items": { "type": "string", "minLength": 1 - } + }, + "description": "Rows to skip within the `filter` scope." }, "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of rows to delete." + "type": "object", + "description": "Cap the run to the first N eligible rows. Omit for an unbounded run.", + "required": ["type", "max"], + "properties": { + "type": { + "enum": ["rows"] + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + } } } }, - "DeleteRowsBody": { - "description": "Provide exactly one of `filter` or `rowIds`.", - "oneOf": [ - { - "$ref": "#/components/schemas/DeleteRowsByFilterBody" - }, - { - "$ref": "#/components/schemas/DeleteRowsByIdsBody" + "RunEnvelope": { + "type": "object", + "description": "Acknowledgement that a run was dispatched.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["dispatchId"], + "properties": { + "dispatchId": { + "type": ["string", "null"], + "description": "Identifies the dispatch the runner walks. Null where no background runner is configured and cells execute inline." + } + } } - ] + } }, - "UpdateRowBody": { + "FindRowsBody": { "type": "object", - "description": "Partial update for a single row.", - "required": ["workspaceId", "data"], + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`.", + "required": ["workspaceId", "q"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "data": { - "$ref": "#/components/schemas/RowData" + "q": { + "type": "string", + "minLength": 1, + "description": "Substring to search for." + }, + "predicate": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" } } }, - "UpsertRowBody": { + "RowMatch": { "type": "object", - "description": "Insert-or-update a row keyed by a unique column.", - "required": ["workspaceId", "data"], + "description": "One matching cell.", + "required": ["ordinal", "rowId", "column"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "ordinal": { + "type": "integer", + "description": "The row\u2019s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments \u2014 use it to page straight to the match." }, - "data": { - "$ref": "#/components/schemas/RowData" + "rowId": { + "type": "string", + "description": "The row holding the matching cell." }, - "conflictTarget": { + "column": { "type": "string", - "minLength": 1, - "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + "description": "Name of the matching column." } } }, - "TableEnvelope": { + "FindRowsEnvelope": { "type": "object", - "description": "A single table wrapped in the v2 data envelope.", + "description": "Matching cells wrapped in the v2 data envelope.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["table"], + "required": ["matches", "truncated"], "properties": { - "table": { - "$ref": "#/components/schemas/Table" + "matches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RowMatch" + } + }, + "truncated": { + "type": "boolean", + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor \u2014 narrow the predicate instead of paging." } } } } }, - "TableListEnvelope": { + "ImportTableForm": { "type": "object", - "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", - "required": ["data", "nextCursor"], + "description": "Multipart form for a synchronous import. `mapping` and `createColumns` are JSON-encoded strings, since every multipart field arrives as text.", + "required": ["workspaceId", "file"], "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Table" - } + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table. Must appear BEFORE the file part \u2014 the server rejects an unauthorized upload before reading its bytes." }, - "nextCursor": { - "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null when there are no more pages." + "file": { + "type": "string", + "format": "binary", + "description": "The .csv or .tsv file." + }, + "mode": { + "enum": ["append", "replace"], + "default": "append", + "description": "`append` adds rows; `replace` deletes every existing row first." + }, + "mapping": { + "type": "string", + "description": "JSON object mapping each CSV header to a column name, or null to skip that header. Omit to auto-map headers to same-named columns.", + "example": "{\"Email\":\"email\",\"Full Name\":\"name\",\"Notes\":null}" + }, + "createColumns": { + "type": "string", + "description": "JSON array of CSV headers to create as new columns before importing. Their types are inferred from the file.", + "example": "[\"Phone\"]" + }, + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes (Excel and Sheets exports carry no offset). Defaults to the API key owner\u2019s saved timezone, else UTC.", + "example": "America/New_York" } } }, - "DeleteTableEnvelope": { + "CreateTableFromCsvForm": { "type": "object", - "description": "Confirmation that a table was archived.", - "required": ["data"], + "description": "Multipart form for creating a table from a file.", + "required": ["workspaceId", "file"], "properties": { - "data": { - "type": "object", - "required": ["id"], - "properties": { - "id": { - "type": "string", - "description": "The id of the archived table." - } - } + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the table in. Must appear BEFORE the file part \u2014 the server rejects an unauthorized upload before reading its bytes." + }, + "file": { + "type": "string", + "format": "binary", + "description": "The .csv or .tsv file." + }, + "folderId": { + "type": "string", + "description": "Folder to create the table in. Omit to create it at the workspace root." + }, + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes. Defaults to the API key owner\u2019s saved timezone, else UTC.", + "example": "America/New_York" } } }, - "ColumnsEnvelope": { + "ImportTableEnvelope": { "type": "object", - "description": "The table's full column list after a column mutation.", + "description": "Synchronous-import summary wrapped in the v2 data envelope.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["columns"], + "required": [ + "tableId", + "mode", + "insertedCount", + "mappedColumns", + "skippedHeaders", + "unmappedColumns", + "sourceFile" + ], "properties": { - "columns": { + "tableId": { + "type": "string" + }, + "mode": { + "enum": ["append", "replace"] + }, + "insertedCount": { + "type": "integer", + "description": "Rows written." + }, + "deletedCount": { + "type": "integer", + "description": "Rows removed first. Present only for `mode: \"replace\"`." + }, + "mappedColumns": { "type": "array", "items": { - "$ref": "#/components/schemas/Column" - } + "type": "string" + }, + "description": "CSV headers that were written to a column." + }, + "skippedHeaders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CSV headers the mapping explicitly skipped." + }, + "unmappedColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Table columns no CSV header supplied \u2014 left at their existing values." + }, + "sourceFile": { + "type": "string", + "description": "Uploaded filename, echoed back." } } } } }, - "RowEnvelope": { + "ImportAsyncEnvelope": { "type": "object", - "description": "A single row wrapped in the v2 data envelope.", + "description": "Background-import kickoff acknowledgement.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["row"], + "required": ["tableId", "importId"], "properties": { - "row": { - "$ref": "#/components/schemas/Row" + "tableId": { + "type": "string" + }, + "importId": { + "type": "string", + "description": "Job id \u2014 pass to `POST /job/cancel` to stop the import." } } } } }, - "RowListEnvelope": { + "ImportAsyncBody": { "type": "object", - "description": "A cursor-paginated page of rows.", - "required": ["data", "nextCursor"], + "description": "Starts a background import of a file already uploaded to workspace storage. The file is read by the worker, not from this request.", + "required": ["workspaceId", "fileKey", "fileName", "mode"], "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Row" - } + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - "nextCursor": { - "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null on the final page." - } - } - }, - "BatchInsertRowsEnvelope": { - "type": "object", - "description": "Result of a batch row insert.", - "required": ["data"], - "properties": { - "data": { + "fileKey": { + "type": "string", + "minLength": 1, + "description": "Storage key of the uploaded file. Must sit under this workspace\u2019s `workspace/{workspaceId}/` prefix.", + "example": "workspace/ws_123/imports/contacts.csv" + }, + "fileName": { + "type": "string", + "minLength": 1, + "description": "Original filename. Its extension selects the separator (.csv or .tsv)." + }, + "mode": { + "enum": ["append", "replace"], + "description": "`append` adds rows; `replace` deletes every existing row first." + }, + "mapping": { "type": "object", - "required": ["rows", "insertedCount"], - "properties": { - "rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Row" - } - }, - "insertedCount": { - "type": "integer", - "description": "Number of rows inserted." - } + "description": "CSV header \u2192 column name, or null to skip the header.", + "additionalProperties": { + "type": ["string", "null"] } - } - } - }, - "CreateRowsResponse": { - "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", - "oneOf": [ - { - "$ref": "#/components/schemas/RowEnvelope" }, - { - "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + "createColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CSV headers to create as new columns before importing." + }, + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes.", + "example": "America/New_York" } - ] + } }, - "UpdateRowsEnvelope": { + "ExportAsyncBody": { "type": "object", - "description": "Result of a bulk update-by-filter.", - "required": ["data"], + "description": "Starts a background export.", + "required": ["workspaceId"], "properties": { - "data": { - "type": "object", - "required": ["updatedCount", "updatedRowIds"], - "properties": { - "updatedCount": { - "type": "integer", - "description": "Number of rows updated." - }, - "updatedRowIds": { - "type": "array", - "description": "Ids of the updated rows. Empty when nothing matched.", - "items": { - "type": "string" - } - } - } + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "format": { + "enum": ["csv", "json"], + "default": "csv", + "description": "Serialization to produce." } } }, - "DeleteRowsEnvelope": { + "ExportAsyncEnvelope": { "type": "object", - "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "description": "Background-export kickoff acknowledgement.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "required": ["tableId", "jobId"], "properties": { - "deletedCount": { - "type": "integer", - "description": "Number of rows deleted." - }, - "deletedRowIds": { - "type": "array", - "description": "Ids of the deleted rows.", - "items": { - "type": "string" - } + "tableId": { + "type": "string" }, - "requestedCount": { - "type": "integer", - "description": "Number of row ids requested. Present only for id-based deletes." - }, - "missingRowIds": { - "type": "array", - "description": "Requested ids that did not exist. Present only for id-based deletes.", - "items": { - "type": "string" - } + "jobId": { + "type": "string", + "description": "Job id \u2014 poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } } }, - "DeleteRowEnvelope": { + "ExportDownloadEnvelope": { "type": "object", - "description": "Result of a single-row delete.", + "description": "A short-lived presigned download URL for a finished export.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "required": ["url", "fileName"], "properties": { - "deletedCount": { - "type": "integer", - "description": "Always 1 when a row was deleted." + "url": { + "type": "string", + "description": "Presigned URL. Expires shortly after issue \u2014 fetch it promptly." }, - "deletedRowIds": { - "type": "array", - "description": "The id of the deleted row.", - "items": { - "type": "string" - } + "fileName": { + "type": "string", + "description": "Suggested filename for the download." } } } } }, - "UpsertRowEnvelope": { + "TableJob": { "type": "object", - "description": "Result of an upsert, including whether the row was inserted or updated.", - "required": ["data"], + "description": "One export job.", + "required": [ + "jobId", + "tableId", + "tableName", + "status", + "rowsProcessed", + "format", + "hasResult", + "error" + ], + "properties": { + "jobId": { + "type": "string" + }, + "tableId": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "Only `ready` jobs can be downloaded." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows written so far." + }, + "format": { + "enum": ["csv", "json"] + }, + "hasResult": { + "type": "boolean", + "description": "Whether a generated file is still available to download." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." + } + } + }, + "TableJobListEnvelope": { + "type": "object", + "description": "Export jobs wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], "properties": { "data": { - "type": "object", - "required": ["row", "operation"], - "properties": { - "row": { - "$ref": "#/components/schemas/Row" - }, - "operation": { - "type": "string", - "enum": ["insert", "update"], - "description": "Whether the row was inserted or updated." - } + "type": "array", + "items": { + "$ref": "#/components/schemas/TableJob" } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null \u2014 the listing is bounded server-side to a single page." } } }, - "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", - "oneOf": [ - { - "type": "object", - "required": ["all"], - "additionalProperties": false, - "properties": { - "all": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/PredicateNode" - } - } - } + "CancelJobBody": { + "type": "object", + "description": "Stops an in-flight import or delete job.", + "required": ["workspaceId", "jobId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - { + "jobId": { + "type": "string", + "minLength": 1, + "description": "The job to stop." + } + } + }, + "CancelJobEnvelope": { + "type": "object", + "description": "Cancel outcome.", + "required": ["data"], + "properties": { + "data": { "type": "object", - "required": ["any"], - "additionalProperties": false, + "required": ["jobId", "canceled"], "properties": { - "any": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/PredicateNode" - } + "jobId": { + "type": "string" + }, + "canceled": { + "type": "boolean", + "description": "False when the job had already finished. Cancelling is idempotent \u2014 a late request is not an error." } } } - ] + } }, - "PredicateNode": { - "oneOf": [ - { + "CancelRunsBody": { + "type": "object", + "description": "Stops in-flight and pending cell runs. `filter` and `excludeRowIds` apply only to `scope: \"all\"`; `rowId` is required for `scope: \"row\"`.", + "required": ["workspaceId", "scope"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "scope": { + "enum": ["all", "row"], + "description": "`all` cancels every running and pending cell; `row` cancels one row\u2019s cells." + }, + "rowId": { + "type": "string", + "minLength": 1, + "description": "Required when `scope` is `row`." + }, + "filter": { "$ref": "#/components/schemas/Predicate" }, - { - "$ref": "#/components/schemas/Condition" + "excludeRowIds": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Rows to leave running within the `filter` scope." } - ] + } }, - "Condition": { + "CancelRunsEnvelope": { "type": "object", - "required": ["field", "op"], - "additionalProperties": false, + "description": "How many in-flight cell runs were stopped.", + "required": ["data"], "properties": { - "field": { - "type": "string", - "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." - }, - "op": { - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." - }, - "value": { - "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." + "data": { + "type": "object", + "required": ["cancelled"], + "properties": { + "cancelled": { + "type": "integer" + } + } } } }, - "SelectOption": { + "TableJobState": { "type": "object", - "required": ["id", "name"], + "description": "The table's in-flight background job. Import and delete jobs are derived onto the table itself (one write job per table), so the table is their status endpoint \u2014 poll `GET /api/v2/tables/{tableId}` after starting one. Exports are read-only and run concurrently, so they are listed separately by `GET /api/v2/tables/jobs` instead.", + "required": ["id", "type", "status", "rowsProcessed", "error"], "properties": { "id": { - "type": "string", - "description": "Stable option id \u2014 the value stored in cells." + "type": ["string", "null"], + "description": "Job id \u2014 pass to `POST /job/cancel` to stop it." }, - "name": { - "type": "string", - "maxLength": 100, - "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." + "type": { + "enum": ["import", "delete", "export", "backfill", "update", null], + "description": "Which kind of job is running." + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "`running` is in-flight; the rest are terminal." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows handled so far \u2014 progress for a running job." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." } } } @@ -2616,6 +5586,70 @@ } } } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource \u2014 for example a rename to a name another table in the workspace already uses.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A table named \"contacts\" already exists" + } + } + } + } + }, + "Locked": { + "description": "The table has a lock that forbids this operation. Clear the relevant lock with `PATCH /api/v2/tables/{tableId}` (workspace admin only) and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Schema changes are locked for this table" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The upload is too large for a synchronous import. Upload the file to workspace storage and use `POST /api/v2/tables/{tableId}/import-async` instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "CSV import file exceeds maximum size" + } + } + } + } + }, + "Gone": { + "description": "The generated export file has aged out of storage. Start a new export rather than retrying this download.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Export file is no longer available" + } + } + } + } } } } diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 58df047c629..17a845ed0ae 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -1,25 +1,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { namedRowMapper } from '@/lib/table/cell-format' -import { getColumnId } from '@/lib/table/column-keys' -import { formatCsvCell } from '@/lib/table/export-format' -import { queryRows } from '@/lib/table/rows/service' +import { + createTableExportStream, + exportContentType, + sanitizeExportFilename, +} from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableExport') - -const EXPORT_BATCH_SIZE = 1000 - -type ExportFormat = 'csv' | 'json' - interface RouteParams { params: Promise<{ tableId: string }> } @@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou { status: 400 } ) } - const format: ExportFormat = formatValidation.data + const format = formatValidation.data const access = await checkAccess(tableId, auth.userId, 'read') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access - const columns = table.schema.columns - // Stored row data is id-keyed; CSV headers and JSON keys are display names, so - // translate id → name on the way out (export is a name-friendly boundary). - const toNamedRow = namedRowMapper(columns) - const safeName = sanitizeFilename(table.name) - const filename = `${safeName}.${format}` - // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, @@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou ) } - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - try { - if (format === 'csv') { - controller.enqueue( - encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) - ) - } else { - controller.enqueue(encoder.encode('[')) - } - - let offset = 0 - let firstJsonRow = true - while (true) { - const result = await queryRows( - table, - { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, - requestId - ) - - for (const row of result.rows) { - if (format === 'csv') { - const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) - controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) - } else { - const prefix = firstJsonRow ? '' : ',' - firstJsonRow = false - controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) - } - } - - // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, - // so a short page does NOT mean the export is done — only a null cursor does. - if (!result.nextCursor) break - offset += result.rows.length - } - - if (format === 'json') controller.enqueue(encoder.encode(']')) - controller.close() - - logger.info(`[${requestId}] Exported table ${tableId}`, { - format, - rowCount: table.rowCount, - }) - } catch (err) { - logger.error(`[${requestId}] Export failed for table ${tableId}`, err) - controller.error(err) - } - }, - }) - - return new NextResponse(stream, { + return new NextResponse(createTableExportStream(table, format, requestId), { status: 200, headers: { - 'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Type': exportContentType(format), + 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, 'Cache-Control': 'no-store', }, }) }) - -function sanitizeFilename(name: string): string { - const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') - return cleaned || 'table' -} - -function toCsvRow(values: string[]): string { - return values.map(escapeCsvField).join(',') -} - -function escapeCsvField(field: string): string { - if (/[",\n\r]/.test(field)) { - return `"${field.replace(/"/g, '""')}"` - } - return field -} diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 3aa28fe34ee..465777f46a3 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -1,7 +1,5 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, @@ -14,39 +12,18 @@ import { import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - buildAutoMapping, - CSV_MAX_FILE_SIZE_BYTES, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - createCsvParser, - dispatchAfterBatchInsert, - generateColumnId, - getMaxRowsPerTable, - inferColumnType, - markTableJobRunning, - releaseJobClaim, - sanitizeName, - type TableDefinition, - type TableSchema, - validateMapping, - wouldExceedRowLimit, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' -import { signalTableSchemaChanged } from '@/lib/table/events' -import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table' +import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, - tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -63,7 +40,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const requestId = generateRequestId() const { tableId } = tableIdParamsSchema.parse(await params) let fileStream: Readable | undefined - let claimedImportId: string | null = null try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -132,18 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if (table.archivedAt) { - return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) - } - // Don't run a sync import on top of an in-flight background job — concurrent writers - // would insert at colliding row positions. - if (table.jobStatus === 'running') { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - let mapping: CsvHeaderMapping | undefined if (fields.mapping) { const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping) @@ -180,246 +144,46 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro timezone = timezoneValidation.data } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionValidation.data === 'tsv' ? '\t' : ',' - ) - let headers: string[] = [] - const parser = createCsvParser(delimiter, (parsedHeaders) => { - headers = parsedHeaders + const outcome = await performTableCsvImport({ + table, + workspaceId, + userId: authResult.userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionValidation.data === 'tsv' ? '\t' : ',', + mode, + mapping, + createColumns, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (streamErr) => parser.destroy(streamErr)) - csvStream.pipe(parser) - const rows: Record[] = [] - for await (const record of parser as AsyncIterable>) { - rows.push(record) - } - if (rows.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - - let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) - let prospectiveTable: TableDefinition = table - const additions: { id?: string; name: string; type: string }[] = [] - - if (createColumns && createColumns.length > 0) { - const headerSet = new Set(headers) - const unknownHeaders = createColumns.filter((h) => !headerSet.has(h)) - if (unknownHeaders.length > 0) { - return NextResponse.json( - { - error: `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, - }, - { status: 400 } - ) - } - - const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) - const updatedMapping: CsvHeaderMapping = { ...effectiveMapping } - const newColumns: TableSchema['columns'] = [] - for (const header of createColumns) { - const base = sanitizeName(header) - let columnName = base - let suffix = 2 - while (usedNames.has(columnName.toLowerCase())) { - columnName = `${base}_${suffix}` - suffix++ - } - usedNames.add(columnName.toLowerCase()) - const inferredType = inferColumnType(rows.map((r) => r[header])) - // Pre-assign the id so the prospective schema (used to coerce rows) and - // the persisted column (created in importAppendRows) share the same key. - const id = generateColumnId() - additions.push({ id, name: columnName, type: inferredType }) - newColumns.push({ - id, - name: columnName, - type: inferredType as TableSchema['columns'][number]['type'], - required: false, - unique: false, - }) - updatedMapping[header] = columnName + if (!outcome.success) { + // A lock rejection renders `{ error, lock }` and deliberately carries NO + // `details`: the client's `isValidationError` treats any array-valued + // `details` as a field-validation error and swallows the toast. + if (outcome.errorCode === 'locked') { + return NextResponse.json({ error: outcome.error, lock: outcome.lock }, { status: 423 }) } - - prospectiveTable = { - ...table, - schema: { columns: [...table.schema.columns, ...newColumns] }, - } - effectiveMapping = updatedMapping - } - - let validation: ReturnType - try { - validation = validateMapping({ - csvHeaders: headers, - mapping: effectiveMapping, - tableSchema: prospectiveTable.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return NextResponse.json({ error: err.message, details: err.details }, { status: 400 }) - } - throw err - } - - if (validation.mappedHeaders.length === 0) { return NextResponse.json( { - error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveTable.schema.columns.map((c) => c.name).join(', ')}`, + error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error, + ...(outcome.details !== undefined ? { details: outcome.details } : {}), + // The append dialog reads this to distinguish "nothing landed" from a + // partial import; only that mode has ever carried it. + ...(mode === 'append' ? { data: { insertedCount: 0 } } : {}), }, - { status: 400 } + { status: statusForOrchestrationError(outcome.errorCode) } ) } - const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, { - timezone, - }) - - // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot - // taken before the parse/validation; a background import could claim the table in that window. - // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in - // the finally so a sync import can't write concurrently with a background one (corrupts replace). - const syncImportId = generateId() - if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - claimedImportId = syncImportId - - if (mode === 'append') { - const maxRows = await getMaxRowsPerTable(workspaceId) - if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) { - const deficit = prospectiveTable.rowCount + coerced.length - maxRows - return NextResponse.json( - { - error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, - }, - { status: 400 } - ) - } - - try { - const { inserted: insertedRows, table: finalTable } = await importAppendRows( - table, - additions, - coerced, - { workspaceId, userId: authResult.userId, requestId } - ) - const inserted = insertedRows.length - // Fire trigger + scheduler AFTER the tx commits — both read through the - // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, insertedRows, requestId, authResult.userId) - - logger.info(`[${requestId}] Append CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - inserted, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - skippedHeaders: validation.skippedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - insertedCount: inserted, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - // This branch returns rather than rethrowing, so the outer catch's - // mapper is unreachable from here — map the lock error first or a 423 - // degrades into a generic 500 (replace mode rethrows and maps fine). - const lockError = tableLockErrorResponse(err) - if (lockError) return lockError - - const message = toError(err).message - logger.warn(`[${requestId}] Append failed for table ${tableId}`, { - total: coerced.length, - createdColumns: additions.length, - error: message, - }) - const classified = asOrchestrationError(err) - return NextResponse.json( - { - error: classified ? classified.message : 'Failed to import CSV', - data: { insertedCount: 0 }, - }, - { status: classified ? statusForOrchestrationError(classified.code) : 500 } - ) - } - } - - try { - const result = await importReplaceRows( - table, - additions, - { rows: coerced, workspaceId, userId: authResult.userId }, - requestId - ) - - logger.info(`[${requestId}] Replace CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - deleted: result.deletedCount, - inserted: result.insertedCount, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - const classified = asOrchestrationError(err) - if (classified) { - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - throw err - } + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import into existing table failed:`, error) - - const classified = asOrchestrationError(error) - return NextResponse.json( - { error: classified ? classified.message : 'Failed to import CSV' }, - { status: classified ? statusForOrchestrationError(classified.code) : 500 } - ) + return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() - // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index f84f457e820..ef4f1cc7547 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,40 +1,20 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { - batchInsertRows, - CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, - CSV_SCHEMA_SAMPLE_SIZE, - coerceRowsForTable, - createCsvParser, - createTable, - deleteTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - sanitizeName, - TABLE_LIMITS, - type TableDefinition, - type TableSchema, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - csvProxyBodyCapResponse, - multipartErrorResponse, - normalizeColumn, - orchestrationErrorResponse, -} from '@/app/api/table/utils' +import { csvProxyBodyCapResponse, multipartErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') @@ -125,135 +105,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionResult.data === 'tsv' ? '\t' : ',' - ) - let csvHeaders: string[] = [] - const parser = createCsvParser(delimiter, (headers) => { - csvHeaders = headers + const outcome = await performCreateTableFromCsv({ + workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionResult.data === 'tsv' ? '\t' : ',', + folderId, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (err) => parser.destroy(err)) - csvStream.pipe(parser) - - interface ImportState { - table: TableDefinition - schema: TableSchema - headerToColumn: Map - } - - const insertRows = async ( - rows: Record[], - state: ImportState, - currentRowCount: number - ) => { - if (rows.length === 0) return 0 - const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone }) - const result = await batchInsertRows( - { tableId: state.table.id, rows: coerced, workspaceId, userId }, - // The created table's rowCount is frozen at 0; pass the running total so the - // per-batch capacity check sees cumulative rows, not an always-empty table. - { ...state.table, rowCount: currentRowCount }, - generateId().slice(0, 8) - ) - return result.length - } - /** Infer the schema from the buffered sample and create the (empty) table. */ - const buildTable = async (sampleRows: Record[]): Promise => { - const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) - const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } - const planLimits = await getWorkspaceTableLimits(workspaceId) - const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const table = await createTable( - { - name: tableName, - description: `Imported from ${file.filename}`, - schema, - workspaceId, - folderId, - userId, - maxTables: planLimits.maxTables, - }, - requestId + if (!outcome.success) { + return NextResponse.json( + { error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - // Coerce against the *created* schema so rows key by the ids `createTable` - // assigned (the local `schema` is the id-less inferred one). - return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } } - let state: ImportState | null = null - let inserted = 0 - const sample: Record[] = [] - let batch: Record[] = [] - - try { - for await (const record of parser as AsyncIterable>) { - if (!state) { - sample.push(record) - if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } - continue - } - batch.push(record) - if (batch.length >= CSV_MAX_BATCH_SIZE) { - inserted += await insertRows(batch, state, inserted) - batch = [] - } - } - - if (!state) { - if (sample.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } else { - inserted += await insertRows(batch, state, inserted) - } - } catch (streamError) { - if (state) await deleteTable(state.table.id, requestId).catch(() => {}) - throw streamError - } - - logger.info(`[${requestId}] CSV imported`, { - tableId: state.table.id, - fileName: file.filename, - columns: state.schema.columns.length, - rows: inserted, - }) - - return NextResponse.json({ - success: true, - data: { - table: { - id: state.table.id, - name: state.table.name, - description: state.table.description, - schema: state.schema, - rowCount: inserted, - }, - }, - }) + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import failed:`, error) - - // Every caller-fixable failure on this path — the plan row-limit check, the - // schema and CSV-shape validation, a name collision — arrives classified. - const classified = orchestrationErrorResponse(error) - if (classified) return classified - return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 5fb64caf1df..7d3222a08d5 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -35,9 +35,18 @@ export type ApiEndpoint = | 'audit-logs' | 'tables' | 'table-detail' + | 'table-restore' | 'table-rows' | 'table-row-detail' + | 'table-rows-find' | 'table-columns' + | 'table-views' + | 'table-view-detail' + | 'table-groups' + | 'table-enrichment' + | 'table-import' + | 'table-export' + | 'table-jobs' | 'files' | 'file-detail' | 'file-share' diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 3bdc2b90b91..6be67ffcd3b 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -175,10 +175,14 @@ const V2_CODE_BY_ORCHESTRATION_ERROR: Record ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockCancelRuns: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockCancelRuns.mockResolvedValue(4) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { + it('cancels every run under scope "all" and reports the count', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ cancelled: 4 }) + expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, { + filter: undefined, + excludeRowIds: undefined, + }) + // Cancelling clears the affected cells, so open readers must refetch. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('scopes to a single row when asked', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' }) + + expect(res.status).toBe(200) + expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything()) + }) + + it('translates a name-keyed predicate to the storage-keyed filter', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate }) + + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockCancelRuns).toHaveBeenCalledWith( + 'table-1', + undefined, + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of cancelling nothing', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + scope: 'all', + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('400s scope "row" with no rowId', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'row' }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('400s scope "row" combined with a filter', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + scope: 'row', + rowId: 'row-1', + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(403) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(429) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts new file mode 100644 index 00000000000..69fe9094e67 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2BulkPredicateToFilter, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableCancelRunsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. + * + * The counterpart to `POST /columns/run`, and distinct from + * `POST /job/cancel`, which stops an import or delete. `scope: 'all'` cancels + * every running and pending cell (optionally narrowed by `filter`); `row` + * cancels one row's cells. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CancelTableRunsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the runners compile the + // storage-keyed legacy filter. Translating up front makes an unknown field + // a 400 rather than a cancel that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { + filter: legacyFilter, + excludeRowIds, + }) + + // Cancelling clears the affected rows' exec state, so open readers must + // refetch to pick up the cleared cells. + signalTableRowsChanged(tableId) + + logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) + + return v2Data({ cancelled }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error cancelling table runs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index ce480142cab..77a3f1f5e1c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -19,12 +19,11 @@ import { v2CaughtOrchestrationError, v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { v2TableAccessError } from '@/app/api/v2/tables/utils' +import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableColumnsAPI') @@ -136,7 +135,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu request, }) if (!outcome.success || !outcome.table) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to update column') + return v2TableOrchestrationError(outcome, 'Failed to update column') } return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts new file mode 100644 index 00000000000..e3dc1b23c0b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + * + * Public v2 column run. The public predicate is column-NAME keyed and the + * dispatcher compiles a storage-keyed legacy filter, so the route translates + * before dispatching — an unknown field must 400 here rather than becoming a + * run that silently matches nothing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockPredicateToFilter, + mockSignalRowsChanged, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/columns/run', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('dispatches the run and returns the dispatch id', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + filter: undefined, + triggeredByUserId: 'user-1', + }) + ) + // The bulk clear is a row change even when the dispatch is a no-op. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('Unknown column "nope"') + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s rowIds and filter together', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s an empty groupIds list', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: [] }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..f534f57f5fd --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + v2BulkPredicateToFilter, + v2TableAccessError, + v2TableLockError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRunColumnAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. + * + * Asynchronous: the response acknowledges the dispatch, not the results. The + * dispatcher walks the scoped rows and writes cells as runs land, so callers + * poll the row endpoints. `dispatchId` is `null` where no background runner is + * configured and cells execute inline. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the dispatcher compiles the + // storage-keyed legacy filter. Translating up front also makes an unknown + // field a 400 here rather than a dispatch that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + filter: legacyFilter, + excludeRowIds, + limit, + requestId, + triggeredByUserId: userId, + }) + + // Starting a run clears the target groups' cells to pending — a row change + // open readers must pick up. + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running table columns`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts new file mode 100644 index 00000000000..fafe14ee422 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment node + * + * Public v2 background export. Export jobs are read-only, so `read` access is + * enough and the job bypasses the one-write-job-per-table gate. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockMarkTableJobRunning, + mockRunDetached, + mockRecordAudit, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockRunDetached: vi.fn(), + mockRecordAudit: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_EXPORTED: 'table.exported' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: vi.fn(), +})) +vi.mock('@/lib/table/export-runner', () => ({ runTableExport: vi.fn() })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/export-async/route' + +const TABLE = { + id: 'table-1', + name: 'customers', + workspaceId: 'ws-1', + rowCount: 3, + schema: { columns: [] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/export-async', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/export-async', () => { + it('queues the export and returns its job id', async () => { + const res = await callPost({ workspaceId: 'ws-1', format: 'csv' }) + + expect(res.status).toBe(200) + const { data } = await res.json() + expect(data.tableId).toBe('table-1') + expect(data.jobId).toEqual(expect.any(String)) + // Typed `export` so the partial-unique index lets it run alongside a write job. + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', data.jobId, 'export', { + format: 'csv', + }) + expect(mockRunDetached).toHaveBeenCalledWith('table-export', expect.any(Function)) + }) + + it('defaults the format to csv', async () => { + await callPost({ workspaceId: 'ws-1' }) + + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', expect.any(String), 'export', { + format: 'csv', + }) + }) + + it('audits at authorization so an abandoned job still records the request', async () => { + await callPost({ workspaceId: 'ws-1' }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'table-1', + metadata: expect.objectContaining({ async: true }), + }) + ) + }) + + it('409s when the claim is lost', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(409) + expect(mockRunDetached).not.toHaveBeenCalled() + }) + + it('400s an unsupported format', async () => { + const res = await callPost({ workspaceId: 'ws-1', format: 'xml' }) + + expect(res.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts new file mode 100644 index 00000000000..39128148ce5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts @@ -0,0 +1,129 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ExportTableAsyncContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportAsyncAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/export-async — Start a background export. + * + * Export jobs are read-only, so they bypass the one-write-job-per-table gate + * (the partial-unique index excludes them) and can run alongside an import or + * delete. Poll `GET /api/v2/tables/jobs`, then fetch the file from + * `GET /export/download` once the job reports `ready`. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportTableAsyncContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const jobId = generateId() + const jobPayload: TableExportJobPayload = { format } + if (!(await markTableJobRunning(tableId, jobId, 'export', jobPayload))) { + return v2Error('CONFLICT', 'Failed to start export') + } + + const payload: TableExportPayload = { jobId, tableId, workspaceId, format } + if (isTriggerDevEnabled) { + try { + const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-export', payload, { + tags: [`tableId:${tableId}`, `jobId:${jobId}`], + region: await resolveTriggerRegion(), + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job behind. + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-export', () => runTableExport(payload)) + } + + // Audit at authorization (like the streaming route) so an abandoned job + // still records that the data was requested. + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount, async: true }, + request, + }) + captureServerEvent( + userId, + 'table_exported', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) + + return v2Data({ tableId, jobId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error starting async export`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts new file mode 100644 index 00000000000..11183817ff3 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + * + * Public v2 export download. The three failure modes are deliberately + * distinct — a caller polling to completion has to tell "not yet" (409) from + * "never again" (410) from "wrong id" (404). + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableJob, + mockPresignedUrl, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockPresignedUrl: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: mockPresignedUrl, +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/[tableId]/export/download/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } +const READY_JOB = { + type: 'export', + status: 'ready', + payload: { format: 'csv', resultKey: 'workspace/ws-1/exports/customers.csv' }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/export/download?workspaceId=ws-1&jobId=job-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableJob.mockResolvedValue(READY_JOB) + mockPresignedUrl.mockResolvedValue('https://storage.example/signed') + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/export/download', () => { + it('issues a presigned URL for a ready job', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + url: 'https://storage.example/signed', + fileName: 'customers.csv', + }) + expect(mockGetTableJob).toHaveBeenCalledWith('table-1', 'job-1') + expect(mockPresignedUrl).toHaveBeenCalledWith( + 'workspace/ws-1/exports/customers.csv', + 'workspace' + ) + }) + + it('404s a job id that is not an export of this table', async () => { + mockGetTableJob.mockResolvedValue({ type: 'import', status: 'ready' }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockPresignedUrl).not.toHaveBeenCalled() + }) + + it('409s a job that is still running — retry later, not a dead end', async () => { + mockGetTableJob.mockResolvedValue({ ...READY_JOB, status: 'running' }) + + const res = await callGet() + + expect(res.status).toBe(409) + expect((await res.json()).error.message).toBe('Export is not ready') + }) + + it('410s once the generated file has aged out of storage', async () => { + mockGetTableJob.mockResolvedValue({ ...READY_JOB, payload: { format: 'csv' } }) + + const res = await callGet() + + expect(res.status).toBe(410) + expect(mockPresignedUrl).not.toHaveBeenCalled() + }) + + it('400s a request with no jobId', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/export/download?workspaceId=ws-1', + { method: 'GET' } + ) + const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(res.status).toBe(400) + expect(mockGetTableJob).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetTableJob).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockGetTableJob).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts new file mode 100644 index 00000000000..d31fafd4d1c --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ExportDownloadContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableJob } from '@/lib/table/jobs/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportDownloadAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/export/download — Presigned URL for a finished + * export. + * + * The three failure modes are deliberately distinct: a job that isn't an export + * of this table is 404, one still running is 409 (retry later), and one whose + * generated file has aged out of storage is 410 (start a new export) — a caller + * polling to completion needs to tell "not yet" from "never again". + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportDownloadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const job = await getTableJob(tableId, jobId) + if (!job || job.type !== 'export') return v2Error('NOT_FOUND', 'Export job not found') + if (job.status !== 'ready') return v2Error('CONFLICT', 'Export is not ready') + + const payload = job.payload as TableExportJobPayload | null + if (!payload?.resultKey) { + return v2Error('NOT_FOUND', 'Export file is no longer available', { status: 410 }) + } + + const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') + const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` + + logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) + + return v2Data({ url, fileName }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error issuing export download URL`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts new file mode 100644 index 00000000000..82ebb10a801 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + * + * Public v2 streaming export — the one v2 success body that is a file rather + * than the `{ data }` envelope. The audit is recorded BEFORE the first byte: + * rows leave incrementally, so a mid-stream failure has still exfiltrated + * whatever was written. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockCreateExportStream, + mockRecordAudit, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockCreateExportStream: vi.fn(), + mockRecordAudit: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_EXPORTED: 'table.exported' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/export-stream', () => ({ + createTableExportStream: mockCreateExportStream, + exportContentType: (format: string) => + format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', + sanitizeExportFilename: (name: string) => name, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/[tableId]/export/route' + +const TABLE = { + id: 'table-1', + name: 'customers', + workspaceId: 'ws-1', + rowCount: 3, + schema: { columns: [] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet(query = 'workspaceId=ws-1') { + const req = new NextRequest(`http://localhost:3000/api/v2/tables/table-1/export?${query}`, { + method: 'GET', + }) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockCreateExportStream.mockReturnValue( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('email\na@b.c\n')) + controller.close() + }, + }) + ) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/export', () => { + it('streams the file with the rate-limit and attachment headers', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8') + expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="customers.csv"') + // The envelope carries these on every other v2 endpoint; a stream response + // has to set them by hand or the whole surface stops being uniform. + expect(res.headers.get('X-RateLimit-Limit')).toBe('100') + expect(await res.text()).toBe('email\na@b.c\n') + expect(mockCreateExportStream).toHaveBeenCalledWith(TABLE, 'csv', expect.any(String)) + }) + + it('defaults to csv and honours an explicit json format', async () => { + const res = await callGet('workspaceId=ws-1&format=json') + + expect(res.headers.get('Content-Type')).toBe('application/json') + expect(mockCreateExportStream).toHaveBeenCalledWith(TABLE, 'json', expect.any(String)) + }) + + it('audits before the first byte leaves', async () => { + await callGet() + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'table-1', actorId: 'user-1' }) + ) + }) + + it('400s an unsupported format', async () => { + const res = await callGet('workspaceId=ws-1&format=xml') + + expect(res.status).toBe(400) + expect(mockCreateExportStream).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCreateExportStream).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockCreateExportStream).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockCreateExportStream).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export/route.ts new file mode 100644 index 00000000000..6570551b5c1 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/route.ts @@ -0,0 +1,111 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { v2ExportTableContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + createTableExportStream, + exportContentType, + sanitizeExportFilename, +} from '@/lib/table/export-stream' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/export — Stream the whole table as a file. + * + * The one v2 endpoint whose success body is NOT the `{ data }` envelope: the + * body is the file. Rate-limit headers are attached by hand for the same + * reason. Errors before the first byte still use the canonical envelope; once + * the stream has started a failure can only tear the connection down, which is + * why large tables belong on the async export. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = access + + // Audit BEFORE streaming: rows leave incrementally, so a mid-stream failure + // has still exfiltrated whatever was written. + recordAudit({ + workspaceId: table.workspaceId ?? null, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Exported table "${table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: table.rowCount }, + request, + }) + captureServerEvent( + userId, + 'table_exported', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return new NextResponse(createTableExportStream(table, format, requestId), { + status: 200, + headers: { + ...rateLimitHeaders(rateLimit), + 'Content-Type': exportContentType(format), + 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, + 'Cache-Control': 'private, no-store', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error exporting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts new file mode 100644 index 00000000000..f42f437eb8a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + * + * Public v2 workflow-group listing — a read-only projection of the table's + * schema, exposed so a caller can discover the group ids the run endpoints + * take. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockGateError } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGateError: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/[tableId]/groups/route' + +const GROUP = { + id: 'group-1', + workflowId: 'wf-1', + name: 'Enrich', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], +} +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [], workflowGroups: [GROUP] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('GET /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + }) + + it('returns the schema groups as one full page', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null }) + }) + + it('returns an empty page for a table with no groups', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } }) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [], nextCursor: null }) + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + }) + + it('400s a request with no workspaceId', async () => { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method: 'GET', + }) + const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(res.status).toBe(400) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts new file mode 100644 index 00000000000..0f4bc8e3fd7 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -0,0 +1,76 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListWorkflowGroupsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableGroupsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. + * + * Read-only: groups are authored in the workflow builder, and the public + * surface exposes them so a caller can discover the `groupIds` the run + * endpoints take. Groups live on the table's schema, so this is a projection of + * the already-loaded definition rather than a second query, and the set is + * bounded per table — one full page, `nextCursor` always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const groups = (result.table.schema as TableSchema).workflowGroups ?? [] + + return v2CursorList(groups, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow groups`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts new file mode 100644 index 00000000000..91672b9802e --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment node + * + * Public v2 background import. Two orderings are load-bearing: the + * client-supplied `fileKey` is checked against the workspace's own storage + * prefix, and the table's locks are asserted BEFORE the single write-job slot + * is claimed so a locked table never holds the slot. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockRunDetached, + mockAssertRowInsert, + mockAssertRowDelete, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockRunDetached: vi.fn(), + mockAssertRowInsert: vi.fn(), + mockAssertRowDelete: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +// Only the assert helpers are stubbed — `TableLockedError` stays real so the +// route's `v2TableLockError` recognizes it by `instanceof` and reports the lock +// kind, exactly as it would in production. +vi.mock('@/lib/table/mutation-locks', async (importOriginal) => ({ + ...(await importOriginal>()), + assertRowInsert: mockAssertRowInsert, + assertRowDelete: mockAssertRowDelete, + assertSchemaMutable: vi.fn(), +})) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/users/queries', () => ({ + getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { TableLockedError } from '@/lib/table/mutation-locks' +import { POST } from '@/app/api/v2/tables/[tableId]/import-async/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] }, archivedAt: null } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const BODY = { + workspaceId: 'ws-1', + fileKey: 'workspace/ws-1/imports/contacts.csv', + fileName: 'contacts.csv', + mode: 'append', +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/import-async', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockMarkTableJobRunning.mockResolvedValue(true) + // `clearAllMocks` drops recorded calls but keeps implementations, so the + // throwing lock assertion below would leak into every later test. + mockAssertRowInsert.mockImplementation(() => {}) + mockAssertRowDelete.mockImplementation(() => {}) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/import-async', () => { + it('claims the job slot and dispatches the import', async () => { + const res = await callPost(BODY) + + expect(res.status).toBe(200) + const { data } = await res.json() + expect(data.tableId).toBe('table-1') + expect(data.importId).toEqual(expect.any(String)) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', data.importId, 'import') + expect(mockRunDetached).toHaveBeenCalledWith('table-import', expect.any(Function)) + }) + + it('rejects a fileKey outside the workspace prefix', async () => { + const res = await callPost({ ...BODY, fileKey: 'workspace/ws-other/imports/contacts.csv' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('Invalid file key for workspace') + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('asserts the insert lock BEFORE claiming the slot, and names the lock in the 423', async () => { + mockAssertRowInsert.mockImplementation(() => { + throw new TableLockedError('insert') + }) + + const res = await callPost(BODY) + + expect(res.status).toBe(423) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + // A table has four independent locks, so "LOCKED" alone doesn't tell the + // caller which one to clear. + const body = await res.json() + expect(body.error.code).toBe('LOCKED') + expect(body.error.details).toEqual({ lock: 'insert' }) + }) + + it('asserts the delete lock too when the mode replaces rows', async () => { + await callPost({ ...BODY, mode: 'replace' }) + + expect(mockAssertRowDelete).toHaveBeenCalledWith(TABLE) + }) + + it('409s when another job already holds the slot', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const res = await callPost(BODY) + + expect(res.status).toBe(409) + expect(mockRunDetached).not.toHaveBeenCalled() + }) + + it('400s an unsupported file extension', async () => { + const res = await callPost({ ...BODY, fileName: 'contacts.xlsx' }) + + expect(res.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('400s a body missing fileKey', async () => { + const res = await callPost({ workspaceId: 'ws-1', fileName: 'c.csv', mode: 'append' }) + + expect(res.status).toBe(400) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost(BODY) + + expect(res.status).toBe(403) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost(BODY) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost(BODY) + + expect(res.status).toBe(429) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts new file mode 100644 index 00000000000..474b59a4ef0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts @@ -0,0 +1,148 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ImportTableAsyncContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' +import { getUserSettings } from '@/lib/users/queries' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportAsyncAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/import-async — Start a background import. + * + * The file must already be in the workspace's storage; `fileKey` is + * client-supplied, so it is checked against the workspace's own prefix — a + * caller must not be able to import another workspace's uploaded object. + * Progress is observable through `GET /api/v2/tables/jobs` and the job can be + * stopped with `POST /job/cancel`. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ImportTableAsyncContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = + parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + const { table } = access + if (table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { + return v2Error('BAD_REQUEST', 'Invalid file key for workspace') + } + if (table.archivedAt) { + return v2Error('BAD_REQUEST', 'Cannot import into an archived table') + } + + const extension = fileName.split('.').pop()?.toLowerCase() + if (extension !== 'csv' && extension !== 'tsv') { + return v2Error('BAD_REQUEST', 'Only CSV and TSV files are supported') + } + + // Gate the locks BEFORE claiming the single write-job slot, so a locked + // table reports 423 here instead of holding the slot and failing inside the + // worker. + assertRowInsert(table) + if (mode === 'replace') assertRowDelete(table) + if (createColumns && createColumns.length > 0) assertSchemaMutable(table) + + const importId = generateId() + if (!(await markTableJobRunning(tableId, importId, 'import'))) { + return v2Error('CONFLICT', 'A job is already in progress for this table') + } + + const payload: TableImportPayload = { + importId, + tableId, + workspaceId, + userId, + fileKey, + fileName, + delimiter: extension === 'tsv' ? '\t' : ',', + mode, + mapping, + createColumns, + timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', + } + + if (isTriggerDevEnabled) { + // Runs outside the web container, so the import survives app deploys. + try { + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-import', payload, { + tags: [`tableId:${tableId}`, `jobId:${importId}`], + region: await resolveTriggerRegion(), + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, importId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(payload)) + } + + logger.info(`[${requestId}] Async CSV import started`, { tableId, importId, mode, fileName }) + + return v2Data({ tableId, importId }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + logger.error(`[${requestId}] Error starting async import`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts new file mode 100644 index 00000000000..f7f28c4cd93 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts @@ -0,0 +1,233 @@ +/** + * @vitest-environment node + * + * Public v2 synchronous CSV import. The body is multipart, so it never goes + * through `parseRequest`; the collected text fields are parsed against the + * contract's form schema instead, and the whole import is delegated to the + * orchestration function so v1 and v2 cannot drift on what an import does. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockReadMultipart, + mockPerformImport, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockReadMultipart: vi.fn(), + mockPerformImport: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/core/utils/multipart', () => ({ + readMultipart: mockReadMultipart, + isMultipartError: (error: unknown) => + typeof error === 'object' && error !== null && 'code' in error, +})) + +vi.mock('@/lib/table/orchestration', () => ({ performTableCsvImport: mockPerformImport })) +vi.mock('@/lib/table', () => ({ CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024 })) +vi.mock('@/lib/users/queries', () => ({ + getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/import/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const IMPORT_DATA = { + tableId: 'table-1', + mode: 'append', + insertedCount: 3, + mappedColumns: ['Email'], + skippedHeaders: [], + unmappedColumns: [], + sourceFile: 'contacts.csv', +} + +function fileStream() { + return { destroy: vi.fn() } +} + +function callPost(options: { contentLength?: string } = {}) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/import', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=x', + ...(options.contentLength ? { 'content-length': options.contentLength } : {}), + }, + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1', mode: 'append' }, + file: { filename: 'contacts.csv', stream: fileStream() }, + }) + mockPerformImport.mockResolvedValue({ success: true, data: IMPORT_DATA }) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/import', () => { + it('delegates the whole import and returns the summary', async () => { + const res = await callPost() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual(IMPORT_DATA) + expect(mockPerformImport).toHaveBeenCalledWith( + expect.objectContaining({ + table: TABLE, + workspaceId: 'ws-1', + userId: 'user-1', + fileName: 'contacts.csv', + fallbackDelimiter: ',', + mode: 'append', + }) + ) + }) + + it('picks the tab fallback from a .tsv extension', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1' }, + file: { filename: 'contacts.tsv', stream: fileStream() }, + }) + + await callPost() + + expect(mockPerformImport).toHaveBeenCalledWith( + expect.objectContaining({ fallbackDelimiter: '\t', mode: 'append' }) + ) + }) + + it('requires workspaceId ahead of the file part so an unauthorized upload is never read', async () => { + await callPost() + + expect(mockReadMultipart).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ requiredFieldsBeforeFile: ['workspaceId'] }) + ) + }) + + it('413s an oversize body rather than importing a silently truncated file', async () => { + const res = await callPost({ contentLength: String(11 * 1024 * 1024) }) + + expect(res.status).toBe(413) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('400s an unsupported file extension', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1' }, + file: { filename: 'contacts.xlsx', stream: fileStream() }, + }) + + const res = await callPost() + + expect(res.status).toBe(400) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('400s a form with no workspaceId', async () => { + mockReadMultipart.mockResolvedValue({ + fields: {}, + file: { filename: 'contacts.csv', stream: fileStream() }, + }) + + const res = await callPost() + + expect(res.status).toBe(400) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without importing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost() + + expect(res.status).toBe(403) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it.each([ + ['conflict', 409, 'CONFLICT'], + ['locked', 423, 'LOCKED'], + ['validation', 400, 'BAD_REQUEST'], + ])('maps a %s import failure to %i', async (errorCode, status, code) => { + mockPerformImport.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await callPost() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost() + + expect(res.status).toBe(429) + expect(mockPerformImport).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts new file mode 100644 index 00000000000..67c11575988 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts @@ -0,0 +1,137 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { csvExtensionSchema } from '@/lib/api/contracts/tables' +import { + v2ImportIntoTableFormSchema, + v2ImportTableCsvContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { performTableCsvImport } from '@/lib/table/orchestration' +import { getUserSettings } from '@/lib/users/queries' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + v2CsvBodyCapError, + v2MultipartError, + v2TableAccessError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/import — Synchronous CSV/TSV import. + * + * `multipart/form-data`, so the body never goes through `parseRequest` — the + * streaming reader consumes the parts and the collected text fields are parsed + * in one pass against the contract's form schema. Auth still runs first: the + * reader is told to require `workspaceId` ahead of the file part so an + * unauthorized upload is rejected before its bytes are read. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + let fileStream: Readable | undefined + + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ImportTableCsvContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + + const oversize = v2CsvBodyCapError(request) + if (oversize) return oversize + + let multipart: Awaited> + try { + multipart = await readMultipart(request, { + maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + requiredFieldsBeforeFile: ['workspaceId'], + signal: request.signal, + }) + } catch (err) { + if (isMultipartError(err)) return v2MultipartError(err) + throw err + } + + const { fields, file } = multipart + if (!file) return v2Error('BAD_REQUEST', 'CSV file is required') + fileStream = file.stream + + const form = v2ImportIntoTableFormSchema.safeParse(fields) + if (!form.success) return v2ValidationError(form.error) + + const extension = csvExtensionSchema.safeParse(file.filename.split('.').pop()?.toLowerCase()) + if (!extension.success) return v2ValidationError(extension.error) + + const scopeError = await resolveWorkspaceScope(rateLimit, form.data.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== form.data.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performTableCsvImport({ + table: access.table, + workspaceId: form.data.workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extension.data === 'tsv' ? '\t' : ',', + mode: form.data.mode, + mapping: form.data.mapping, + createColumns: form.data.createColumns, + timezone: form.data.timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', + requestId, + }) + + if (!outcome.success || !outcome.data) { + return v2TableOrchestrationError(outcome, 'Failed to import CSV') + } + + return v2Data(outcome.data, { rateLimit }) + } catch (error) { + if (isMultipartError(error)) return v2MultipartError(error) + + logger.error(`[${requestId}] Error importing CSV into table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + fileStream?.destroy() + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts new file mode 100644 index 00000000000..e0db26f5381 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + * + * Public v2 job cancel — the "stop it" half of the async import/export story. + * Idempotent by design: cancelling a job that already finished reports + * `canceled: false` rather than failing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableJob, + mockMarkJobCanceled, + mockAppendTableEvent, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockMarkJobCanceled: vi.fn(), + mockAppendTableEvent: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + getTableJob: mockGetTableJob, + markJobCanceled: mockMarkJobCanceled, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/job/cancel/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/job/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableJob.mockResolvedValue({ type: 'import' }) + mockMarkJobCanceled.mockResolvedValue(true) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/job/cancel', () => { + it('cancels the job and emits the event with the job’s real type', async () => { + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ jobId: 'job-1', canceled: true }) + expect(mockMarkJobCanceled).toHaveBeenCalledWith('table-1', 'job-1') + // The table-level derivation excludes exports, so the type has to come from + // the job's own row or an export cancel would announce itself as an import. + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'import', jobId: 'job-1', status: 'canceled' }) + ) + }) + + it('reads the type from an export job rather than defaulting', async () => { + mockGetTableJob.mockResolvedValue({ type: 'export' }) + + await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(mockAppendTableEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'export' })) + }) + + it('reports canceled: false for a job that already finished, and emits nothing', async () => { + mockMarkJobCanceled.mockResolvedValue(false) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ jobId: 'job-1', canceled: false }) + expect(mockAppendTableEvent).not.toHaveBeenCalled() + }) + + it('400s a body with no jobId', async () => { + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without cancelling', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(404) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(403) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(429) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts new file mode 100644 index 00000000000..42969c1fcb6 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelTableJobContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { appendTableEvent } from '@/lib/table/events' +import { getTableJob, markJobCanceled } from '@/lib/table/jobs/service' +import type { TableJobType } from '@/lib/table/types' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableJobCancelAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/job/cancel — Stop an in-flight import or delete. + * + * Flips the job's status so the worker's next ownership check fails and it + * stops. Work already committed (rows inserted or deleted) is left in place — + * there is no rollback. Idempotent: cancelling a job that already finished + * reports `canceled: false` rather than failing, so a client racing the + * worker's completion is not an error. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-jobs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CancelTableJobContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // Resolve the job's real type from its own row — the table-level derivation + // excludes exports — so the cancel event carries the right `type`. + const job = await getTableJob(tableId, jobId) + const type = (job?.type ?? 'import') as TableJobType + + const canceled = await markJobCanceled(tableId, jobId) + if (canceled) { + void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) + } + + logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) + + return v2Data({ jobId, canceled }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error cancelling table job`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts new file mode 100644 index 00000000000..804db7fd7f0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + * + * Public v2 table restore. The target is archived by definition, so the route + * resolves it with archived rows included and checks the permission against + * that row's own workspace rather than going through `checkAccess`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockGetTableById, + mockGetUserEntityPermissions, + mockPerformRestoreTable, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockGetTableById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockPerformRestoreTable: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/lib/table', () => ({ getTableById: mockGetTableById })) +vi.mock('@/lib/table/orchestration', () => ({ performRestoreTable: mockPerformRestoreTable })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/app/api/table/utils', () => ({ + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/restore/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const ARCHIVED_TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } +const RESTORED_TABLE = { + id: 'table-1', + name: 'Tasks', + description: null, + workspaceId: 'ws-1', + schema: { columns: [] }, + rowCount: 7, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockGetTableById.mockResolvedValue(ARCHIVED_TABLE) + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGateError.mockResolvedValue(null) + }) + + it('restores through the orchestration function and returns the table', async () => { + mockPerformRestoreTable.mockResolvedValue({ success: true, table: RESTORED_TABLE }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'Tasks', + description: null, + schema: { columns: [] }, + rowCount: 7, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + job: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }) + // Archived tables are invisible to `getTableById` by default; without the + // opt-in the route would 404 every restore. + expect(mockGetTableById).toHaveBeenCalledWith('table-1', { includeArchived: true }) + expect(mockPerformRestoreTable).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', userId: 'user-1' }) + ) + }) + + it('404s an archived table belonging to another workspace', async () => { + mockGetTableById.mockResolvedValue({ ...ARCHIVED_TABLE, workspaceId: 'ws-other' }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(403) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('maps a name collision with a live table to 409 CONFLICT', async () => { + mockPerformRestoreTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A table named "Tasks" already exists', + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('400s a body with no workspace', async () => { + const res = await callPost({}) + + expect(res.status).toBe(400) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts new file mode 100644 index 00000000000..25485da61da --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RestoreTableContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableById } from '@/lib/table' +import { performRestoreTable } from '@/lib/table/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRestoreAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/restore — Un-archive a table. + * + * The only table endpoint that cannot use `checkAccess`: its target is archived + * by definition, and `checkAccess` resolves active tables only. The permission + * check is therefore done against the archived row's own workspace, which is + * also what makes the workspace-match check an IDOR guard rather than a + * formality. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-restore') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RestoreTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const archived = await getTableById(tableId, { includeArchived: true }) + // Mask a missing table and a foreign one alike so archived-table existence + // never leaks across workspaces. + if (!archived || archived.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const permission = await getUserEntityPermissions(userId, 'workspace', archived.workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return v2Error('FORBIDDEN', 'Access denied') + } + + const outcome = await performRestoreTable({ tableId, userId, requestId }) + if (!outcome.success || !outcome.table) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to restore table') + } + + return v2Data({ table: toApiTable(outcome.table) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error restoring table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 43210d8a8e8..e89ce0fae12 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -1,8 +1,10 @@ /** * @vitest-environment node * - * Public v2 table delete: the actor is handed to the service so the audit is - * emitted there — and only for a delete that actually archived a row. + * Public v2 table delete and update. Delete hands the actor to the service so + * the audit is emitted there — and only for a delete that actually archived a + * row. Update routes each field to its own orchestration call, and carries the + * first-party permission split: renaming needs `write`, locking needs `admin`. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +14,29 @@ const { mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteTable, + mockPerformRenameTable, + mockPerformMoveTableToFolder, + mockPerformUpdateTableLocks, mockRecordAudit, + mockGetTableById, + mockFindActiveFolder, + mockIsFeatureEnabled, + mockGateError, + mockSignalSchemaChanged, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), mockCheckAccess: vi.fn(), mockPerformDeleteTable: vi.fn(), + mockPerformRenameTable: vi.fn(), + mockPerformMoveTableToFolder: vi.fn(), + mockPerformUpdateTableLocks: vi.fn(), mockRecordAudit: vi.fn(), + mockGetTableById: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockGateError: vi.fn(), + mockSignalSchemaChanged: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -41,21 +59,65 @@ vi.mock('@/app/api/table/utils', () => ({ vi.mock('@/lib/table', () => ({ updateTable: vi.fn(), - getTableById: vi.fn(), + getTableById: mockGetTableById, updateRow: vi.fn(), rowDataNameToId: vi.fn(), buildIdByName: vi.fn(), })) -vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: mockPerformDeleteTable })) +vi.mock('@/lib/table/events', () => ({ + signalTableSchemaChanged: mockSignalSchemaChanged, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), +})) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/table/orchestration', () => ({ + performDeleteTable: mockPerformDeleteTable, + performRenameTable: mockPerformRenameTable, + performMoveTableToFolder: mockPerformMoveTableToFolder, + performUpdateTableLocks: mockPerformUpdateTableLocks, })) -import { DELETE } from '@/app/api/v2/tables/[tableId]/route' +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const TABLE = { + id: 'table-1', + name: 'Tasks', + workspaceId: 'ws-1', + schema: { columns: [] }, + locks: UNLOCKED, +} +const UPDATED_TABLE = { + ...TABLE, + name: 'Renamed', + description: null, + rowCount: 0, + maxRows: 1000, + folderId: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} -const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [] } } +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} function callDelete() { const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', { @@ -64,22 +126,27 @@ function callDelete() { return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('DELETE /api/v2/tables/[tableId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) +function callPatch(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableById.mockResolvedValue(UPDATED_TABLE) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockIsFeatureEnabled.mockResolvedValue(true) + mockGateError.mockResolvedValue(null) +}) +describe('DELETE /api/v2/tables/[tableId]', () => { it('delegates to the orchestration function with the resolved table and actor', async () => { mockPerformDeleteTable.mockResolvedValue({ success: true }) @@ -107,3 +174,255 @@ describe('DELETE /api/v2/tables/[tableId]', () => { expect((await res.json()).error.code).toBe('LOCKED') }) }) + +describe('PATCH /api/v2/tables/[tableId]', () => { + it('renames through the orchestration function and returns the re-read table', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'Renamed', + description: null, + schema: { columns: [] }, + rowCount: 0, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + job: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformRenameTable).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, newName: 'Renamed', userId: 'user-1' }) + ) + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('surfaces a running import so an async job is observable, not just startable', async () => { + // `POST /import-async` and `POST /job/cancel` let a caller start and stop an + // import; without this the table never reports that it is running, so there + // is nothing to poll between the two. + mockGetTableById.mockResolvedValue({ + ...UPDATED_TABLE, + jobStatus: 'running', + jobId: 'job-1', + jobType: 'import', + jobRowsProcessed: 250, + jobError: null, + }) + mockPerformRenameTable.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect((await res.json()).data.table.job).toEqual({ + id: 'job-1', + type: 'import', + status: 'running', + rowsProcessed: 250, + error: null, + }) + }) + + it('moves the table only after confirming the folder belongs to the workspace', async () => { + mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-1' }) + + expect(res.status).toBe(200) + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') + expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) + ) + }) + + it('404s a folder from outside the workspace without attempting the move', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-elsewhere' }) + + expect(res.status).toBe(404) + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('rejects a bad folder without applying the rename that came with it', async () => { + // The three operations are separate transactions, so validation has to run + // before the first write — otherwise a rejected PATCH still renames. + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + folderId: 'folder-elsewhere', + }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + expect(mockSignalSchemaChanged).not.toHaveBeenCalled() + }) + + it('rejects a lock change from a non-admin without applying the rename beside it', async () => { + mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => + level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } + ) + + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + locks: { deleteLocked: true }, + }) + + expect(res.status).toBe(403) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('reports which operations landed when a later one fails', async () => { + // The three writes commit independently, so rather than pretending + // atomicity the error states what is already live — a caller can reconcile + // instead of re-reading and diffing. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits the applied list when the very first operation fails', async () => { + // `details.applied` present must always mean "these changes are live". + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'taken', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.details).toBeUndefined() + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('still signals collaborators when a later operation fails after an earlier one landed', async () => { + // A mid-write fault can't be rolled back across three transactions, so the + // clients must at least be told to refetch what did apply. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('rejects a lock change from a write-level caller', async () => { + mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => + level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } + ) + + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(403) + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('rejects enabling a lock while the feature is off', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(403) + expect((await res.json()).error.message).toBe('Table locks are not enabled') + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('still clears a lock while the feature is off, so a locked table is never stranded', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + mockCheckAccess.mockResolvedValue({ + ok: true, + table: { ...TABLE, locks: { ...UNLOCKED, deleteLocked: true } }, + }) + mockPerformUpdateTableLocks.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: false } }) + + expect(res.status).toBe(200) + expect(mockIsFeatureEnabled).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', partial: { deleteLocked: false } }) + ) + }) + + it('maps a duplicate-name rename to 409 CONFLICT', async () => { + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A table named "Renamed" already exists', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('rejects a body with nothing to change', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without writing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(429) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 55e2d792f8f..5c68a30d8c1 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,23 +1,44 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v2/tables' +import { + v2DeleteTableContract, + v2GetTableContract, + v2UpdateTableContract, +} from '@/lib/api/contracts/v2/tables' import { parseRequest } from '@/lib/api/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performDeleteTable } from '@/lib/table/orchestration' +import { findActiveFolder } from '@/lib/folders/queries' +import { getTableById } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from '@/lib/table/orchestration' +import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' +import { + toApiTable, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') @@ -69,6 +90,175 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR } }) +/** + * PATCH /api/v2/tables/[tableId] — Rename, move, and/or change lock flags. + * + * Each field routes to its own orchestration call so the audit records the + * operation the caller actually performed. `locks` carries the first-party + * permission split: `write` is the floor for the endpoint, but enabling a lock + * additionally needs workspace `admin` and the `table-locks` feature. Clearing + * a lock stays available with the feature off, or flipping the kill switch + * would strand an already-locked table with no way to unlock it while + * enforcement of the stored locks keeps running. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // ── Validate every field BEFORE the first write ── + // The three operations are separate transactions, so a rejection + // discovered partway through would leave the earlier ones persisted while + // the response reports failure. Everything a request can be rejected for + // is therefore checked up front: a rejected PATCH changes nothing. + if (validated.locks !== undefined) { + // Only a lock transitioning off→on needs the feature; comparing against + // the stored state is what lets a caller submitting the full flag set + // clear one lock while another stays on. + const enablesALock = TABLE_LOCK_KINDS.some((kind) => { + const flag = TABLE_LOCK_FLAGS[kind] + return validated.locks?.[flag] === true && !table.locks[flag] + }) + if (enablesALock) { + // Resolved against the workspace's host organization, not the caller's + // active one, so an org-targeted rollout can't accept the write here + // and reject it in the first-party UI. + const workspace = await getWorkspaceWithOwner(table.workspaceId) + const enabled = await isFeatureEnabled('table-locks', { + userId, + orgId: workspace?.organizationId ?? undefined, + }) + if (!enabled) return v2Error('FORBIDDEN', 'Table locks are not enabled') + } + + const adminResult = await checkAccess(tableId, userId, 'admin') + if (!adminResult.ok) { + return v2Error('FORBIDDEN', 'Admin access required to change table locks') + } + } + + if (validated.folderId != null) { + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't file the table somewhere Tables never lists. + if (!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + } + + // ── Apply ── + // Every deterministic rejection is already behind us, so a failure here is + // a genuine fault (lost race, archived mid-request, database error) rather + // than a bad request. The three operations commit independently — a single + // transaction would have to span three shared service functions that also + // back the first-party route and two copilot tools, and would break their + // per-operation audits — so instead of pretending atomicity the response + // states exactly which operations landed. A caller that gets an error can + // then reconcile rather than having to re-read and diff. + const applied: ('locks' | 'name' | 'folderId')[] = [] + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.locks !== undefined) { + const outcome = await performUpdateTableLocks({ + tableId, + partial: validated.locks, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('locks') + else failure = { outcome, fallback: 'Failed to update table locks' } + } + + if (!failure && validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } + + if (!failure && validated.folderId !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: validated.folderId, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderId') + } else { + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + failure = { + outcome: + outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, + fallback: 'Failed to move table', + } + } + } + + // Live-collab: tell open viewers the definition changed so they refetch. + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError( + failure.outcome, + failure.fallback, + // Omitted when nothing landed, so `details.applied` present always + // means "these changes are live despite the error". + applied.length > 0 ? { applied } : undefined + ) + } + + // Re-read so the response reflects every applied change at once. + const updated = await getTableById(tableId) + if (!updated) return v2Error('NOT_FOUND', 'Table not found') + + return v2Data({ table: toApiTable(updated) }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + /** DELETE /api/v2/tables/[tableId] — Archive a table. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() @@ -102,7 +292,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete table') + return v2TableOrchestrationError(outcome, 'Failed to delete table') } return v2Data({ id: tableId }, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts new file mode 100644 index 00000000000..cc1566ce848 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + * + * Public v2 per-row enrichment run — the single-cell case of the column run. + * Naming a specific cell is an explicit re-run, so it dispatches in `all` mode + * and recomputes an already-populated cell. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockSignalRowsChanged, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/rows/row-1/enrichment/group-1', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) + return POST(req, { + params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), + }) +} + +describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('scopes the dispatch to the one row and group in the path', async () => { + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + triggeredByUserId: 'user-1', + }) + ) + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('reports a null dispatch id verbatim rather than inventing one', async () => { + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: null }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: null }) + }) + + it('404s a table in another workspace without dispatching', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s a body with no workspace', async () => { + const res = await callPost({}) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts new file mode 100644 index 00000000000..9f3e7a27b69 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowEnrichmentAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowEnrichmentRouteParams { + params: Promise<{ tableId: string; rowId: string; groupId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] + * + * The single-cell case of `POST /columns/run`: runs one group for one row. + * `mode: 'all'` because naming a specific cell is an explicit re-run request — + * an already-populated cell must recompute rather than be skipped. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: RowEnrichmentRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunRowEnrichmentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId, groupId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds: [groupId], + rowIds: [rowId], + mode: 'all', + requestId, + triggeredByUserId: userId, + }) + + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running row enrichment`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts index 2139216449a..626dd3ba567 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -96,4 +96,27 @@ describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => { expect(res.status).toBe(status) expect((await res.json()).error.code).toBe(code) }) + + it('names the lock on a 423 that arrived as a classified outcome, not a throw', async () => { + mockPerformDeleteRow.mockResolvedValue({ + success: false, + errorCode: 'locked', + error: 'Row deletes are locked for this table', + lock: 'delete', + }) + + const res = await callDelete() + + expect(res.status).toBe(423) + expect((await res.json()).error.details).toEqual({ lock: 'delete' }) + }) + + it('omits details entirely when the lock kind is unknown', async () => { + // A caller branching on `details.lock` should see absence, not a null. + mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode: 'locked', error: 'nope' }) + + const res = await callDelete() + + expect((await res.json()).error.details).toBeUndefined() + }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index b0bb10b78d3..026348e69f9 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -23,12 +23,16 @@ import { v2CaughtOrchestrationError, v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import { + toApiRow, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowAPI') @@ -209,7 +213,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete row') + return v2TableOrchestrationError(outcome, 'Failed to delete row') } // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts new file mode 100644 index 00000000000..19f38dfb59f --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + * + * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate + * and sort translate down to storage ids on the way in, and the matched column + * id translates back to its name on the way out. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockFindRowMatches, + mockPredicateToFilter, + mockValidateSortSpec, + mockSortSpecNamesToIds, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockFindRowMatches: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockValidateSortSpec: vi.fn(), + mockSortSpecNamesToIds: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table', () => ({ + buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }), + sortSpecNamesToIds: mockSortSpecNamesToIds, +})) +vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches })) +vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' + +const COLUMNS = [ + { id: 'col-1', name: 'status', type: 'string' }, + { id: 'col-2', name: 'name', type: 'string' }, +] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/rows/find', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockFindRowMatches.mockResolvedValue({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }], + truncated: false, + }) + mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) => + spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field })) + ) + mockGateError.mockResolvedValue(null) + }) + + it('reports the matched column by NAME, not its storage id', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], + truncated: false, + }) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: undefined, sort: undefined }, + expect.any(String) + ) + }) + + it('translates the predicate and sort to storage keys before searching', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate, + sort: [{ field: 'name', direction: 'asc' }], + }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockValidateSortSpec).toHaveBeenCalledWith( + [{ field: 'name', direction: 'asc' }], + COLUMNS + ) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } }, + expect.any(String) + ) + }) + + it('surfaces truncation so a caller narrows instead of paging', async () => { + mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'a' }) + + expect((await res.json()).data).toEqual({ matches: [], truncated: true }) + }) + + it('400s an unresolvable predicate field instead of returning zero matches', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('400s an empty search string', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: '' }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(429) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts new file mode 100644 index 00000000000..68d86f3dea5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, Sort, TableSchema } from '@/lib/table' +import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { validateSortSpec } from '@/lib/table/query-builder/validate' +import { findRowMatches } from '@/lib/table/rows/service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsFindAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search + * across every cell, narrowed by the same predicate/sort grammar as + * `POST /query`. + * + * Returns matching CELLS, not rows: each match carries the row's ordinal in the + * same filtered+sorted view a `POST /query` with these arguments would return, + * so a caller can jump straight to the page holding it. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows-find') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2FindTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, q, predicate, sort } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = accessResult + const schema = table.schema as TableSchema + + // The public wire is column-NAME keyed both ways: translate the predicate + // and sort down to storage ids on the way in, and the matched column id + // back to its name on the way out. + let filter: Filter | undefined + if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) + + let sortObj: Sort | undefined + if (sort?.length) { + validateSortSpec(sort, schema.columns) + const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) + sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) + } + + const { matches, truncated } = await findRowMatches( + table, + { q, filter, sort: sortObj }, + requestId + ) + + const toColumnName = columnNameById(schema) + + return v2Data( + { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error finding rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts new file mode 100644 index 00000000000..25488f0ad26 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -0,0 +1,240 @@ +/** + * @vitest-environment node + * + * Public v2 saved-view detail: read, patch, delete. A view that is not on this + * table is a 404 rather than a silent no-op, so a caller can tell a wrong id + * from a successful write. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableView, + mockUpdateTableView, + mockDeleteTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableView: vi.fn(), + mockUpdateTableView: vi.fn(), + mockDeleteTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + getTableView: mockGetTableView, + updateTableView: mockUpdateTableView, + deleteTableView: mockDeleteTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } + +function callGet() { + return GET( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'GET', + }), + params + ) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + params + ) +} + +function callDelete() { + return DELETE( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'DELETE', + }), + params + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the view scoped to its table', async () => { + mockGetTableView.mockResolvedValue(VIEW) + + const res = await callGet() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS) + }) + + it('404s a view id that belongs to a different table', async () => { + mockGetTableView.mockResolvedValue(null) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('View not found') + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockGetTableView).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => { + it('forwards the patch fields to the service', async () => { + mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(200) + expect((await res.json()).data.view.isDefault).toBe(true) + expect(mockUpdateTableView).toHaveBeenCalledWith({ + viewId: 'view-1', + tableId: 'table-1', + name: undefined, + config: undefined, + configPatch: undefined, + isDefault: true, + columns: COLUMNS, + }) + }) + + it('400s a body that changes nothing', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('400s config and configPatch together', async () => { + const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(403) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the deleted view id', async () => { + mockDeleteTableView.mockResolvedValue(true) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ id: 'view-1' }) + expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1') + }) + + it('404s when nothing was deleted rather than reporting a phantom success', async () => { + mockDeleteTableView.mockResolvedValue(false) + + const res = await callDelete() + + expect(res.status).toBe(404) + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callDelete() + + expect(res.status).toBe(403) + expect(mockDeleteTableView).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts new file mode 100644 index 00000000000..ba29f7665c0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -0,0 +1,183 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableViewContract, + v2GetTableViewContract, + v2UpdateTableViewContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { + deleteTableView, + getTableView, + TableViewValidationError, + updateTableView, +} from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableViewRouteParams { + params: Promise<{ tableId: string; viewId: string }> +} + +/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the + * config, or promote the view to the table's default. + */ +export const PATCH = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await updateTableView({ + viewId, + tableId, + name, + config, + configPatch, + isDefault, + columns: (result.table.schema as TableSchema).columns, + }) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error updating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const deleted = await deleteTableView(viewId, tableId) + if (!deleted) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ id: viewId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts new file mode 100644 index 00000000000..8a789e0de94 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + * + * Public v2 saved views: list and create. A view is presentation state, so the + * read needs only `read` while saving one needs `write`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockListTableViews, + mockCreateTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockListTableViews: vi.fn(), + mockCreateTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + listTableViews: mockListTableViews, + createTableView: mockCreateTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } }, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views', () => { + it('returns every view as one full page with ISO timestamps', async () => { + mockListTableViews.mockResolvedValue([VIEW]) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null }) + // The columns are passed so stale references are pruned from each config. + expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS) + }) + + it('404s a table in another workspace without listing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockListTableViews).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/v2/tables/[tableId]/views', () => { + it('creates the view with the caller as author and answers 201', async () => { + mockCreateTableView.mockResolvedValue(VIEW) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(201) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockCreateTableView).toHaveBeenCalledWith({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Active', + config: {}, + userId: 'user-1', + columns: COLUMNS, + }) + }) + + it('400s a blank view name without touching the service', async () => { + const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} }) + + expect(res.status).toBe(400) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(403) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('surfaces a service-level view validation failure as 400', async () => { + mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty')) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('View name cannot be empty') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts new file mode 100644 index 00000000000..be0dcbe0fa7 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -0,0 +1,127 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/views — Every saved view on the table. + * + * A table carries a bounded set of views, so this is one full page and + * `nextCursor` is always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListTableViewsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) + + return v2CursorList(views.map(toApiView), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing table views`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, name, config } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await createTableView({ + tableId, + workspaceId, + name, + config, + userId, + columns: (result.table.schema as TableSchema).columns, + }) + + return v2Data({ view: toApiView(view) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error creating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.test.ts b/apps/sim/app/api/v2/tables/import-csv/route.test.ts new file mode 100644 index 00000000000..a13d5c901e2 --- /dev/null +++ b/apps/sim/app/api/v2/tables/import-csv/route.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment node + * + * Public v2 create-table-from-CSV. Workspace-scoped rather than table-scoped — + * there is no table to authorize against yet — and the response is re-read + * through `toApiTable` so it carries the same table shape as every other v2 + * endpoint rather than the import's partial view. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockReadMultipart, + mockPerformCreate, + mockGetTableById, + mockFindActiveFolder, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockReadMultipart: vi.fn(), + mockPerformCreate: vi.fn(), + mockGetTableById: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/core/utils/multipart', () => ({ + readMultipart: mockReadMultipart, + isMultipartError: (error: unknown) => + typeof error === 'object' && error !== null && 'code' in error, +})) + +vi.mock('@/lib/table/orchestration', () => ({ performCreateTableFromCsv: mockPerformCreate })) +vi.mock('@/lib/table', () => ({ + CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024, + getTableById: mockGetTableById, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/users/queries', () => ({ + getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/import-csv/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const CREATED_TABLE = { + id: 'table-1', + name: 'contacts', + description: 'Imported from contacts.csv', + workspaceId: 'ws-1', + schema: { columns: [] }, + rowCount: 3, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(options: { contentLength?: string } = {}) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/import-csv', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=x', + ...(options.contentLength ? { 'content-length': options.contentLength } : {}), + }, + }) + return POST(req) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1' }, + file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, + }) + mockPerformCreate.mockResolvedValue({ success: true, data: { table: { id: 'table-1' } } }) + mockGetTableById.mockResolvedValue(CREATED_TABLE) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/import-csv', () => { + it('creates the table and answers 201 with the canonical table shape', async () => { + const res = await callPost() + + expect(res.status).toBe(201) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'contacts', + description: 'Imported from contacts.csv', + schema: { columns: [] }, + rowCount: 3, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + job: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }) + expect(mockPerformCreate).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + userId: 'user-1', + fileName: 'contacts.csv', + fallbackDelimiter: ',', + folderId: null, + }) + ) + }) + + it('checks a supplied folder is a table folder in this workspace', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1', folderId: 'folder-1' }, + file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, + }) + + await callPost() + + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') + expect(mockPerformCreate).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }) + ) + }) + + it('404s a folder from outside the workspace without importing', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1', folderId: 'folder-elsewhere' }, + file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, + }) + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('413s an oversize body rather than importing a silently truncated file', async () => { + const res = await callPost({ contentLength: String(11 * 1024 * 1024) }) + + expect(res.status).toBe(413) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('403s a caller without workspace write', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const res = await callPost() + + expect(res.status).toBe(403) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('400s a file with no data rows', async () => { + mockPerformCreate.mockResolvedValue({ + success: false, + errorCode: 'validation', + error: 'CSV file has no data rows', + }) + + const res = await callPost() + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('CSV file has no data rows') + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost() + + expect(res.status).toBe(429) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.ts b/apps/sim/app/api/v2/tables/import-csv/route.ts new file mode 100644 index 00000000000..d207c4cff91 --- /dev/null +++ b/apps/sim/app/api/v2/tables/import-csv/route.ts @@ -0,0 +1,140 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { csvExtensionSchema } from '@/lib/api/contracts/tables' +import { + v2CreateTableFromCsvContract, + v2CreateTableFromCsvFormSchema, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findActiveFolder } from '@/lib/folders/queries' +import { CSV_MAX_FILE_SIZE_BYTES, getTableById } from '@/lib/table' +import { performCreateTableFromCsv } from '@/lib/table/orchestration' +import { getUserSettings } from '@/lib/users/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable, v2CsvBodyCapError, v2MultipartError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2CreateTableFromCsvAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +/** + * POST /api/v2/tables/import-csv — Create a table from a CSV/TSV. + * + * The column schema is inferred from the file's first rows and the table is + * named after the file. Workspace-scoped rather than table-scoped, so the + * permission check is the workspace one — there is no table to authorize + * against yet. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + let fileStream: Readable | undefined + + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateTableFromCsvContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const oversize = v2CsvBodyCapError(request) + if (oversize) return oversize + + let multipart: Awaited> + try { + multipart = await readMultipart(request, { + maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + requiredFieldsBeforeFile: ['workspaceId'], + signal: request.signal, + }) + } catch (err) { + if (isMultipartError(err)) return v2MultipartError(err) + throw err + } + + const { fields, file } = multipart + if (!file) return v2Error('BAD_REQUEST', 'CSV file is required') + fileStream = file.stream + + const form = v2CreateTableFromCsvFormSchema.safeParse(fields) + if (!form.success) return v2ValidationError(form.error) + + const extension = csvExtensionSchema.safeParse(file.filename.split('.').pop()?.toLowerCase()) + if (!extension.success) return v2ValidationError(extension.error) + + const accessError = await resolveWorkspaceAccess( + rateLimit, + userId, + form.data.workspaceId, + 'write' + ) + if (accessError) return v2WorkspaceAccessError(accessError) + + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't file the imported table where Tables never lists it. + if ( + form.data.folderId && + !(await findActiveFolder(form.data.folderId, form.data.workspaceId, 'table')) + ) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + + const outcome = await performCreateTableFromCsv({ + workspaceId: form.data.workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extension.data === 'tsv' ? '\t' : ',', + folderId: form.data.folderId ?? null, + timezone: form.data.timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', + requestId, + }) + + if (!outcome.success || !outcome.data) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to import CSV') + } + + // Re-read so the response carries the canonical v2 table shape (row count, + // plan row cap, timestamps) rather than the import's partial view. + const table = await getTableById(outcome.data.table.id) + if (!table) return v2Error('INTERNAL_ERROR', 'Internal server error') + + return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) + } catch (error) { + if (isMultipartError(error)) return v2MultipartError(error) + + logger.error(`[${requestId}] Error creating table from CSV`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + fileStream?.destroy() + } +}) diff --git a/apps/sim/app/api/v2/tables/jobs/route.test.ts b/apps/sim/app/api/v2/tables/jobs/route.test.ts new file mode 100644 index 00000000000..749c29fd70f --- /dev/null +++ b/apps/sim/app/api/v2/tables/jobs/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + * + * Public v2 export-job listing — the observability half of the async + * import/export story. Workspace-scoped, so the permission check is the + * workspace one rather than a table's. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListJobs, mockGateError } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListJobs: vi.fn(), + mockGateError: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ listWorkspaceExportJobs: mockListJobs })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/jobs/route' + +const JOB = { + jobId: 'job-1', + tableId: 'table-1', + tableName: 'customers', + status: 'ready', + rowsProcessed: 12, + format: 'csv', + hasResult: true, + error: null, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet(query = 'workspaceId=ws-1&type=export') { + return GET( + new NextRequest(`http://localhost:3000/api/v2/tables/jobs?${query}`, { method: 'GET' }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListJobs.mockResolvedValue([JOB]) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/jobs', () => { + it('returns the workspace export jobs as one full page', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [JOB], nextCursor: null }) + expect(mockListJobs).toHaveBeenCalledWith('ws-1') + }) + + it('400s a request with no type, so widening the parameter can never surprise a caller', async () => { + const res = await callGet('workspaceId=ws-1') + + expect(res.status).toBe(400) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('400s an unsupported job type', async () => { + const res = await callGet('workspaceId=ws-1&type=import') + + expect(res.status).toBe(400) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('403s a caller without workspace access', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const res = await callGet() + + expect(res.status).toBe(403) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockListJobs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/jobs/route.ts b/apps/sim/app/api/v2/tables/jobs/route.ts new file mode 100644 index 00000000000..77d1067920f --- /dev/null +++ b/apps/sim/app/api/v2/tables/jobs/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListTableJobsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableJobsAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * GET /api/v2/tables/jobs — Export jobs across a workspace. + * + * Export-only today, and `type` is a required literal rather than a default so + * the parameter can widen to other job kinds later without silently changing + * what an existing caller receives. Running jobs plus recently finished ones, + * so a completed export stays re-downloadable. Workspace-scoped, so the + * permission check is the workspace one. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-jobs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListTableJobsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const accessError = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (accessError) return v2WorkspaceAccessError(accessError) + + const jobs = await listWorkspaceExportJobs(workspaceId) + + return v2CursorList(jobs, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing table jobs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 8d662be3d4a..58f0c97196b 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,8 @@ import type { NextResponse } from 'next/server' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import type { MultipartError } from '@/lib/core/utils/multipart' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' +import { getColumnId } from '@/lib/table/column-keys' import { TableLockedError } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { @@ -7,9 +10,15 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { predicateToStorage } from '@/lib/table/select-values' -import type { Filter } from '@/lib/table/types' -import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' -import { v2Error } from '@/app/api/v2/lib/response' +import type { Filter, TableLockKind } from '@/lib/table/types' +import type { TableView } from '@/lib/table/views/service' +import { + CSV_IMPORT_PROXY_BODY_CAP_BYTES, + normalizeColumn, + rootErrorMessage, + rowWriteErrorResponse, +} from '@/app/api/table/utils' +import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' /** * Shared serialization + error helpers for the v2 tables surface. Every v2 @@ -54,11 +63,52 @@ export function toApiTable(table: TableDefinition) { }, rowCount: table.rowCount, maxRows: table.maxRows, + folderId: table.folderId ?? null, + locks: table.locks, + // `jobStatus` is the presence signal — the service leaves the whole group + // null when the table is idle. Without this an async import could be + // started and cancelled but never observed to completion or failure. + job: table.jobStatus + ? { + id: table.jobId ?? null, + type: table.jobType ?? null, + status: table.jobStatus, + rowsProcessed: table.jobRowsProcessed ?? 0, + error: table.jobError ?? null, + } + : null, createdAt: toIso(table.createdAt), updatedAt: toIso(table.updatedAt), } } +/** + * Normalized public view shape. Identical to the stored view except that the + * timestamps are ISO strings, matching every other v2 payload. + */ +export function toApiView(view: TableView) { + return { + id: view.id, + tableId: view.tableId, + name: view.name, + config: view.config, + isDefault: view.isDefault, + createdBy: view.createdBy, + createdAt: toIso(view.createdAt), + updatedAt: toIso(view.updatedAt), + } +} + +/** + * Maps a stored column id (the JSONB key that `findRowMatches` reports) back to + * its display name, so cell references on the public wire are name-keyed like + * row `data`. Falls back to the id for a column that no longer exists. + */ +export function columnNameById(schema: TableSchema): (columnId: string) => string { + const nameById = new Map(schema.columns.map((column) => [getColumnId(column), column.name])) + return (columnId) => nameById.get(columnId) ?? columnId +} + /** * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow} * translates it to column names. @@ -85,6 +135,35 @@ export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowDat } } +/** + * Maps a {@link MultipartError} from the streaming CSV reader to the v2 + * envelope. Mirrors v1's {@link multipartErrorResponse} — same classification, + * different envelope. + */ +export function v2MultipartError(error: MultipartError): NextResponse { + if (error.code === 'FILE_TOO_LARGE') { + return v2Error('PAYLOAD_TOO_LARGE', 'CSV import file exceeds maximum size') + } + return error.code === 'NO_FILE' + ? v2Error('BAD_REQUEST', 'CSV file is required') + : v2Error('BAD_REQUEST', `Invalid CSV upload: ${error.message}`) +} + +/** + * 413 when a synchronous CSV upload would exceed the proxy's body cap; `null` + * otherwise. Next buffers the request body for the proxy and silently + * TRUNCATES it past the cap, so an unchecked oversize upload imports a partial + * file and reports success — the failure this exists to prevent. + */ +export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse | null { + const contentLength = Number(request.headers.get('content-length') ?? 0) + if (contentLength <= CSV_IMPORT_PROXY_BODY_CAP_BYTES) return null + return v2Error( + 'PAYLOAD_TOO_LARGE', + 'File too large to import through the server. Upload it to workspace storage and use the async import instead.' + ) +} + /** * Renders a failed {@link checkAccess} result on a MUTATION path: a missing * table stays 404, a missing permission stays 403. Read paths instead mask both @@ -100,12 +179,55 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything * else so the caller falls through to its own classification. + * + * `details.lock` names the flag that rejected the write. A table carries four + * independent locks, so "locked" on its own does not tell a caller which one to + * clear — every 423 on the surface reports it. */ export function v2TableLockError(error: unknown): NextResponse | null { - if (error instanceof TableLockedError) return v2Error('LOCKED', error.message) + if (error instanceof TableLockedError) { + return v2Error('LOCKED', error.message, { details: { lock: error.lock } }) + } return null } +/** The failure half of any `lib/table/orchestration` result. */ +export interface OrchestrationOutcome { + errorCode?: OrchestrationErrorCode + error?: string + lock?: TableLockKind +} + +/** + * Renders a `lib/table/orchestration` failure in the v2 envelope, naming the + * lock when one caused it. + * + * A lock rejection reaches a route two different ways — thrown and caught at + * the boundary ({@link v2TableLockError}), or returned as a classified + * `errorCode: 'locked'` outcome — and both must produce the same body. Plain + * {@link v2ErrorForOrchestration} cannot, because the `lock` kind lives on the + * outcome rather than the code, so every table route that renders an + * orchestration result goes through this instead. + */ +export function v2TableOrchestrationError( + outcome: OrchestrationOutcome, + fallback: string, + /** Merged into `details` — e.g. which operations of a composite write landed. */ + extraDetails?: Record +): NextResponse { + // `lock` is omitted rather than sent as null when the kind is unknown — a + // caller branching on `details.lock` should see absence, not a phantom value. + const details = { + ...(outcome.errorCode === 'locked' && outcome.lock ? { lock: outcome.lock } : {}), + ...extraDetails, + } + return v2ErrorForOrchestration( + outcome.errorCode, + outcome.error ?? fallback, + Object.keys(details).length > 0 ? details : undefined + ) +} + /** * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 89e68b9715f..6b4ccb84396 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1534,44 +1534,60 @@ export const deleteWorkflowGroupContract = defineRouteContract({ * cells on rows matching it (filtered "select all" Stop) * - `row` — every running/pending cell for a specific row (`rowId` required) */ -export const cancelTableRunsBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - scope: z.enum(['all', 'row']), - rowId: z.string().min(1).optional(), - filter: z.union([predicateSchema, domainObjectSchema()]).optional(), - /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - }) - .superRefine((value, ctx) => { - if (value.scope === 'row' && !value.rowId) { - ctx.addIssue({ - code: 'custom', - path: ['rowId'], - message: 'rowId is required when scope is "row"', - }) - } - if (value.scope === 'row' && value.filter) { - ctx.addIssue({ - code: 'custom', - path: ['filter'], - message: 'filter only applies to scope "all"', - }) - } - if (value.scope === 'row' && value.excludeRowIds) { - ctx.addIssue({ - code: 'custom', - path: ['excludeRowIds'], - message: 'excludeRowIds only applies to scope "all"', - }) - } - }) +/** + * Plain-object base for the cancel-runs body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying {@link refineCancelTableRunsScope} — Zod forbids + * `.extend()` on a refined schema. + */ +export const cancelTableRunsBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + scope: z.enum(['all', 'row']), + rowId: z.string().min(1).optional(), + filter: z.union([predicateSchema, domainObjectSchema()]).optional(), + /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), +}) + +/** + * `row` scope names exactly one row, so it requires `rowId` and rejects the + * two select-all-only narrowing fields rather than ignoring them — a caller + * that sends both has misunderstood the scope. + */ +export function refineCancelTableRunsScope(value: { + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: string[] +}): { path: string[]; message: string }[] { + if (value.scope !== 'row') return [] + const issues: { path: string[]; message: string }[] = [] + if (!value.rowId) { + issues.push({ path: ['rowId'], message: 'rowId is required when scope is "row"' }) + } + if (value.filter) { + issues.push({ path: ['filter'], message: 'filter only applies to scope "all"' }) + } + if (value.excludeRowIds) { + issues.push({ + path: ['excludeRowIds'], + message: 'excludeRowIds only applies to scope "all"', + }) + } + return issues +} + +export const cancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema.superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } +}) export const cancelTableRunsContract = defineRouteContract({ method: 'POST', @@ -1635,32 +1651,47 @@ export const runLimitSchema = z.object({ .max(1_000_000, 'max cannot exceed 1,000,000'), }) -export const runColumnBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - groupIds: z.array(z.string().min(1)).min(1), - runMode: z.enum(['all', 'incomplete']).default('all'), - rowIds: z.array(z.string().min(1)).min(1).optional(), - /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The - * dispatcher walks only matching rows (paginated), so no id list is materialized. */ - filter: bulkFilterSchema.optional(), - /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ - limit: runLimitSchema.optional(), - }) - .refine((data) => !(data.rowIds && data.filter), { - message: 'Provide either filter or rowIds, but not both', - }) - .refine((data) => !(data.rowIds && data.excludeRowIds), { - message: 'excludeRowIds only applies to select-all scope (no rowIds)', - }) +/** + * Plain-object base for the run-column body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying the mutex refines — Zod forbids `.extend()` on a + * refined schema. + */ +export const runColumnBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + groupIds: z.array(z.string().min(1)).min(1), + runMode: z.enum(['all', 'incomplete']).default('all'), + rowIds: z.array(z.string().min(1)).min(1).optional(), + /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The + * dispatcher walks only matching rows (paginated), so no id list is materialized. */ + filter: bulkFilterSchema.optional(), + /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), + /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ + limit: runLimitSchema.optional(), +}) + +/** An explicit row set and a select-all filter are mutually exclusive scopes. */ +export const runColumnScopeMutexRefine = [ + (data: { rowIds?: string[]; filter?: unknown }) => !(data.rowIds && data.filter), + { message: 'Provide either filter or rowIds, but not both' }, +] as const + +/** Deselections only mean something under select-all scope. */ +export const runColumnExcludeMutexRefine = [ + (data: { rowIds?: string[]; excludeRowIds?: string[] }) => !(data.rowIds && data.excludeRowIds), + { message: 'excludeRowIds only applies to select-all scope (no rowIds)' }, +] as const + +export const runColumnBodySchema = runColumnBodyBaseSchema + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) export const runColumnContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 4c6f886ffe7..d94a2c20392 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,20 +1,42 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { + cancelTableJobBodySchema, + cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, + createTableViewBodySchema, + csvImportCreateColumnsSchema, + csvImportMappingSchema, + csvImportModeSchema, deleteTableColumnBodySchema, + exportDownloadQuerySchema, + exportTableAsyncBodySchema, + importIntoTableAsyncBodySchema, + listTableJobsQuerySchema, predicateSchema, + refineCancelTableRunsScope, + runColumnBodyBaseSchema, + runColumnExcludeMutexRefine, + runColumnScopeMutexRefine, sortSpecSchema, tableColumnSchema, + tableExportFormatSchema, tableIdParamsSchema, + tableJobSummarySchema, + tableLocksSchema, tableRowParamsSchema, tableRowsQueryBaseSchema, + tableViewConfigSchema, + tableViewParamsSchema, updateRowsByFilterBodySchema, + updateTableBodySchema, updateTableColumnBodySchema, updateTableRowBodySchema, + updateTableViewBodySchema, upsertTableRowBodySchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { v1CreateTableBodySchema, v1CreateTableRowsBodySchema, @@ -54,6 +76,24 @@ export const V2_MAX_ROW_LIMIT = 1000 * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). * Concrete so the v2 contract describes exactly what the wire carries. */ +/** + * The table's current background job, or `null` when idle. + * + * This is how an async import or delete is observed. Those jobs are derived + * onto the table itself (one write job per table at a time), so the table is + * their status endpoint — unlike exports, which are read-only, run concurrently, + * and therefore have the dedicated `GET /api/v2/tables/jobs` list instead. + */ +export const v2TableJobStateSchema = z.object({ + id: z.string().nullable(), + type: z.enum(['import', 'delete', 'export', 'backfill', 'update']).nullable(), + status: z.enum(['running', 'ready', 'failed', 'canceled']), + rowsProcessed: z.number(), + /** Failure reason for a `failed` job; `null` otherwise. */ + error: z.string().nullable(), +}) +export type V2TableJobState = z.output + export const v2ApiTableSchema = z.object({ id: z.string(), name: z.string(), @@ -61,6 +101,12 @@ export const v2ApiTableSchema = z.object({ schema: z.object({ columns: z.array(tableColumnSchema) }), rowCount: z.number(), maxRows: z.number(), + /** Owning folder, or `null` when the table sits at the workspace root. */ + folderId: z.string().nullable(), + /** Governance flags. Writable only by a workspace admin via `PATCH`. */ + locks: tableLocksSchema, + /** In-flight background job, or `null` when the table is idle. */ + job: v2TableJobStateSchema.nullable(), createdAt: z.string(), updatedAt: z.string(), }) @@ -179,6 +225,26 @@ export const v2GetTableContract = defineRouteContract({ }, }) +/** + * Table update. Every field is optional but at least one must be present: + * `name` renames, `folderId` moves the table (explicit `null` moves it to the + * workspace root; omission leaves the placement untouched), and `locks` flips + * the governance flags. The lock branch additionally requires workspace `admin` + * and the `table-locks` feature, matching the first-party surface — a `write` + * caller can rename and move but not lock. + */ +export const v2UpdateTableContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + body: updateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) +export type V2UpdateTableBody = z.input + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -407,3 +473,501 @@ export const v2UpsertTableRowContract = defineRouteContract({ schema: v2DataResponse(v2UpsertRowDataSchema), }, }) + +/** + * Body for the endpoints whose only input is the workspace the table must + * belong to. Present so every v2 mutation carries the same scope check the rest + * of the surface applies through `resolveWorkspaceScope`. + */ +export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2WorkspaceScopedBody = z.input + +/** + * Un-archives a table archived by `DELETE /api/v2/tables/[tableId]`. Resolves + * the table with archived rows included, so it is the one table endpoint whose + * target is expected NOT to be active. + */ +export const v2RestoreTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + params: tableIdParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +/** + * A saved view: a named preset of `{ filter, sort, column layout }` over a + * table. Presentation state only — a view narrows what a reader sees by + * default, it is never an access boundary, and every row it hides stays + * reachable by reading the table without it. Timestamps ISO-serialized. + */ +export const v2ApiViewSchema = z.object({ + id: z.string(), + tableId: z.string(), + name: z.string(), + config: tableViewConfigSchema, + isDefault: z.boolean(), + /** User who saved the view; `null` for views whose author is gone. */ + createdBy: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiView = z.output + +/** A single view payload. */ +export const v2TableViewDataSchema = z.object({ view: v2ApiViewSchema }) +export type V2TableViewData = z.output + +/** Delete confirmation — the id of the view that was removed. */ +export const v2DeleteTableViewDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableViewData = z.output + +/** + * Every saved view on a table, oldest first. A table carries a small bounded + * set of views, so this is a single full page (`nextCursor` is always `null`); + * the cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListTableViewsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiViewSchema), + }, +}) + +export const v2CreateTableViewContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + body: createTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2GetTableViewContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2UpdateTableViewContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + body: updateTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +/** Deleting the default view simply leaves the table unfiltered. */ +export const v2DeleteTableViewContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableViewDataSchema), + }, +}) + +/** + * One workflow/enrichment column group: a backing workflow (or registry + * enrichment) plus the output columns its runs populate. Read-only on v2 — + * groups are authored in the workflow builder, and the public surface exposes + * them so a caller can discover the `groupIds` the run endpoints take. + */ +export const v2WorkflowGroupSchema = z.object({ + id: z.string(), + /** Backing workflow id for `manual` groups; `''` for enrichment groups. */ + workflowId: z.string(), + /** Registry enrichment id for `enrichment` groups. */ + enrichmentId: z.string().optional(), + name: z.string().optional(), + type: z.enum(['manual', 'enrichment']).optional(), + dependencies: z.object({ columns: z.array(z.string()).optional() }).optional(), + outputs: z.array( + z.object({ + blockId: z.string(), + path: z.string(), + outputId: z.string().optional(), + columnName: z.string(), + }) + ), + inputMappings: z.array(z.object({ inputName: z.string(), columnName: z.string() })).optional(), + deploymentMode: z.enum(['live', 'deployed']).optional(), + /** When `false` the group never auto-fires; it runs only on an explicit request. */ + autoRun: z.boolean().optional(), +}) +export type V2WorkflowGroup = z.output + +/** + * The table's workflow/enrichment groups. Bounded per table, so a single full + * page (`nextCursor` is always `null`). + */ +export const v2ListWorkflowGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowGroupSchema), + }, +}) + +/** + * Run-column body. Identical to the first-party shape except `filter`, which v2 + * narrows to the typed predicate tree — the legacy `$`-operator dialect stays + * v1-only across the whole v2 surface. + */ +export const v2RunColumnBodySchema = runColumnBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) +export type V2RunColumnBody = z.input + +/** + * A started run. `dispatchId` identifies the `table_run_dispatches` row the + * dispatcher walks; it is `null` in deployments without a background runner, + * where cells execute inline and no dispatch row is created. + */ +export const v2RunColumnDataSchema = z.object({ dispatchId: z.string().nullable() }) +export type V2RunColumnData = z.output + +/** + * Runs one or more workflow/enrichment groups across the table or a row subset. + * Asynchronous: the response acknowledges the dispatch, and cell values land as + * the runs complete. Poll the rows endpoints for results. + */ +export const v2RunTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + params: tableIdParamsSchema, + body: v2RunColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +export const v2RowEnrichmentParamsSchema = tableRowParamsSchema.extend({ + groupId: z.string().min(1), +}) +export type V2RowEnrichmentParams = z.output + +/** + * The single-cell case of {@link v2RunTableColumnContract}: runs one group for + * one row. The scope lives entirely in the path, so the body carries only the + * workspace. + */ +export const v2RunRowEnrichmentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + params: v2RowEnrichmentParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +/** + * Lookup body: a case-insensitive substring search across every cell, narrowed + * by the same predicate/sort grammar as `POST /query`. POST because the + * predicate tree is a structured body, not a querystring dialect. + */ +export const v2FindRowsBodySchema = z.object({ + workspaceId: workspaceIdSchema, + q: z.string().min(1, 'q must be a non-empty search string'), + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional(), +}) +export type V2FindRowsBody = z.input + +/** + * One matching cell. `ordinal` is the row's 0-based index in the + * predicate-filtered, sorted view, so it lines up with the same page a + * `POST /query` with the same predicate and sort would return. `column` is the + * column NAME, matching how row `data` is keyed everywhere on the public wire. + */ +export const v2RowMatchSchema = z.object({ + ordinal: z.number(), + rowId: z.string(), + column: z.string(), +}) +export type V2RowMatch = z.output + +/** + * Match set. `truncated` is `true` when the search hit the server-side cap and + * more cells match than were returned — narrow the predicate rather than + * paging, since matches have no cursor. + */ +export const v2FindRowsDataSchema = z.object({ + matches: z.array(v2RowMatchSchema), + truncated: z.boolean(), +}) +export type V2FindRowsData = z.output + +export const v2FindTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + params: tableIdParamsSchema, + body: v2FindRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FindRowsDataSchema), + }, +}) + +/** + * Multipart form fields for `POST /api/v2/tables/[tableId]/import`. + * + * Not declared as the contract's `body`: the request is `multipart/form-data`, + * so the route reads the parts with the streaming multipart reader and parses + * the collected text fields through this schema in one pass. Every value + * arrives as a string — `mapping` and `createColumns` are JSON-encoded and + * decoded by their shared field schemas. + */ +export const v2ImportIntoTableFormSchema = z.object({ + workspaceId: workspaceIdSchema, + mode: csvImportModeSchema.default('append'), + mapping: csvImportMappingSchema.optional(), + createColumns: csvImportCreateColumnsSchema.optional(), + timezone: ianaTimezoneSchema.optional(), +}) +export type V2ImportIntoTableForm = z.input + +/** + * Synchronous-import summary. `deletedCount` is present only for + * `mode: "replace"`; `skippedHeaders` and `unmappedColumns` report what the + * import chose NOT to write, so a caller can tell a partial mapping from a + * complete one without diffing the schema. + */ +export const v2ImportTableDataSchema = z.object({ + tableId: z.string(), + mode: csvImportModeSchema, + insertedCount: z.number(), + deletedCount: z.number().optional(), + mappedColumns: z.array(z.string()), + skippedHeaders: z.array(z.string()), + unmappedColumns: z.array(z.string()), + sourceFile: z.string(), +}) +export type V2ImportTableData = z.output + +/** + * Synchronous CSV/TSV import into an existing table. Bounded by the request + * body cap — larger files go through `POST /import-async`, which reads the file + * from storage instead of the request. + */ +export const v2ImportTableCsvContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/import', + params: tableIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ImportTableDataSchema), + }, +}) + +/** Multipart form fields for `POST /api/v2/tables/import-csv`. */ +export const v2CreateTableFromCsvFormSchema = z.object({ + workspaceId: workspaceIdSchema, + folderId: folderIdSchema.optional(), + timezone: ianaTimezoneSchema.optional(), +}) +export type V2CreateTableFromCsvForm = z.input + +/** + * Creates a NEW table from a CSV/TSV: the column schema is inferred from the + * file's first rows and the table is named after the file. Returns the created + * table in the same shape as every other v2 table endpoint. + */ +export const v2CreateTableFromCsvContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/import-csv', + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +/** Kickoff acknowledgement for a background import. */ +export const v2ImportAsyncDataSchema = z.object({ + tableId: z.string(), + importId: z.string(), +}) +export type V2ImportAsyncData = z.output + +/** + * Starts a background import of a file already uploaded to workspace storage. + * Returns immediately; track the job through `GET /api/v2/tables/jobs` and stop + * it with `POST /api/v2/tables/[tableId]/job/cancel`. + */ +export const v2ImportTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/import-async', + params: tableIdParamsSchema, + body: importIntoTableAsyncBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ImportAsyncDataSchema), + }, +}) + +/** Export query: the workspace scope plus the serialization format. */ +export const v2ExportTableQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + format: tableExportFormatSchema, +}) +export type V2ExportTableQuery = z.input + +/** + * Streams the whole table as a CSV or JSON attachment. `mode: 'stream'` because + * the body is the file itself, not the v2 JSON envelope — rows are written as + * they are read, so nothing is buffered. Large tables should use + * `POST /export-async` instead, which survives a dropped connection. + */ +export const v2ExportTableContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/export', + params: tableIdParamsSchema, + query: v2ExportTableQuerySchema, + response: { + mode: 'stream', + }, +}) + +/** Kickoff acknowledgement for a background export. */ +export const v2ExportAsyncDataSchema = z.object({ + tableId: z.string(), + jobId: z.string(), +}) +export type V2ExportAsyncData = z.output + +/** + * Starts a background export. Export jobs are read-only, so they bypass the + * one-write-job-per-table gate and can run alongside an import or delete. + */ +export const v2ExportTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/export-async', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExportAsyncDataSchema), + }, +}) + +/** A short-lived presigned URL for a finished export. */ +export const v2ExportDownloadDataSchema = z.object({ + url: z.string(), + fileName: z.string(), +}) +export type V2ExportDownloadData = z.output + +/** + * Resolves a `ready` export job to a presigned download URL. Returns 409 while + * the job is still running and 410 once the generated file has aged out. + */ +export const v2ExportDownloadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/export/download', + params: tableIdParamsSchema, + query: exportDownloadQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExportDownloadDataSchema), + }, +}) + +/** + * Workspace-scoped export-job listing: running jobs plus recently finished ones + * (kept so a completed export stays re-downloadable). Bounded server-side, so a + * single full page — `nextCursor` is always `null`. + */ +export const v2ListTableJobsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/jobs', + query: listTableJobsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(tableJobSummarySchema), + }, +}) + +/** + * Cancel outcome. `canceled` is `false` when the job had already finished — + * cancelling is idempotent and a late request is not an error. + */ +export const v2CancelTableJobDataSchema = z.object({ + jobId: z.string(), + canceled: z.boolean(), +}) +export type V2CancelTableJobData = z.output + +/** + * Stops an in-flight import or delete. The worker halts at its next ownership + * check; work already committed (rows inserted or deleted) stays — there is no + * rollback. + */ +export const v2CancelTableJobContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/job/cancel', + params: tableIdParamsSchema, + body: cancelTableJobBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelTableJobDataSchema), + }, +}) + +/** + * Cancel-runs body. Identical to the first-party shape except `filter`, which + * v2 narrows to the typed predicate tree. + */ +export const v2CancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } + }) +export type V2CancelTableRunsBody = z.input + +/** How many in-flight cell runs the cancel actually stopped. */ +export const v2CancelTableRunsDataSchema = z.object({ cancelled: z.number() }) +export type V2CancelTableRunsData = z.output + +/** + * Stops in-flight and pending workflow/enrichment cell runs — the counterpart + * to `POST /columns/run`. Distinct from `POST /job/cancel`, which stops an + * import or delete job. + */ +export const v2CancelTableRunsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + params: tableIdParamsSchema, + body: v2CancelTableRunsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelTableRunsDataSchema), + }, +}) diff --git a/apps/sim/lib/table/export-stream.ts b/apps/sim/lib/table/export-stream.ts new file mode 100644 index 00000000000..66fbd497090 --- /dev/null +++ b/apps/sim/lib/table/export-stream.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { neutralizeCsvFormula } from '@/lib/core/utils/csv' +import { namedRowMapper } from '@/lib/table/cell-format' +import { getColumnId } from '@/lib/table/column-keys' +import { formatCsvCell } from '@/lib/table/export-format' +import { queryRows } from '@/lib/table/rows/service' +import type { TableDefinition, TableExportFormat } from '@/lib/table/types' + +const logger = createLogger('TableExportStream') + +const EXPORT_BATCH_SIZE = 1000 + +/** + * Synchronous table export as a byte stream, shared by the first-party and + * public surfaces so both emit byte-identical files. + * + * Rows are paged out as they are read rather than buffered, so a table larger + * than memory still exports — at the cost of a mid-stream failure being + * unrecoverable (the response has already started). Large tables should use the + * background export instead. + */ + +/** Filename-safe stem for the downloaded file. */ +export function sanitizeExportFilename(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return cleaned || 'table' +} + +function escapeCsvField(field: string): string { + return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field +} + +function toCsvRow(values: string[]): string { + return values.map(escapeCsvField).join(',') +} + +/** `Content-Type` for an export in `format`. */ +export function exportContentType(format: TableExportFormat): string { + return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' +} + +export function createTableExportStream( + table: TableDefinition, + format: TableExportFormat, + requestId: string +): ReadableStream { + const columns = table.schema.columns + // Stored row data is id-keyed; CSV headers and JSON keys are display names, so + // translate id → name on the way out (export is a name-friendly boundary). + const toNamedRow = namedRowMapper(columns) + + return new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + try { + if (format === 'csv') { + controller.enqueue( + encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) + ) + } else { + controller.enqueue(encoder.encode('[')) + } + + let offset = 0 + let firstJsonRow = true + while (true) { + const result = await queryRows( + table, + { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, + requestId + ) + + for (const row of result.rows) { + if (format === 'csv') { + const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) + controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) + } else { + const prefix = firstJsonRow ? '' : ',' + firstJsonRow = false + controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) + } + } + + // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, + // so a short page does NOT mean the export is done — only a null cursor does. + if (!result.nextCursor) break + offset += result.rows.length + } + + if (format === 'json') controller.enqueue(encoder.encode(']')) + controller.close() + + logger.info(`[${requestId}] Exported table ${table.id}`, { + format, + rowCount: table.rowCount, + }) + } catch (err) { + logger.error(`[${requestId}] Export failed for table ${table.id}`, err) + controller.error(err) + } + }, + }) +} diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 18d321d3e3b..71048def646 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -18,7 +18,7 @@ import { import { isSupportedCurrencyCode } from '@/lib/table/currency' import { TableLockedError } from '@/lib/table/mutation-locks' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' -import type { ColumnType, SelectOption, TableDefinition } from '@/lib/table/types' +import type { ColumnType, SelectOption, TableDefinition, TableLockKind } from '@/lib/table/types' const logger = createLogger('TableColumnOrchestration') @@ -45,12 +45,14 @@ export interface PerformUpdateTableColumnResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind table?: TableDefinition } function classify(error: unknown): PerformUpdateTableColumnResult { if (error instanceof TableLockedError) { - return { success: false, error: error.message, errorCode: 'locked' } + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } } if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts new file mode 100644 index 00000000000..d511266ad43 --- /dev/null +++ b/apps/sim/lib/table/orchestration/import.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + * + * CSV import orchestration — the logic both the first-party and public import + * routes delegate to, so neither can drift on what an import actually does. + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockImportAppendRows, + mockImportReplaceRows, + mockGetMaxRowsPerTable, + mockDispatchAfterBatchInsert, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockImportAppendRows: vi.fn(), + mockImportReplaceRows: vi.fn(), + mockGetMaxRowsPerTable: vi.fn(), + mockDispatchAfterBatchInsert: vi.fn(), + mockSignalSchemaChanged: vi.fn(), +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/import-data', () => ({ + importAppendRows: mockImportAppendRows, + importReplaceRows: mockImportReplaceRows, +})) +vi.mock('@/lib/table/billing', () => ({ + getMaxRowsPerTable: mockGetMaxRowsPerTable, + getWorkspaceTableLimits: vi.fn(), + wouldExceedRowLimit: (limit: number, current: number, added: number) => + limit >= 0 && current + added > limit, +})) +vi.mock('@/lib/table/rows/service', () => ({ + batchInsertRows: vi.fn(), + dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, +})) +vi.mock('@/lib/table/service', () => ({ createTable: vi.fn(), deleteTable: vi.fn() })) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) + +import { performTableCsvImport } from '@/lib/table/orchestration/import' + +const TABLE = { + id: 'table-1', + name: 'contacts', + workspaceId: 'ws-1', + rowCount: 10, + archivedAt: null, + jobStatus: null, + schema: { + columns: [ + { id: 'col_email', name: 'email', type: 'string', required: false, unique: false }, + { id: 'col_name', name: 'name', type: 'string', required: false, unique: false }, + ], + }, +} as never + +const CSV = 'email,name\na@b.c,Ann\nd@e.f,Dan\n' + +function csvStream(text = CSV) { + return Readable.from([Buffer.from(text)]) +} + +function importParams(overrides: Record = {}) { + return { + table: TABLE, + workspaceId: 'ws-1', + userId: 'user-1', + fileStream: csvStream(), + fileName: 'contacts.csv', + fallbackDelimiter: ',' as const, + mode: 'append' as const, + timezone: 'UTC', + requestId: 'req-1', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockMarkTableJobRunning.mockResolvedValue(true) + mockReleaseJobClaim.mockResolvedValue(undefined) + mockGetMaxRowsPerTable.mockResolvedValue(1000) + mockImportAppendRows.mockResolvedValue({ + inserted: [{ id: 'row-1' }, { id: 'row-2' }], + table: TABLE, + }) + mockImportReplaceRows.mockResolvedValue({ insertedCount: 2, deletedCount: 10 }) +}) + +describe('performTableCsvImport', () => { + it('auto-maps same-named headers and appends the parsed rows', async () => { + const result = await performTableCsvImport(importParams()) + + expect(result.success).toBe(true) + expect(result.data).toEqual({ + tableId: 'table-1', + mode: 'append', + insertedCount: 2, + mappedColumns: ['email', 'name'], + skippedHeaders: [], + unmappedColumns: [], + sourceFile: 'contacts.csv', + }) + // The trigger/scheduler fan-out must run AFTER the tx commits, so it is the + // orchestration's job rather than the writer's. + expect(mockDispatchAfterBatchInsert).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('reports the deleted count on a replace', async () => { + const result = await performTableCsvImport(importParams({ mode: 'replace' })) + + expect(result.data).toMatchObject({ mode: 'replace', insertedCount: 2, deletedCount: 10 }) + expect(mockImportReplaceRows).toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() + }) + + it('holds the table job slot for the write and releases it before returning', async () => { + await performTableCsvImport(importParams()) + + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', expect.any(String), 'import') + // Released before the response, so a client refetch never observes the claim. + expect(mockReleaseJobClaim).toHaveBeenCalledWith('table-1', expect.any(String)) + }) + + it('releases the claim even when the write throws', async () => { + mockImportAppendRows.mockRejectedValue(new Error('boom')) + + const result = await performTableCsvImport(importParams()) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('internal') + expect(mockReleaseJobClaim).toHaveBeenCalled() + }) + + it('refuses when another job already holds the slot', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const result = await performTableCsvImport(importParams()) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(mockImportAppendRows).not.toHaveBeenCalled() + // Nothing was claimed, so nothing may be released — releasing here would + // free the *other* job's slot. + expect(mockReleaseJobClaim).not.toHaveBeenCalled() + }) + + it('refuses an import that would exceed the plan row limit, before writing', async () => { + mockGetMaxRowsPerTable.mockResolvedValue(11) + + const result = await performTableCsvImport(importParams()) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('exceed table row limit') + expect(mockImportAppendRows).not.toHaveBeenCalled() + }) + + it('rejects an archived table and a table with a job already running', async () => { + const archived = await performTableCsvImport( + importParams({ table: { ...TABLE, archivedAt: new Date() } }) + ) + expect(archived).toMatchObject({ success: false, errorCode: 'validation' }) + + const busy = await performTableCsvImport( + importParams({ table: { ...TABLE, jobStatus: 'running' } }) + ) + expect(busy).toMatchObject({ success: false, errorCode: 'conflict' }) + + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('rejects a file with no data rows', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('email,name\n') }) + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toBe('CSV file has no data rows') + }) + + it('rejects a file whose headers map to nothing on the table', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('alpha,beta\n1,2\n') }) + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('No CSV headers map to columns') + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('reports which headers were skipped and which columns went unfilled', async () => { + const result = await performTableCsvImport( + importParams({ + fileStream: csvStream('email,notes\na@b.c,hi\n'), + mapping: { email: 'email', notes: null }, + }) + ) + + expect(result.data).toMatchObject({ + mappedColumns: ['email'], + skippedHeaders: ['notes'], + unmappedColumns: ['name'], + }) + }) + + it('rejects createColumns naming a header the file does not have', async () => { + const result = await performTableCsvImport(importParams({ createColumns: ['phone'] })) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('unknown CSV headers') + }) + + it('creates the requested columns with ids the coerced rows already key by', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('email,phone\na@b.c,555\n'), createColumns: ['phone'] }) + ) + + expect(result.success).toBe(true) + const [, additions, rows] = mockImportAppendRows.mock.calls[0] + expect(additions).toEqual([{ id: expect.any(String), name: 'phone', type: expect.any(String) }]) + // The id is pre-assigned so the prospective schema used to coerce and the + // column the write creates share one key — otherwise the values land under + // a key nothing reads. + expect(Object.keys(rows[0])).toContain(additions[0].id) + }) +}) diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts new file mode 100644 index 00000000000..7739d5e473b --- /dev/null +++ b/apps/sim/lib/table/orchestration/import.ts @@ -0,0 +1,514 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + getMaxRowsPerTable, + getWorkspaceTableLimits, + wouldExceedRowLimit, +} from '@/lib/table/billing' +import { generateColumnId } from '@/lib/table/column-keys' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + buildAutoMapping, + CSV_MAX_BATCH_SIZE, + CSV_SCHEMA_SAMPLE_SIZE, + type CsvDelimiter, + type CsvHeaderMapping, + CsvImportValidationError, + coerceRowsForTable, + createCsvParser, + inferColumnType, + inferSchemaFromCsv, + sanitizeName, + validateMapping, +} from '@/lib/table/import' +import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { batchInsertRows, dispatchAfterBatchInsert } from '@/lib/table/rows/service' +import { createTable, deleteTable } from '@/lib/table/service' +import type { RowData, TableDefinition, TableLockKind, TableSchema } from '@/lib/table/types' + +const logger = createLogger('TableImportOrchestration') + +/** + * CSV import orchestration. + * + * Both entry points own the whole import: they consume the caller's file + * stream, sniff the separator, parse, map, coerce, claim the table's job slot, + * and write. Routes are left holding only transport concerns — reading the + * multipart body, authorizing, and rendering the result — so the v1 and v2 + * surfaces cannot drift on what an import actually does. + */ + +interface ImportFailure { + success: false + error: string + errorCode: OrchestrationErrorCode + details?: unknown + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind +} + +function fail(error: string, errorCode: OrchestrationErrorCode, details?: unknown): ImportFailure { + return { success: false, error, errorCode, ...(details !== undefined ? { details } : {}) } +} + +/** + * Classifies a write failure raised inside an import. A lock rejection is a + * 423 and `TableLockedError` is not an `OrchestrationError`, so it needs its + * own branch; anything unclassified is a server fault whose message must not + * reach the caller. + * + * A lock rejection carries its `lock` kind through, because "locked" alone does + * not tell a caller which of the four flags to clear. + */ +function classifyImportFailure(error: unknown, requestId: string, tableId: string): ImportFailure { + if (error instanceof TableLockedError) { + return { ...fail(error.message, 'locked'), lock: error.lock } + } + if (error instanceof OrchestrationError) return fail(error.message, error.code) + logger.error(`[${requestId}] CSV import failed for table ${tableId}`, { error }) + return fail(toError(error).message, 'internal') +} + +/** + * Drains a CSV/TSV stream into memory. The extension only picks the fallback — + * the separator is sniffed from the file's head so semicolon/pipe exports + * (European-locale Excel) don't land in one column. + */ +async function readCsvRows( + fileStream: Readable, + fallbackDelimiter: CsvDelimiter +): Promise<{ headers: string[]; rows: Record[] }> { + const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) + + let headers: string[] = [] + const parser = createCsvParser(delimiter, (parsedHeaders) => { + headers = parsedHeaders + }) + // `.pipe` doesn't forward source errors; forward them so the iterator throws. + stream.on('error', (streamError) => parser.destroy(streamError)) + stream.pipe(parser) + + const rows: Record[] = [] + for await (const record of parser as AsyncIterable>) { + rows.push(record) + } + return { headers, rows } +} + +/** + * Resolves `createColumns` into pending column definitions plus the schema the + * rows should be coerced against. Ids are pre-assigned so the prospective + * schema and the columns the write actually creates share the same keys — the + * coerced rows are keyed by id before those columns exist. + */ +function planNewColumns( + table: TableDefinition, + headers: string[], + createColumns: string[], + mapping: CsvHeaderMapping, + rows: Record[] +): + | { + ok: true + additions: { id: string; name: string; type: string }[] + schema: TableSchema + mapping: CsvHeaderMapping + } + | { ok: false; failure: ImportFailure } { + const headerSet = new Set(headers) + const unknownHeaders = createColumns.filter((header) => !headerSet.has(header)) + if (unknownHeaders.length > 0) { + return { + ok: false, + failure: fail( + `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, + 'validation' + ), + } + } + + const usedNames = new Set(table.schema.columns.map((column) => column.name.toLowerCase())) + const updatedMapping: CsvHeaderMapping = { ...mapping } + const additions: { id: string; name: string; type: string }[] = [] + const newColumns: TableSchema['columns'] = [] + + for (const header of createColumns) { + const base = sanitizeName(header) + let columnName = base + let suffix = 2 + while (usedNames.has(columnName.toLowerCase())) { + columnName = `${base}_${suffix}` + suffix++ + } + usedNames.add(columnName.toLowerCase()) + const inferredType = inferColumnType(rows.map((row) => row[header])) + const id = generateColumnId() + additions.push({ id, name: columnName, type: inferredType }) + newColumns.push({ + id, + name: columnName, + type: inferredType as TableSchema['columns'][number]['type'], + required: false, + unique: false, + }) + updatedMapping[header] = columnName + } + + return { + ok: true, + additions, + schema: { columns: [...table.schema.columns, ...newColumns] }, + mapping: updatedMapping, + } +} + +export interface PerformTableCsvImportParams { + table: TableDefinition + workspaceId: string + userId: string + /** Multipart file stream. The caller still owns destroying it. */ + fileStream: Readable + fileName: string + /** Separator to fall back to when sniffing is inconclusive. */ + fallbackDelimiter: CsvDelimiter + mode: 'append' | 'replace' + /** Explicit CSV header → column name map. Auto-derived from the schema when omitted. */ + mapping?: CsvHeaderMapping + /** CSV headers to create as new columns on the table before importing. */ + createColumns?: string[] + /** IANA zone used to read naive datetimes (Excel/Sheets exports carry no offset). */ + timezone: string + requestId?: string +} + +export interface TableCsvImportData { + tableId: string + mode: 'append' | 'replace' + insertedCount: number + /** Replace mode only — rows removed before the insert. */ + deletedCount?: number + mappedColumns: string[] + skippedHeaders: string[] + unmappedColumns: string[] + sourceFile: string +} + +export interface PerformTableCsvImportResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Per-header mapping issues, when the failure is a mapping validation. */ + details?: unknown + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind + data?: TableCsvImportData +} + +/** + * Imports a CSV into an EXISTING table, appending or replacing its rows. + * + * The table's single write-job slot is claimed for the whole write and released + * before returning. The claim is the real concurrency gate — the `jobStatus` + * pre-check reads a snapshot taken before the parse, and a background import + * can start in that window; without the claim a synchronous and a background + * import would interleave and corrupt a replace. + */ +export async function performTableCsvImport( + params: PerformTableCsvImportParams +): Promise { + const { table, workspaceId, userId, fileStream, fileName, fallbackDelimiter, mode, timezone } = + params + const requestId = params.requestId ?? generateRequestId() + + if (table.archivedAt) return fail('Cannot import into an archived table', 'validation') + if (table.jobStatus === 'running') { + return fail('A job is already in progress for this table', 'conflict') + } + + const { headers, rows } = await readCsvRows(fileStream, fallbackDelimiter) + if (rows.length === 0) return fail('CSV file has no data rows', 'validation') + + let effectiveMapping = params.mapping ?? buildAutoMapping(headers, table.schema) + let prospectiveSchema = table.schema + let additions: { id: string; name: string; type: string }[] = [] + + if (params.createColumns && params.createColumns.length > 0) { + const planned = planNewColumns(table, headers, params.createColumns, effectiveMapping, rows) + if (!planned.ok) return planned.failure + additions = planned.additions + prospectiveSchema = planned.schema + effectiveMapping = planned.mapping + } + + let validation: ReturnType + try { + validation = validateMapping({ + csvHeaders: headers, + mapping: effectiveMapping, + tableSchema: prospectiveSchema, + }) + } catch (error) { + if (error instanceof CsvImportValidationError) { + return fail(error.message, 'validation', error.details) + } + throw error + } + + if (validation.mappedHeaders.length === 0) { + return fail( + `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveSchema.columns + .map((column) => column.name) + .join(', ')}`, + 'validation' + ) + } + + const coerced = coerceRowsForTable(rows, prospectiveSchema, validation.effectiveMap, { timezone }) + + const importId = generateId() + if (!(await markTableJobRunning(table.id, importId, 'import'))) { + return fail('A job is already in progress for this table', 'conflict') + } + + const summary = { + tableId: table.id, + mode, + mappedColumns: validation.mappedHeaders, + skippedHeaders: validation.skippedHeaders, + unmappedColumns: validation.unmappedColumns, + sourceFile: fileName, + } + + try { + if (mode === 'append') { + const maxRows = await getMaxRowsPerTable(workspaceId) + if (wouldExceedRowLimit(maxRows, table.rowCount, coerced.length)) { + const deficit = table.rowCount + coerced.length - maxRows + return fail( + `Append would exceed table row limit (${maxRows}). Currently ${table.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, + 'validation' + ) + } + + const { inserted, table: finalTable } = await importAppendRows(table, additions, coerced, { + workspaceId, + userId, + requestId, + }) + // Fire trigger + scheduler AFTER the tx commits — both read through the + // global db connection and would otherwise see no rows. + dispatchAfterBatchInsert(finalTable, inserted, requestId, userId) + + logger.info(`[${requestId}] Append CSV imported`, { + tableId: table.id, + fileName, + inserted: inserted.length, + createdColumns: additions.length, + }) + signalTableSchemaChanged(table.id) + + return { success: true, data: { ...summary, insertedCount: inserted.length } } + } + + const result = await importReplaceRows( + table, + additions, + { rows: coerced, workspaceId, userId }, + requestId + ) + + logger.info(`[${requestId}] Replace CSV imported`, { + tableId: table.id, + fileName, + deleted: result.deletedCount, + inserted: result.insertedCount, + createdColumns: additions.length, + }) + signalTableSchemaChanged(table.id) + + return { + success: true, + data: { + ...summary, + insertedCount: result.insertedCount, + deletedCount: result.deletedCount, + }, + } + } catch (error) { + return classifyImportFailure(error, requestId, table.id) + } finally { + // Release before returning, so a client refetch never observes the transient claim. + await releaseJobClaim(table.id, importId).catch(() => {}) + } +} + +export interface PerformCreateTableFromCsvParams { + workspaceId: string + userId: string + /** Multipart file stream. The caller still owns destroying it. */ + fileStream: Readable + fileName: string + fallbackDelimiter: CsvDelimiter + /** Folder to create the table in; `null` creates it at the workspace root. */ + folderId: string | null + timezone: string + requestId?: string +} + +export interface CreatedTableFromCsv { + id: string + name: string + description: string | null + schema: TableSchema + rowCount: number +} + +export interface PerformCreateTableFromCsvResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + data?: { table: CreatedTableFromCsv } +} + +/** + * Creates a NEW table from a CSV and streams its rows in. + * + * Unlike {@link performTableCsvImport} this never buffers the whole file: it + * infers the schema from the first {@link CSV_SCHEMA_SAMPLE_SIZE} records, + * creates the table, then inserts in batches as records arrive. A failure part + * way through drops the half-populated table rather than leaving it behind. + */ +export async function performCreateTableFromCsv( + params: PerformCreateTableFromCsvParams +): Promise { + const { workspaceId, userId, fileStream, fileName, fallbackDelimiter, folderId, timezone } = + params + const requestId = params.requestId ?? generateRequestId() + + const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) + + let csvHeaders: string[] = [] + const parser = createCsvParser(delimiter, (headers) => { + csvHeaders = headers + }) + stream.on('error', (streamError) => parser.destroy(streamError)) + stream.pipe(parser) + + interface ImportState { + table: TableDefinition + schema: TableSchema + headerToColumn: Map + } + + const insertRows = async ( + batch: Record[], + state: ImportState, + currentRowCount: number + ): Promise => { + if (batch.length === 0) return 0 + const coerced = coerceRowsForTable(batch, state.schema, state.headerToColumn, { timezone }) + const inserted = await batchInsertRows( + { tableId: state.table.id, rows: coerced as RowData[], workspaceId, userId }, + // The created table's rowCount is frozen at 0; pass the running total so the + // per-batch capacity check sees cumulative rows, not an always-empty table. + { ...state.table, rowCount: currentRowCount }, + generateId().slice(0, 8) + ) + return inserted.length + } + + /** Infer the schema from the buffered sample and create the (empty) table. */ + const buildTable = async (sampleRows: Record[]): Promise => { + const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) + // Inference emits only `{ name, type }`; the stored schema carries the + // constraint flags explicitly so a later read never has to guess a default. + const columns = inferred.columns.map((column) => ({ + ...column, + required: false, + unique: false, + })) + const planLimits = await getWorkspaceTableLimits(workspaceId) + const tableName = sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const table = await createTable( + { + name: tableName, + description: `Imported from ${fileName}`, + schema: { columns }, + workspaceId, + folderId, + userId, + maxTables: planLimits.maxTables, + }, + requestId + ) + // Coerce against the *created* schema so rows key by the ids `createTable` + // assigned (the inferred schema above is id-less). + return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } + } + + let state: ImportState | null = null + let inserted = 0 + const sample: Record[] = [] + let batch: Record[] = [] + + try { + for await (const record of parser as AsyncIterable>) { + if (!state) { + sample.push(record) + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + state = await buildTable(sample) + inserted += await insertRows(sample, state, inserted) + } + continue + } + batch.push(record) + if (batch.length >= CSV_MAX_BATCH_SIZE) { + inserted += await insertRows(batch, state, inserted) + batch = [] + } + } + + if (!state) { + if (sample.length === 0) return fail('CSV file has no data rows', 'validation') + state = await buildTable(sample) + inserted += await insertRows(sample, state, inserted) + } else { + inserted += await insertRows(batch, state, inserted) + } + } catch (error) { + // A half-populated table from a mid-stream failure is worse than none. + if (state) await deleteTable(state.table.id, requestId).catch(() => {}) + return classifyImportFailure(error, requestId, state?.table.id ?? 'unknown') + } + + logger.info(`[${requestId}] CSV imported`, { + tableId: state.table.id, + fileName, + columns: state.schema.columns.length, + rows: inserted, + }) + + return { + success: true, + data: { + table: { + id: state.table.id, + name: state.table.name, + description: state.table.description ?? null, + schema: state.schema, + rowCount: inserted, + }, + }, + } +} diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index b1ea82abddf..9fa267d7f33 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -1,4 +1,5 @@ export { performUpdateTableColumn } from './columns' +export { performCreateTableFromCsv, performTableCsvImport } from './import' export { performRestoreTable } from './restore' export { performDeleteTable, diff --git a/apps/sim/lib/table/orchestration/tables.test.ts b/apps/sim/lib/table/orchestration/tables.test.ts index 09127e8e40b..33cb68566ab 100644 --- a/apps/sim/lib/table/orchestration/tables.test.ts +++ b/apps/sim/lib/table/orchestration/tables.test.ts @@ -85,7 +85,7 @@ describe('performDeleteTable', () => { const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) - expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(result).toMatchObject({ success: false, errorCode: 'locked', lock: 'delete' }) expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) }) @@ -129,7 +129,10 @@ describe('performDeleteTableRow', () => { it('classifies a delete lock as locked', async () => { mockDeleteRow.mockRejectedValue(new TableLockedError('delete')) - expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe('locked') + const rowResult = await performDeleteTableRow({ table: TABLE, rowId: 'row-1' }) + expect(rowResult.errorCode).toBe('locked') + // The kind rides along so the route can name which flag to clear. + expect(rowResult.lock).toBe('delete') }) it('classifies a missing row as not_found', async () => { diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts index dcec50a25cc..3c0abcf6ae0 100644 --- a/apps/sim/lib/table/orchestration/tables.ts +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -15,6 +15,7 @@ import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS, type TableDefinition, + type TableLockKind, type TableLocks, } from '@/lib/table/types' @@ -32,6 +33,8 @@ export interface PerformDeleteTableResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind } /** @@ -54,7 +57,7 @@ export async function performDeleteTable( ;({ archived } = await deleteTable(table.id, requestId)) } catch (error) { if (error instanceof TableLockedError) { - return { success: false, error: error.message, errorCode: 'locked' } + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } } if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } @@ -98,6 +101,8 @@ export interface PerformDeleteTableRowResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind } /** @@ -116,7 +121,7 @@ export async function performDeleteTableRow( return { success: true } } catch (error) { if (error instanceof TableLockedError) { - return { success: false, error: error.message, errorCode: 'locked' } + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } } if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } @@ -139,12 +144,19 @@ export interface PerformTableMutationResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind table?: TableDefinition } function classifyTableMutation(error: unknown, requestId: string, tableId: string) { if (error instanceof TableLockedError) { - return { success: false as const, error: error.message, errorCode: 'locked' as const } + return { + success: false as const, + error: error.message, + errorCode: 'locked' as const, + lock: error.lock, + } } // `TableConflictError` is an `OrchestrationError('conflict')`, so a duplicate // rename reaches 409 through this branch — by class, not by the message diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 40231c77900..a0e53c0a922 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -353,8 +353,11 @@ export interface TableUpdateJobPayload { * on completion — the storage key of the generated file, served to the client via a presigned URL * and deleted by the janitor when the terminal job is pruned. */ +/** Serialization a table export produces. */ +export type TableExportFormat = 'csv' | 'json' + export interface TableExportJobPayload { - format: 'csv' | 'json' + format: TableExportFormat resultKey?: string } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 0033d7b2ce2..24dde87c3fa 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/table/events', () => ({ import { createTableView, deleteTableView, + getTableView, normalizeStoredViewConfig, pruneViewConfig, updateTableView, @@ -184,3 +185,37 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) }) + +describe('getTableView', () => { + const columns: ColumnDefinition[] = [{ id: 'col_a', name: 'Name', type: 'text' }] + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('prunes stale column references the same way the list read does', async () => { + queueTableRows(tableViews, [ + { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: { columnOrder: ['col_a', 'col_gone'], hiddenColumns: ['col_gone'] }, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + const view = await getTableView('view-1', 'table-1', columns) + + expect(view?.config.columnOrder).toEqual(['col_a']) + expect(view?.config.hiddenColumns).toEqual([]) + }) + + it('returns null for a view id that is not on this table', async () => { + expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 1487be7dd35..bd3fb64963e 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -151,6 +151,21 @@ export async function listTableViews( return rows.map((row) => toTableView(row, columns)) } +/** One view by id, scoped to its table, or `null` when it doesn't exist there. */ +export async function getTableView( + viewId: string, + tableId: string, + columns: ColumnDefinition[] +): Promise { + const [row] = await db + .select() + .from(tableViews) + .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .limit(1) + + return row ? toTableView(row, columns) : null +} + function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c52c7f59a55..c5162f82057 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1046, - zodRoutes: 1046, + totalRoutes: 1062, + zodRoutes: 1062, nonZodRoutes: 0, } as const