diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 476d289159e..c379903774c 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -169,7 +169,7 @@ } } }, - "post": { + "x-removed-buffered-post": { "operationId": "uploadFile", "summary": "Upload File", "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", @@ -315,6 +315,126 @@ } } }, + "/api/v2/files/uploads": { + "post": { + "operationId": "createFileUpload", + "summary": "Create File Upload", + "description": "Create a stateless multipart upload session and signed upload token. Every file uses this flow; a small file is a single part. The maximum file size is 5 GB.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "201": { + "description": "The upload session.", + "content": { "application/json": { "schema": {} } } + }, + "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/files/uploads/{uploadId}": { + "delete": { + "operationId": "abortFileUpload", + "summary": "Abort File Upload", + "description": "Abort an incomplete upload and discard its provider parts.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "responses": { + "200": { + "description": "The aborted upload session.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/files/uploads/{uploadId}/parts": { + "post": { + "operationId": "createFileUploadPartUrls", + "summary": "Create File Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for a bounded set of upload part numbers.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeFileUpload", + "summary": "Complete File Upload", + "description": "Verify every part, assemble the object, and atomically register the workspace file.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "The completed upload and registered file.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, "/api/v2/files/{fileId}": { "get": { "operationId": "downloadFile", @@ -1557,6 +1677,13 @@ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" } }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned when the multipart upload was created.", + "schema": { "type": "string", "minLength": 1 } + }, "FileIdPath": { "name": "fileId", "in": "path", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 00f523d23eb..5212bd88b78 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -55,14 +55,21 @@ "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every table in the workspace.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -80,7 +87,11 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -128,7 +139,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 @@ -229,7 +248,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 } } } @@ -385,6 +412,130 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "operationId": "updateTable", + "summary": "Update Table", + "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderId`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — 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 the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — 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 + } + } + } + } + } + }, + "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": { @@ -489,7 +640,7 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name \u2014 rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", "tags": ["Tables"], "x-codeSamples": [ { @@ -633,7 +784,7 @@ "get": { "operationId": "listTableRows", "summary": "List rows", - "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface \u2014 use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -1369,7 +1520,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` \u2014 a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", "tags": ["Tables"], "parameters": [ { @@ -1414,7 +1565,7 @@ "minimum": 0, "maximum": 1000, "default": 100, - "description": "Omitted \u2192 100. `1..1000` \u2192 page size. `0` \u2192 the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." }, "cursor": { "type": "string", @@ -1527,1005 +1678,4306 @@ } } } - } - }, - "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’s name — 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", + }, + "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 + } + } + } + } + } + }, + "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}/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" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table’s 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 + } + } + } + }, + "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" + } + } + }, + "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 — 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" + } + }, + "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" + } + } + } + }, + "/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’s 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’s 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’s 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’s 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’s workflow and enrichment groups — 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’s 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" + } + } + }, + "post": { + "operationId": "addTableWorkflowGroup", + "summary": "Add Workflow Group", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"group\": {\n \"workflowId\": \"wf_...\",\n \"outputs\": [{\"blockId\": \"blk_7f2a\", \"path\": \"output.revenue\", \"columnName\": \"revenue\"}]\n },\n \"outputColumns\": [{\"name\": \"revenue\", \"type\": \"currency\"}]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWorkflowGroupBody" + }, + "examples": { + "workflow": { + "summary": "Workflow-backed column", + "value": { + "workspaceId": "ws_123", + "group": { + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "currency" + } + ] + } + }, + "enrichment": { + "summary": "Registry enrichment filling two columns", + "value": { + "workspaceId": "ws_123", + "group": { + "type": "enrichment", + "enrichmentId": "company_lookup", + "outputs": [ + { + "outputId": "annual_revenue", + "columnName": "revenue" + }, + { + "outputId": "headquarters", + "columnName": "hq" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "currency" + }, + { + "name": "hq", + "type": "string" + } + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created group and the table's columns.", + "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/WorkflowGroupEnvelope" + }, + "example": { + "data": { + "group": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ], + "deploymentMode": "deployed", + "autoRun": true + }, + "columns": [ + { + "id": "col_a1b2c3", + "name": "revenue", + "type": "currency", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableWorkflowGroup", + "summary": "Update Workflow Group", + "description": "Restructure a group: re-point it at a different workflow, add or remove outputs, or change how its runs are scheduled.\n\n**Removing an output deletes that column and its values.** There is currently no way to detach a column from its group while keeping the data.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\", \"name\": \"Renamed\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowGroupBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "name": "Renamed" + } + }, + "addOutput": { + "summary": "Add a second output column", + "value": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + }, + { + "blockId": "blk_7f2a", + "path": "output.hq", + "columnName": "hq" + } + ], + "newOutputColumns": [ + { + "name": "hq", + "type": "string" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated group and the table's columns.", + "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/WorkflowGroupEnvelope" + }, + "example": { + "data": { + "group": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ], + "deploymentMode": "deployed", + "autoRun": true + }, + "columns": [ + { + "id": "col_a1b2c3", + "name": "revenue", + "type": "currency", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableWorkflowGroup", + "summary": "Delete Workflow Group", + "description": "Remove a group **and every column it fed**, along with their values. The surviving column list is returned so a caller does not have to re-read the table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowGroupBody" + }, + "example": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + } + } + } + }, + "responses": { + "200": { + "description": "The group was removed.", + "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/DeleteWorkflowGroupEnvelope" + }, + "example": { + "data": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "deleted": true, + "columns": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "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) — never both. Omit both to run every row. Starting a run clears the target groups’ 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** — 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’s 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 — 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/jobs": { + "x-removed-get": { + "operationId": "listTableJobs", + "summary": "List Export Jobs", + "description": "Export jobs across a workspace — 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’s 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-async": { + "x-removed-post": { + "operationId": "importTableCsvAsync", + "summary": "Import CSV (Background)", + "description": "Start a background import of a file already uploaded to workspace storage — 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 — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — 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’s storage prefix. The table’s 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-async": { + "x-removed-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": { + "x-removed-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": { + "x-removed-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 — 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/imports": { + "post": { + "operationId": "createTableImport", + "summary": "Create Table Import", + "description": "Create a table import. Upload sources return a stateless multipart token; workspace-file sources start immediately and both use table jobs for processing state.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "201": { + "description": "The table import resource.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/imports/{importId}": { + "get": { + "operationId": "getTableImport", + "summary": "Get Table Import", + "description": "Read processing progress and terminal state from the table job using the same import id.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The table import resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "cancelTableImport", + "summary": "Cancel Table Import", + "description": "Cancel an upload or processing import. Already committed row batches remain in the table.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/OptionalUploadTokenHeader" } + ], + "responses": { + "200": { + "description": "The canceled import resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/imports/{importId}/parts": { + "post": { + "operationId": "createTableImportPartUrls", + "summary": "Create Table Import Part URLs", + "description": "Issue short-lived signed PUT URLs for a bounded set of import part numbers.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested import parts.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/imports/{importId}/complete": { + "post": { + "operationId": "completeTableImportUpload", + "summary": "Complete Table Import Upload", + "description": "Verify and assemble the uploaded CSV or TSV, then start processing with the same import id.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "The queued import resource.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "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}/exports": { + "post": { + "operationId": "createTableExport", + "summary": "Create Table Export", + "description": "Create one export resource. The server completes small exports inline and queues larger exports without changing the API path.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "201": { + "description": "The completed or processing export resource.", + "content": { "application/json": { "schema": {} } } + }, + "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/exports/{exportId}": { + "get": { + "operationId": "getTableExport", + "summary": "Get Table Export", + "description": "Read processing, progress, and terminal state for an export resource.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The table export resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "cancelTableExport", + "summary": "Cancel Table Export", + "description": "Cancel an export that is still processing.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The canceled export resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/exports/{exportId}/download": { + "get": { + "operationId": "downloadTableExport", + "summary": "Download Table Export", + "description": "Return a short-lived download URL once an export has completed.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "A short-lived URL for the generated export file.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "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 — 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’s 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’s 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." + }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned for an upload-backed table import.", + "schema": { "type": "string", "minLength": 1 } + }, + "OptionalUploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": false, + "description": "Required when canceling before upload completion; omitted when canceling a running table job.", + "schema": { "type": "string", "minLength": 1 } + }, + "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": { + "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 — 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", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." + "description": "The current name of the column to update.", + "example": "phone" }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." + "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." }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." + "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." }, - "workflowGroupId": { + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { "type": "string", - "description": "Set when the column is the output of a workflow group." + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." }, - "options": { + "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/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + { + "$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." }, - "currencyCode": { + "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", - "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" + "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." } } }, - "ColumnInput": { + "TableEnvelope": { "type": "object", - "description": "Column definition supplied when creating a table or adding a column.", - "required": ["name", "type"], + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], "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": { + "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/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." + "$ref": "#/components/schemas/Table" + } }, - "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" + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." } } }, - "Table": { + "DeleteTableEnvelope": { "type": "object", - "description": "A user-defined table with a typed column schema.", - "required": [ - "id", - "name", - "description", - "schema", - "rowCount", - "maxRows", - "createdAt", - "updatedAt" - ], + "description": "Confirmation that a table was archived.", + "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", + "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", - "description": "Table schema definition.", "required": ["columns"], "properties": { "columns": { "type": "array", - "description": "Array of column definitions for the table.", "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" + } }, - "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." + "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" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was last modified." + { + "$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" + } + } + } } } }, - "RowData": { + "DeleteRowsEnvelope": { "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 + "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" + } + } + } + } } }, - "Row": { + "DeleteRowEnvelope": { "type": "object", - "description": "A single row in a table.", - "required": ["id", "data", "createdAt", "updatedAt"], + "description": "Result of a single-row delete.", + "required": ["data"], "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." + "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" + } + } + } } } }, - "CreateTableBody": { + "UpsertRowEnvelope": { "type": "object", - "description": "Payload to create a new table.", - "required": ["workspaceId", "name", "schema"], + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], "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." + "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." + } + } + } + } + }, + "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–100 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" + } + } + } }, - "schema": { + { "type": "object", - "required": ["columns"], - "description": "The table's column schema.", + "required": ["any"], + "additionalProperties": false, "properties": { - "columns": { + "any": { "type": "array", "minItems": 1, - "maxItems": 50, - "description": "Column definitions. A table must have between 1 and 50 columns.", + "maxItems": 100, "items": { - "allOf": [ - { - "$ref": "#/components/schemas/ColumnInput" - }, - { - "type": "object", - "properties": { - "workflowGroupId": { - "type": "string", - "description": "Advanced: binds the column to a workflow group's output." - } - } - } - ] + "$ref": "#/components/schemas/PredicateNode" } } } + } + ] + }, + "PredicateNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/Predicate" }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + { + "$ref": "#/components/schemas/Condition" } - } + ] }, - "AddColumnBody": { + "Condition": { "type": "object", - "description": "Payload to add a column to a table.", - "required": ["workspaceId", "column"], + "required": ["field", "op"], + "additionalProperties": false, "properties": { - "workspaceId": { + "field": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "maxLength": 128, + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." }, - "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." - } - } - } + "op": { + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" ], - "description": "The column definition to add." + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — 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." } } }, - "UpdateColumnBody": { + "SelectOption": { "type": "object", - "description": "Payload to update an existing column by name.", - "required": ["workspaceId", "columnName", "updates"], + "required": ["id", "name"], "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "description": "Stable option id — the value stored in cells." }, - "columnName": { + "name": { "type": "string", - "description": "The current name of the column to update.", - "example": "phone" + "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." }, - "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" - } - } + "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." } } }, - "DeleteColumnBody": { + "UpdateTableBody": { "type": "object", - "description": "Payload to delete a column by name.", - "required": ["workspaceId", "columnName"], + "description": "Rename and/or move a table. Every field beyond `workspaceId` is optional, but at least one must be present. Lock flags are read-only on this API and are not accepted here.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "columnName": { + "name": { "type": "string", - "description": "The name of the column to delete.", - "example": "phone_number" + "minLength": 1, + "description": "New table name." + }, + "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." } - } + }, + "additionalProperties": false }, - "CreateRowSingleBody": { + "WorkspaceScopedBody": { "type": "object", - "description": "Insert a single row.", - "required": ["workspaceId", "data"], + "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’s 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 + } }, - "data": { - "$ref": "#/components/schemas/RowData" + "columnOrder": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Left-to-right column order, as column ids." }, - "afterRowId": { + "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 — 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 — 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": "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’s 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" + } + } + }, + "CreateViewBody": { + "type": "object", + "description": "Save a filter/sort/layout preset as a named view.", + "required": ["workspaceId", "name", "config"], + "properties": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + "description": "The workspace that owns the table." }, - "beforeRowId": { + "name": { "type": "string", "minLength": 1, - "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + "description": "Display name for the view." + }, + "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’s default. Setting it demotes the table’s 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 — 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 — 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 — 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) — 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": "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 + } } - }, - "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" + "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’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — 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 — narrow the predicate instead of paging." } } } } }, - "TableListEnvelope": { + "ImportAsyncEnvelope": { "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": "Background-import kickoff acknowledgement.", + "required": ["data"], "properties": { "data": { + "type": "object", + "required": ["tableId", "importId"], + "properties": { + "tableId": { + "type": "string" + }, + "importId": { + "type": "string", + "description": "Job id — pass to `POST /job/cancel` to stop the import." + } + } + } + } + }, + "ImportAsyncBody": { + "type": "object", + "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": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "fileKey": { + "type": "string", + "minLength": 1, + "description": "Storage key of the uploaded file. Must sit under this workspace’s `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", + "description": "CSV header → column name, or null to skip the header.", + "additionalProperties": { + "type": ["string", "null"] + } + }, + "createColumns": { "type": "array", "items": { - "$ref": "#/components/schemas/Table" - } + "type": "string" + }, + "description": "CSV headers to create as new columns before importing." }, - "nextCursor": { - "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null when there are no more pages." + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes.", + "example": "America/New_York" } } }, - "DeleteTableEnvelope": { + "ExportAsyncBody": { "type": "object", - "description": "Confirmation that a table was archived.", + "description": "Starts a background export.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "format": { + "enum": ["csv", "json"], + "default": "csv", + "description": "Serialization to produce." + } + } + }, + "ExportAsyncEnvelope": { + "type": "object", + "description": "Background-export kickoff acknowledgement.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["id"], + "required": ["tableId", "jobId"], "properties": { - "id": { + "tableId": { + "type": "string" + }, + "jobId": { "type": "string", - "description": "The id of the archived table." + "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } } }, - "ColumnsEnvelope": { + "ExportDownloadEnvelope": { "type": "object", - "description": "The table's full column list after a column mutation.", + "description": "A short-lived presigned download URL for a finished export.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["columns"], + "required": ["url", "fileName"], "properties": { - "columns": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Column" - } + "url": { + "type": "string", + "description": "Presigned URL. Expires shortly after issue — fetch it promptly." + }, + "fileName": { + "type": "string", + "description": "Suggested filename for the download." } } } } }, - "RowEnvelope": { + "TableJob": { "type": "object", - "description": "A single row wrapped in the v2 data envelope.", - "required": ["data"], + "description": "One export job.", + "required": [ + "jobId", + "tableId", + "tableName", + "status", + "rowsProcessed", + "format", + "hasResult", + "error" + ], "properties": { - "data": { - "type": "object", - "required": ["row"], - "properties": { - "row": { - "$ref": "#/components/schemas/Row" - } - } + "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." } } }, - "RowListEnvelope": { + "TableJobListEnvelope": { "type": "object", - "description": "A cursor-paginated page of rows.", + "description": "Export jobs wrapped in the v2 cursor-list envelope.", "required": ["data", "nextCursor"], "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Row" + "$ref": "#/components/schemas/TableJob" } }, "nextCursor": { "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null on the final page." + "description": "Always null — the listing is bounded server-side to a single page." } } }, - "BatchInsertRowsEnvelope": { + "CancelJobBody": { "type": "object", - "description": "Result of a batch row insert.", + "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": ["rows", "insertedCount"], + "required": ["jobId", "canceled"], "properties": { - "rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Row" - } + "jobId": { + "type": "string" }, - "insertedCount": { - "type": "integer", - "description": "Number of rows inserted." + "canceled": { + "type": "boolean", + "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." } } } } }, - "CreateRowsResponse": { - "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", - "oneOf": [ - { - "$ref": "#/components/schemas/RowEnvelope" + "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." }, - { - "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + "scope": { + "enum": ["all", "row"], + "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." + }, + "rowId": { + "type": "string", + "minLength": 1, + "description": "Required when `scope` is `row`." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Rows to leave running within the `filter` scope." } - ] + } }, - "UpdateRowsEnvelope": { + "CancelRunsEnvelope": { "type": "object", - "description": "Result of a bulk update-by-filter.", + "description": "How many in-flight cell runs were stopped.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["updatedCount", "updatedRowIds"], + "required": ["cancelled"], "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" - } + "cancelled": { + "type": "integer" } } } } }, - "DeleteRowsEnvelope": { + "TableJobState": { "type": "object", - "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", - "required": ["data"], + "description": "The latest write job derived onto the table. Durable imports also expose their full lifecycle at `GET /api/v2/tables/imports/{importId}`. Exports are read-only resources and do not replace this field.", + "required": ["id", "type", "status", "rowsProcessed", "error"], "properties": { - "data": { + "id": { + "type": ["string", "null"], + "description": "Job id. For durable imports this is also the import resource id." + }, + "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 — progress for a running job." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." + } + } + }, + "WorkflowGroupOutputColumnInput": { + "type": "object", + "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Column name. Must match one of `group.outputs[].columnName`.", + "example": "revenue" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"] + }, + "required": { + "type": "boolean" + }, + "unique": { + "type": "boolean" + } + } + }, + "AddWorkflowGroupBody": { + "type": "object", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.", + "required": ["workspaceId", "group", "outputColumns"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "group": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.", + "required": ["outputs"], "properties": { - "deletedCount": { - "type": "integer", - "description": "Number of rows deleted." + "id": { + "type": "string", + "minLength": 1, + "description": "Optional. Omit to have the server generate one." }, - "deletedRowIds": { + "workflowId": { + "type": "string", + "description": "Required for `manual` groups." + }, + "enrichmentId": { + "type": "string", + "minLength": 1, + "description": "Required for `enrichment` groups." + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["manual", "enrichment"], + "default": "manual", + "description": "`manual` means workflow-backed — not hand-entered." + }, + "dependencies": { + "type": "object", + "description": "Columns that must be populated before this group runs." + }, + "outputs": { "type": "array", - "description": "Ids of the deleted rows.", + "minItems": 1, + "description": "Where each value comes from. Workflow outputs carry `blockId`/`path`; enrichment outputs carry `outputId`.", "items": { - "type": "string" + "type": "object" } }, - "requestedCount": { - "type": "integer", - "description": "Number of row ids requested. Present only for id-based deletes." - }, - "missingRowIds": { + "inputMappings": { "type": "array", - "description": "Requested ids that did not exist. Present only for id-based deletes.", + "description": "Workflow Start-block inputs fed from table columns.", "items": { - "type": "string" + "type": "object" } + }, + "deploymentMode": { + "type": "string", + "enum": ["live", "deployed"] + }, + "autoRun": { + "type": "boolean", + "description": "Whether the group auto-fires from the scheduler." } } + }, + "outputColumns": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput" + } + }, + "autoRun": { + "type": "boolean", + "default": false, + "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run." } } }, - "DeleteRowEnvelope": { + "UpdateWorkflowGroupBody": { + "type": "object", + "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.", + "required": ["workspaceId", "groupId"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "groupId": { + "type": "string", + "minLength": 1 + }, + "workflowId": { + "type": "string", + "minLength": 1, + "description": "Re-point the group. Re-checked against the workspace." + }, + "name": { + "type": "string" + }, + "dependencies": { + "type": "object" + }, + "outputs": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Full replacement set. Entries dropped here delete their columns." + }, + "newOutputColumns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput" + } + }, + "mappingUpdates": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Re-point a column to a different workflow output, keeping the column." + }, + "inputMappings": { + "type": "array", + "items": { + "type": "object" + } + }, + "deploymentMode": { + "type": "string", + "enum": ["live", "deployed"] + }, + "type": { + "type": "string", + "enum": ["manual", "enrichment"] + }, + "autoRun": { + "type": "boolean" + } + } + }, + "DeleteWorkflowGroupBody": { + "type": "object", + "description": "Remove a group and every column it fed.", + "required": ["workspaceId", "groupId"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "groupId": { + "type": "string", + "minLength": 1 + } + } + }, + "WorkflowGroupEnvelope": { "type": "object", - "description": "Result of a single-row delete.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "required": ["group", "columns"], "properties": { - "deletedCount": { - "type": "integer", - "description": "Always 1 when a row was deleted." + "group": { + "$ref": "#/components/schemas/WorkflowGroup" }, - "deletedRowIds": { + "columns": { "type": "array", - "description": "The id of the deleted row.", "items": { - "type": "string" + "$ref": "#/components/schemas/Column" } } } } } }, - "UpsertRowEnvelope": { + "DeleteWorkflowGroupEnvelope": { "type": "object", - "description": "Result of an upsert, including whether the row was inserted or updated.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["row", "operation"], + "required": ["id", "deleted", "columns"], "properties": { - "row": { - "$ref": "#/components/schemas/Row" + "id": { + "type": "string" }, - "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", - "minItems": 1, - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/PredicateNode" - } - } - } - }, - { - "type": "object", - "required": ["any"], - "additionalProperties": false, - "properties": { - "any": { + "deleted": { + "type": "boolean", + "enum": [true] + }, + "columns": { "type": "array", - "minItems": 1, - "maxItems": 100, "items": { - "$ref": "#/components/schemas/PredicateNode" + "$ref": "#/components/schemas/Column" } } } } - ] - }, - "PredicateNode": { - "oneOf": [ - { - "$ref": "#/components/schemas/Predicate" - }, - { - "$ref": "#/components/schemas/Condition" - } - ] - }, - "Condition": { - "type": "object", - "required": ["field", "op"], - "additionalProperties": false, - "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." - } - } - }, - "SelectOption": { - "type": "object", - "required": ["id", "name"], - "properties": { - "id": { - "type": "string", - "description": "Stable option id \u2014 the value stored in cells." - }, - "name": { - "type": "string", - "maxLength": 100, - "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." - } } } }, @@ -2648,6 +6100,70 @@ } } } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource — 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 import source exceeds the 5 GB resource limit.", + "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/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..560357745da --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { getWorkspaceFile, registerUploadedWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + completeUploadSession, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(completeWorkspaceFileUploadContract, request, context) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const upload = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + uploadToken: parsed.data.headers['upload-token'], + }) + const metadata = upload.metadata as { folderId?: string | null } + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async (claimed) => { + const registered = await registerUploadedWorkspaceFile({ + workspaceId, + userId: user, + key: claimed.storageKey, + originalName: claimed.fileName, + contentType: claimed.contentType, + folderId: metadata.folderId, + }) + return { value: registered.file.id, completedFileId: registered.file.id } + }, + }) + const fileId = completed.value + if (!fileId) throw new Error('Completed upload is missing its workspace file id') + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new Error(`Completed workspace file ${fileId} not found`) + if (!completed.alreadyCompleted) await notifyWorkspaceFilesChanged(workspaceId) + return NextResponse.json({ data: toV2FileUpload(completed.session, file) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..a01a9c4ca9d --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createWorkspaceFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createUploadPartUrls, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(createWorkspaceFileUploadPartUrlsContract, request, context) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const upload = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..ffda91c0c44 --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -0,0 +1,38 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { abortWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const DELETE = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(abortWorkspaceFileUploadContract, request, context) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const upload = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + uploadToken: parsed.data.headers['upload-token'], + }) + return NextResponse.json({ data: toV2FileUpload(await abortUploadSession(upload), null) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts new file mode 100644 index 00000000000..25385b370f9 --- /dev/null +++ b/apps/sim/app/api/files/uploads/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(createWorkspaceFileUploadContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, name, contentType, size, folderId } = parsed.data.body + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + const upload = await createUploadSession({ + workspaceId, + userId: user, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: normalizedFolderId }, + }) + return NextResponse.json({ data: toV2FileUpload(upload, null) }, { status: 201 }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts new file mode 100644 index 00000000000..b4530be82ca --- /dev/null +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +export async function requireUploadUser(): Promise { + const session = await getSession() + return session?.user?.id ?? NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +} + +export async function requireWorkspaceWrite( + userId: string, + workspaceId: string +): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + return permission === 'write' || permission === 'admin' + ? null + : NextResponse.json({ error: 'Forbidden' }, { status: 403 }) +} + +export function uploadSessionErrorResponse(error: unknown): NextResponse | null { + const classified = asOrchestrationError(error) + return classified + ? NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) + : null +} 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]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts new file mode 100644 index 00000000000..525f455b81b --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableExportResource, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, 'table-export') + if (access.table.workspaceId !== parsed.data.body.workspaceId) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } + try { + const record = await createTableExportResource({ + table: access.table, + format: parsed.data.body.format, + }) + return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) 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/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts new file mode 100644 index 00000000000..93ba5175585 --- /dev/null +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +interface ExportRouteParams { + params: Promise<{ exportId: string }> +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(downloadTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const record = await requireTableExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId + ) + const access = await checkAccess(record.tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, 'table-export') + const result = tableExportResult(record) + return NextResponse.json({ + data: { + url: await generatePresignedDownloadUrl( + result.resultKey, + 'workspace', + DOWNLOAD_TTL_SECONDS + ), + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + }, + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts new file mode 100644 index 00000000000..c7e9f56b405 --- /dev/null +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + cancelTableExportResourceContract, + getTableExportResourceContract, +} from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + cancelTableExportResource, + requireTableExport, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ExportRouteParams { + params: Promise<{ exportId: string }> +} + +async function authorizedExport(exportId: string, workspaceId: string, userId: string) { + const record = await requireTableExport(exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + return { record, access } +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(getTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const { record, access } = await authorizedExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId, + auth.userId + ) + if (!access.ok) return accessError(access, 'table-export') + return NextResponse.json({ data: toV2TableExport(record) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(cancelTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const { record, access } = await authorizedExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId, + auth.userId + ) + if (!access.ok) return accessError(access, 'table-export') + return NextResponse.json({ data: toV2TableExport(await cancelTableExportResource(record)) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) 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/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..4ca1b13974c --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -0,0 +1,52 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findOwnedTableImport, + getOwnedTableImportUpload, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(completeTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + }) + if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async () => ({ value: null }), + }) + return NextResponse.json({ + data: toV2TableImport(await startUploadedTableImport(completed.session)), + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts new file mode 100644 index 00000000000..f3534b65958 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' +import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) + if (!parsed.success) return parsed.response + try { + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts new file mode 100644 index 00000000000..15fb5cd2914 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + cancelTableImportResourceContract, + getTableImportResourceContract, +} from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + abortTableImportUpload, + cancelTableImportResource, + getOwnedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +async function userId(request: NextRequest): Promise { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + return auth.success && auth.userId + ? auth.userId + : NextResponse.json({ error: 'Authentication required' }, { status: 401 }) +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const user = await userId(request) + if (user instanceof NextResponse) return user + const parsed = await parseRequest(getTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + return NextResponse.json({ data: await toV2TableImport(record) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const user = await userId(request) + if (user instanceof NextResponse) return user + const parsed = await parseRequest(cancelTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const uploadToken = parsed.data.headers['upload-token'] + const record = uploadToken + ? await abortTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + uploadToken, + }) + : await cancelTableImportResource( + await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + ) + return NextResponse.json({ + data: toV2TableImport(record), + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts new file mode 100644 index 00000000000..254420388fe --- /dev/null +++ b/apps/sim/app/api/table/imports/route.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableImportResource, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableImportResourceContract, request, {}) + if (!parsed.success) return parsed.response + try { + const created = await createTableImportResource(parsed.data.body, auth.userId) + return NextResponse.json({ data: await toV2TableImport(created.record) }, { status: 201 }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 2f994150296..93627bc4105 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -37,9 +37,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/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index c6e68913959..ac6c3dca5bd 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -1,29 +1,18 @@ /** * @vitest-environment node * - * Public v2 files list/upload: gate ordering, the `scope` split that makes - * Recently Deleted reachable, and folder-targeted upload. + * Public v2 files list: gate ordering and the `scope` split that makes Recently Deleted reachable. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockQueryWorkspaceFiles, - mockUploadWorkspaceFile, - mockGetWorkspaceFile, - mockReadFormDataWithLimit, - mockReadFileToBufferWithLimit, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockQueryWorkspaceFiles: vi.fn(), - mockUploadWorkspaceFile: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockReadFormDataWithLimit: vi.fn(), - mockReadFileToBufferWithLimit: vi.fn(), -})) +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockQueryWorkspaceFiles } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), + }) +) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, @@ -36,25 +25,10 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ vi.mock('@/lib/uploads/contexts/workspace', () => ({ queryWorkspaceFiles: mockQueryWorkspaceFiles, - uploadWorkspaceFile: mockUploadWorkspaceFile, - getWorkspaceFile: mockGetWorkspaceFile, - FileConflictError: class FileConflictError extends Error {}, -})) - -vi.mock('@/lib/core/utils/stream-limits', () => ({ - readFormDataWithLimit: mockReadFormDataWithLimit, - readFileToBufferWithLimit: mockReadFileToBufferWithLimit, - isPayloadSizeLimitError: () => false, -})) - -vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), - AuditAction: { FILE_UPLOADED: 'file.uploaded' }, - AuditResourceType: { FILE: 'file' }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET, POST } from '@/app/api/v2/files/route' +import { GET } from '@/app/api/v2/files/route' const WS = 'workspace-1' const FOLDER_ID = 'fold_1' @@ -108,15 +82,6 @@ const DEFAULT_LIST_ARGS = { const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) -const callUpload = (query: string) => - POST( - new NextRequest(`http://localhost:3000/api/v2/files?${query}`, { - method: 'POST', - headers: { 'Content-Type': 'multipart/form-data; boundary=x' }, - body: 'x', - }) - ) - describe('GET /api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() @@ -309,125 +274,3 @@ describe('GET /api/v2/files', () => { expect((await res.json()).nextCursor).toBeNull() }) }) - -describe('POST /api/v2/files', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockReadFileToBufferWithLimit.mockResolvedValue(Buffer.from('id,name\n')) - mockUploadWorkspaceFile.mockResolvedValue({ id: 'wf_1' }) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - - const form = new FormData() - form.set('file', new File(['id,name\n'], 'data.csv', { type: 'text/csv' })) - mockReadFormDataWithLimit.mockResolvedValue(form) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callUpload(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callUpload('folderId=fold_1') - expect(res.status).toBe(400) - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure before buffering the body', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callUpload(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockReadFormDataWithLimit).not.toHaveBeenCalled() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callUpload(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('uploads to the workspace root and returns 201 with the stored record', async () => { - const res = await callUpload(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.id).toBe('wf_1') - expect(body.data.folderId).toBeNull() - expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( - WS, - 'user-1', - expect.any(Buffer), - 'data.csv', - 'text/csv', - { folderId: null } - ) - }) - - it('lands the upload in the folder named by folderId', async () => { - mockGetWorkspaceFile.mockResolvedValue( - buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }) - ) - - const res = await callUpload(`workspaceId=${WS}&folderId=${FOLDER_ID}`) - const body = await res.json() - - expect(res.status).toBe(201) - expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( - WS, - 'user-1', - expect.any(Buffer), - 'data.csv', - 'text/csv', - { folderId: FOLDER_ID } - ) - expect(body.data.folderId).toBe(FOLDER_ID) - expect(body.data.folderPath).toBe('Reports/Q1') - }) - - it('404s when the target folder does not exist', async () => { - mockUploadWorkspaceFile.mockRejectedValue( - new OrchestrationError('not_found', 'Target folder not found') - ) - - const res = await callUpload(`workspaceId=${WS}&folderId=missing`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('413s on a blown storage quota by class, not by message wording', async () => { - mockUploadWorkspaceFile.mockRejectedValue( - new OrchestrationError('payload_too_large', 'Quota exceeded for this workspace') - ) - - const res = await callUpload(`workspaceId=${WS}`) - - expect(res.status).toBe(413) - expect((await res.json()).error.code).toBe('PAYLOAD_TOO_LARGE') - }) - - it('409s on a duplicate-name conflict by class', async () => { - mockUploadWorkspaceFile.mockRejectedValue( - new OrchestrationError('conflict', 'A file named "data.csv" already exists in this workspace') - ) - - const res = await callUpload(`workspaceId=${WS}`) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) -}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 2c088b8b611..e6fd3e6698c 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,24 +1,10 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { - type V2File, - v2ListFilesContract, - v2UploadFileContract, -} from '@/lib/api/contracts/v2/files' +import { type V2File, v2ListFilesContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' -import { - isPayloadSizeLimitError, - readFileToBufferWithLimit, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getWorkspaceFile, - queryWorkspaceFiles, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -29,7 +15,6 @@ import { v2CaughtOrchestrationError, v2CursorList, v2CursorSortError, - v2Data, v2Error, v2RateLimitError, v2ValidationError, @@ -41,9 +26,6 @@ const logger = createLogger('V2FilesAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const MAX_FILE_SIZE = 100 * 1024 * 1024 -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 - /** * GET /api/v2/files — List files in a workspace with search, sort, and cursor * pagination. @@ -108,124 +90,3 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return v2Error('INTERNAL_ERROR', 'Internal server error') } }) - -/** - * POST /api/v2/files — Upload a file to a workspace. - * - * Authorization runs fully (rate limit → workspace write access) before the - * multipart body is buffered: the workspace and the optional target `folderId` - * are contract-validated query params, so an unauthorized caller never streams a - * 100 MB body into memory. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2UploadFileContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderId } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - let formData: FormData - try { - formData = await readFormDataWithLimit(request, { - maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'workspace file upload body', - }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') - } - - const rawFile = formData.get('file') - const file = rawFile instanceof File ? rawFile : null - if (!file) { - return v2Error('BAD_REQUEST', 'file form field is required') - } - - if (file.size > MAX_FILE_SIZE) { - return v2Error( - 'PAYLOAD_TOO_LARGE', - `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` - ) - } - - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: MAX_FILE_SIZE, - label: 'workspace upload file', - }) - - const userFile = await uploadWorkspaceFile( - workspaceId, - userId, - buffer, - file.name, - file.type || 'application/octet-stream', - { folderId: folderId ?? null } - ) - - logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: file.name, - description: `Uploaded file "${file.name}" via API`, - metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, - request, - }) - - /** - * `uploadWorkspaceFile` returns the executor-facing `UserFile`, which carries - * neither the folder path nor the persisted timestamps, so the stored record - * is the source for the response projection. - * - * `throwOnError` matters here: by default this reader swallows a query - * failure and returns `null`, which would make a transient blip on the read - * indistinguishable from the row being gone. The row was committed by the - * upload moments earlier on the same primary, so a genuine `null` is an - * invariant break — worth a 500 — while a transient failure should surface - * as itself rather than being reported as a missing file. - */ - const fileRecord = await getWorkspaceFile(workspaceId, userFile.id, { throwOnError: true }) - if (!fileRecord) { - throw new Error(`Uploaded file ${userFile.id} could not be read back`) - } - - return v2Data(toV2File(fileRecord), { rateLimit, status: 201 }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - - // Conflicts, a missing target folder, and a blown storage quota all arrive classified - // now, so the status comes off the error's code rather than its wording. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - const message = getErrorMessage(error, 'Failed to upload file') - logger.error('Error uploading file', { error: message }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..3dfca6ca127 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,95 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceFile, registerUploadedWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + completeUploadSession, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CompleteFileUploadAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CompleteFileUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const metadata = session.metadata as { folderId?: string | null } + const result = await completeUploadSession({ + session, + parts: parsed.data.body.parts, + finalize: async (claimed) => { + const registered = await registerUploadedWorkspaceFile({ + workspaceId, + userId, + key: claimed.storageKey, + originalName: claimed.fileName, + contentType: claimed.contentType, + folderId: metadata.folderId, + }) + return { value: registered.file.id, completedFileId: registered.file.id } + }, + }) + const fileId = result.value + if (!fileId) throw new Error('Completed upload is missing its workspace file id') + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new Error(`Completed workspace file ${fileId} not found`) + + if (!result.alreadyCompleted) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type }, + request, + }) + } + return v2Data(toV2FileUpload(result.session, file), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to complete file upload', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..4272e75796f --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,63 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createUploadPartUrls, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } 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' + +const logger = createLogger('V2FileUploadPartsAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateFileUploadPartUrlsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create file upload part URLs', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..cccb93f3524 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2AbortFileUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortUploadSession(session) + return v2Data(toV2FileUpload(aborted, null), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to abort file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts new file mode 100644 index 00000000000..34c934f0183 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockAssertFolder, + mockCreateUploadSession, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockAssertFolder: vi.fn(), + mockCreateUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileFolderTarget: mockAssertFolder, +})) + +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + createUploadSession: mockCreateUploadSession, +})) + +import { POST } from '@/app/api/v2/files/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +function request(body: Record) { + return POST( + new NextRequest('http://localhost:3000/api/v2/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +describe('POST /api/v2/files/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolder.mockResolvedValue(null) + mockCreateUploadSession.mockResolvedValue({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + storageContext: 'workspace', + storageKey: `${WORKSPACE_ID}/file.csv`, + storageProvider: 's3', + providerUploadId: 'provider-1', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + uploadToken: 'signed-upload-token', + metadata: {}, + completedFileId: null, + error: null, + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + createdAt: new Date('2026-08-03T21:00:00.000Z'), + updatedAt: new Date('2026-08-03T21:00:00.000Z'), + completedAt: null, + }) + }) + + it('creates one signed multipart session for a small file', async () => { + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }) + + expect(response.status).toBe(201) + expect((await response.json()).data).toMatchObject({ + id: 'upload-1', + status: 'uploading', + partCount: 1, + uploadToken: 'signed-upload-token', + file: null, + }) + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + metadata: { folderId: null }, + }) + }) + + it('authorizes workspace write access before creating provider state', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }) + + expect(response.status).toBe(403) + expect(mockAssertFolder).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('rejects an empty file before creating provider state', async () => { + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 0, + }) + + expect(response.status).toBe(400) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts new file mode 100644 index 00000000000..8ee6777176d --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFileUploadContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, name, contentType, size, folderId } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + + const session = await createUploadSession({ + workspaceId, + userId, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: normalizedFolderId }, + }) + return v2Data(toV2FileUpload(session, null), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts new file mode 100644 index 00000000000..c79baf22bb1 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -0,0 +1,38 @@ +import type { V2FileUpload } from '@/lib/api/contracts/v2/files' +import type { V2UploadStatus } from '@/lib/api/contracts/v2/uploads' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { UploadSessionRecord } from '@/lib/uploads/multipart-session/service' +import { toV2File } from '@/app/api/v2/files/utils' + +export function toV2FileUpload( + session: UploadSessionRecord, + file: WorkspaceFileRecord | null +): V2FileUpload { + return { + id: session.id, + status: uploadStatus(session.status), + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + partSize: session.partSize, + partCount: session.partCount, + uploadToken: session.uploadToken, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + file: file ? toV2File(file) : null, + } +} + +function uploadStatus(status: string): V2UploadStatus { + if ( + status !== 'uploading' && + status !== 'finalizing' && + status !== 'completed' && + status !== 'failed' && + status !== 'aborted' && + status !== 'expired' + ) { + throw new Error(`Invalid upload session status: ${status}`) + } + return status +} diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index f226e77a345..475f73cb5c5 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -230,10 +230,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]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts new file mode 100644 index 00000000000..63bf970f013 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -0,0 +1,67 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableExportResource, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +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' + +const logger = createLogger('V2TableExportsAPI') + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + 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(v2CreateTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId, format } = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const access = await checkAccess(parsed.data.params.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + const record = await createTableExportResource({ table: access.table, format }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: access.table.id, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount }, + request, + }) + return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table export', { error: getErrorMessage(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..0a847cabcac --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -0,0 +1,437 @@ +/** + * @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, + mockAddWorkflowGroup, + mockUpdateWorkflowGroup, + mockDeleteWorkflowGroup, + mockGetActiveWorkflowContext, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGateError: vi.fn(), + mockAddWorkflowGroup: vi.fn(), + mockUpdateWorkflowGroup: vi.fn(), + mockDeleteWorkflowGroup: vi.fn(), + mockGetActiveWorkflowContext: vi.fn(), + mockSignalSchemaChanged: 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-groups/service', () => ({ + addWorkflowGroup: mockAddWorkflowGroup, + updateWorkflowGroup: mockUpdateWorkflowGroup, + deleteWorkflowGroup: mockDeleteWorkflowGroup, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowContext: mockGetActiveWorkflowContext, +})) + +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH, POST } 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() + }) +}) + +const ADD_BODY = { + workspaceId: 'ws-1', + group: { + workflowId: 'wf-1', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], + }, + outputColumns: [{ name: 'summary', type: 'string' }], +} + +const UPDATED_TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ name: 'summary', type: 'string' }], workflowGroups: [GROUP] }, +} + +function callWrite(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const handler = method === 'POST' ? POST : method === 'PATCH' ? PATCH : DELETE + return handler(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /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) + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) + // Echo back the id the route generated, as the real service does. + mockAddWorkflowGroup.mockImplementation(async (data: { group: { id: string } }) => ({ + ...UPDATED_TABLE, + schema: { + ...UPDATED_TABLE.schema, + workflowGroups: [{ ...GROUP, id: data.group.id }], + }, + })) + }) + + it('creates the group and its columns, returning both', async () => { + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.group).toMatchObject({ workflowId: 'wf-1', name: 'Enrich' }) + expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('500s rather than emitting a body without the group it claims to have written', async () => { + // Write reports success but the group is absent — an internal inconsistency + // must not surface as a 200 with `group: undefined`. + mockAddWorkflowGroup.mockResolvedValue({ + ...UPDATED_TABLE, + schema: { columns: [], workflowGroups: [] }, + }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(500) + expect((await res.json()).error.code).toBe('INTERNAL_ERROR') + }) + + it('server-generates the group id and stamps it onto the output columns', async () => { + await callWrite('POST', ADD_BODY) + + const call = mockAddWorkflowGroup.mock.calls[0][0] + expect(call.group.id).toEqual(expect.any(String)) + expect(call.group.id).not.toBe('') + // The caller never supplies workflowGroupId — it is derived from the group. + expect(call.outputColumns[0].workflowGroupId).toBe(call.group.id) + }) + + it('defaults autoRun to false so one POST cannot fan out a metered backfill', async () => { + await callWrite('POST', ADD_BODY) + expect(mockAddWorkflowGroup.mock.calls[0][0].autoRun).toBe(false) + }) + + it('rejects a workflow from another workspace before persisting it', async () => { + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Workflow not found') + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('rejects an output column that no group output feeds', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + outputColumns: [{ name: 'summry', type: 'string' }], + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('summry') + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s an enrichment group with no enrichmentId', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + group: { ...ADD_BODY.group, workflowId: '', type: 'enrichment' }, + }) + + expect(res.status).toBe(400) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s a workflow group with no workflowId', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + group: { ...ADD_BODY.group, workflowId: '' }, + }) + + expect(res.status).toBe(400) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(404) + expect(mockAddWorkflowGroup).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 callWrite('POST', ADD_BODY) + + expect(res.status).toBe(404) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, retryAfterMs: 1000 }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(429) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('surfaces a duplicate-column failure as 400, not 500', async () => { + mockAddWorkflowGroup.mockRejectedValue(new Error('Column "summary" already exists')) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('already exists') + }) +}) + +describe('PATCH /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) + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) + mockUpdateWorkflowGroup.mockResolvedValue(UPDATED_TABLE) + }) + + it('updates the group and returns it with the resulting columns', async () => { + const res = await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + name: 'Renamed', + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.group).toEqual(GROUP) + expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) + expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', groupId: 'group-1', name: 'Renamed' }), + expect.any(String) + ) + }) + + it('re-checks workspace containment when the group is re-pointed', async () => { + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) + + const res = await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + workflowId: 'wf-elsewhere', + }) + + expect(res.status).toBe(400) + expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() + }) + + it('stamps the group id onto any newly added output columns', async () => { + await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + newOutputColumns: [{ name: 'score', type: 'number' }], + }) + + expect(mockUpdateWorkflowGroup.mock.calls[0][0].newOutputColumns[0].workflowGroupId).toBe( + 'group-1' + ) + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(404) + expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() + }) + + it('404s an unknown group rather than reporting a generic failure', async () => { + mockUpdateWorkflowGroup.mockRejectedValue(new Error('Workflow group not found')) + + const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'nope' }) + + expect(res.status).toBe(404) + }) +}) + +describe('DELETE /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) + mockDeleteWorkflowGroup.mockResolvedValue({ + ...UPDATED_TABLE, + schema: { columns: [], workflowGroups: [] }, + }) + }) + + it('deletes the group and reports the surviving columns', async () => { + const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(200) + // The group's columns go with it — the caller sees what is left, not a bare ack. + expect(await res.json()).toEqual({ data: { id: 'group-1', deleted: true, columns: [] } }) + expect(mockDeleteWorkflowGroup).toHaveBeenCalledWith( + { tableId: 'table-1', groupId: 'group-1' }, + expect.any(String) + ) + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(404) + expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s a body with no groupId', async () => { + const res = await callWrite('DELETE', { workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockDeleteWorkflowGroup).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..2d96bf9149a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -0,0 +1,367 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + v2AddWorkflowGroupContract, + v2DeleteWorkflowGroupContract, + v2ListWorkflowGroupsContract, + v2UpdateWorkflowGroupContract, +} 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 { TableDefinition, TableSchema } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + addWorkflowGroup, + deleteWorkflowGroup, + updateWorkflowGroup, +} from '@/lib/table/workflow-groups/service' +import { checkAccess, normalizeColumn } 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 { v2TableLockError } from '@/app/api/v2/tables/utils' + +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') + } +}) + +/** + * Renders a group-service failure in the v2 envelope. The service signals + * through thrown `Error` messages rather than classified codes, so the string + * matching mirrors the first-party mapper — the two surfaces must agree on + * which failures are the caller's fault. + */ +function groupMutationError(error: unknown, requestId: string, fallback: string) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + if (error instanceof Error) { + const message = error.message + if (message === 'Table not found' || message.includes('not found')) { + return v2Error('NOT_FOUND', message) + } + if ( + message.includes('Schema validation') || + message.includes('Missing column definition') || + message.includes('already exists') || + message.includes('exceed') + ) { + return v2Error('BAD_REQUEST', message) + } + } + + logger.error(`[${requestId}] ${fallback}`, { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') +} + +/** + * A group persists a `workflowId` that its runs later execute. Without this the + * table becomes a way to invoke workflows the API key cannot otherwise reach, + * so containment is asserted before the id is stored — on create and on any + * update that re-points the group. + */ +async function assertWorkflowInWorkspace(workflowId: string, workspaceId: string) { + const context = await getActiveWorkflowContext(workflowId) + if (!context || context.workspaceId !== workspaceId) { + return v2Error('BAD_REQUEST', 'Workflow not found in this workspace') + } + return null +} + +/** + * `{ group, columns }` for the group a mutation touched. + * + * Throws when the write reports success but the group is absent from the + * returned schema. The contract declares `group` as present, so emitting + * `undefined` there would ship a body no client can parse while reporting 200 — + * an internal inconsistency is worth a 500, not a malformed success. + */ +function groupResponse(table: TableDefinition, groupId: string) { + const schema = table.schema as TableSchema + const group = (schema.workflowGroups ?? []).find((candidate) => candidate.id === groupId) + if (!group) { + throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) + } + return { group, columns: schema.columns.map(normalizeColumn) } +} + +/** + * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the + * table and create the columns its runs populate, in one call. + */ +export const POST = 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(v2AddWorkflowGroupContract, 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 || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.group.workflowId) { + const workflowError = await assertWorkflowInWorkspace( + validated.group.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + /** + * `outputs` and `outputColumns` are two arrays joined by column name, so a + * typo in either silently creates a column nothing feeds. The first-party + * client builds both from one picker and can't desync; a public caller can, + * so the mismatch is rejected rather than persisted. + */ + const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) + const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + return v2Error( + 'BAD_REQUEST', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const groupId = validated.group.id ?? generateId() + + const updatedTable = await addWorkflowGroup( + { + tableId, + group: { ...validated.group, id: groupId }, + // Stamped from the resolved group rather than trusted from the caller. + outputColumns: validated.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: validated.autoRun, + actorUserId: userId, + }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to add workflow group') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/groups — Restructure a group: re-point it, + * add or remove outputs, or change how its runs are scheduled. + * + * Removing an output **deletes that column and its values** — the same + * behavior as `DELETE /columns` on a bound column. There is no detach. + */ +export const PATCH = 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(v2UpdateWorkflowGroupContract, 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 || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.workflowId !== undefined) { + const workflowError = await assertWorkflowInWorkspace( + validated.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + actorUserId: userId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { + newOutputColumns: validated.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: validated.groupId, + })), + } + : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.inputMappings !== undefined + ? { inputMappings: validated.inputMappings } + : {}), + ...(validated.deploymentMode !== undefined + ? { deploymentMode: validated.deploymentMode } + : {}), + ...(validated.type !== undefined ? { type: validated.type } : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), + }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to update workflow group') + } +}) + +/** + * DELETE /api/v2/tables/[tableId]/groups — Remove a group **and every column it + * fed**, along with their values. The surviving column list comes back so a + * caller does not have to re-read the table to see what is left. + */ +export const DELETE = 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(v2DeleteWorkflowGroupContract, 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 || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data( + { + id: validated.groupId, + deleted: true as const, + columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), + }, + { rateLimit } + ) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to delete workflow group') + } +}) 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..735529f80a0 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; lock flags are + * read-only on this surface and a request carrying them is refused outright. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +14,27 @@ const { mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteTable, + mockPerformRenameTable, + mockPerformMoveTableToFolder, + mockPerformUpdateTableLocks, mockRecordAudit, + mockGetTableById, + mockFindActiveFolder, + 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(), + mockGateError: vi.fn(), + mockSignalSchemaChanged: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -41,21 +57,64 @@ 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/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 +123,26 @@ 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' }) + 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 +170,278 @@ 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('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') + }) + + /** + * Locks are read-only on the public API. A `write`-level API key can already + * mutate the table, so letting it clear a lock would let it undo the guard + * placed there to stop it. The strict body rejects the field outright rather + * than dropping it silently, which would report success for a change that + * never happened. + */ + it('rejects a lock change instead of applying or silently ignoring it', async () => { + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('BAD_REQUEST') + expect(JSON.stringify(body.error)).toContain('locks') + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('rejects a lock change even when paired with an otherwise valid rename', async () => { + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + locks: { deleteLocked: false }, + }) + + expect(res.status).toBe(400) + // The whole request is refused — the rename must not land either. + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + /** + * The re-read runs after the writes have committed, so a failure there must + * still name what landed. Reporting a bare 500 tells the caller nothing took + * effect and it retries into a duplicate-name conflict. + */ + it('reports the applied operations when the final re-read throws', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockGetTableById.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(500) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('reports the applied operations when the re-read finds the table archived', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockGetTableById.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits applied details when the failure happened before any write', async () => { + mockGetTableById.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', folderId: 'nope' }) + + // Absence is meaningful: nothing is live, so a retry is safe. + expect((await res.json()).error.details).toBeUndefined() + }) + + it('still reports the stored lock flags on the table it returns', async () => { + // The response is a re-read, so the locked state has to come from there. + mockGetTableById.mockResolvedValue({ + ...UPDATED_TABLE, + locks: { ...UNLOCKED, deleteLocked: true }, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(200) + expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true }) + }) + + 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..447bd76e4c2 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,26 +1,54 @@ 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 { asOrchestrationError } from '@/lib/core/orchestration/types' 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, +} from '@/lib/table/orchestration' 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, - 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') +/** + * `details` payload naming the operations of a composite write that committed, + * or `undefined` when none did — so `details.applied` being present always + * means "these changes are live despite the error". + */ +function appliedDetails( + applied: readonly ('name' | 'folderId')[] +): { applied: readonly string[] } | undefined { + return applied.length > 0 ? { applied } : undefined +} + export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -69,6 +97,151 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR } }) +/** + * PATCH /api/v2/tables/[tableId] — Rename and/or move a table. + * + * Each field routes to its own orchestration call so the audit records the + * operation the caller actually performed. + * + * Lock flags are **not** settable here. They are readable on the table resource + * and enforced on every write, but an API key that can mutate a table must not + * also be able to clear the lock placed there to stop it; changing a lock stays + * a first-party admin action. The contract body is `.strict()`, so a request + * carrying `locks` is rejected rather than silently ignored. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + /** + * Hoisted above the `try` so every exit path can report it. Once a write has + * committed, the response must say so even when the failure came *after* the + * writes — a throw in the final re-read, or the re-read finding the table + * archived. Reporting a bare 500 there tells the caller nothing landed, and + * it retries into a duplicate-name conflict or a repeated move. + */ + const applied: ('name' | 'folderId')[] = [] + + 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') + } + + // The two operations are separate transactions, so a rejection discovered + // partway through would leave the earlier one 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.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') + } + } + + // 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 two operations commit independently — a single + // transaction would have to span two 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. + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (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, appliedDetails(applied)) + } + + // Re-read so the response reflects every applied change at once. A miss + // means the table was archived after the writes committed, so the caller + // still has to be told what landed. + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } + + return v2Data({ table: toApiTable(updated) }, { rateLimit }) + } catch (error) { + const details = appliedDetails(applied) + + const lockError = v2TableLockError(error, details) + if (lockError) return lockError + + const classified = asOrchestrationError(error) + if (classified) { + return v2TableOrchestrationError( + { errorCode: classified.code, error: classified.message }, + 'Failed to update table', + details + ) + } + + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + applied, + }) + return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + } +}) + /** DELETE /api/v2/tables/[tableId] — Archive a table. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() @@ -102,7 +275,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/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts new file mode 100644 index 00000000000..87268ab070f --- /dev/null +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' +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 { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportDownloadAPI') +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +interface TableExportRouteParams { + params: Promise<{ exportId: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + 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(v2TableExportDownloadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await requireTableExport(parsed.data.params.exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table export not found') + } + const result = tableExportResult(record) + const url = await generatePresignedDownloadUrl( + result.resultKey, + 'workspace', + DOWNLOAD_TTL_SECONDS + ) + return v2Data( + { + url, + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + }, + { rateLimit } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to issue table export download', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts new file mode 100644 index 00000000000..4fa1032e782 --- /dev/null +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CancelTableExportContract, + v2GetTableExportContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + cancelTableExportResource, + requireTableExport, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +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' + +const logger = createLogger('V2TableExportAPI') + +interface TableExportRouteParams { + params: Promise<{ exportId: string }> +} + +async function authorizeExport(exportId: string, workspaceId: string, userId: string) { + const record = await requireTableExport(exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) return null + return record +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + 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(v2GetTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + if (!record) return v2Error('NOT_FOUND', 'Table export not found') + return v2Data(toV2TableExport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + 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(v2CancelTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + if (!record) return v2Error('NOT_FOUND', 'Table export not found') + return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to cancel table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts new file mode 100644 index 00000000000..783a473fda5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockGetOwnedTableImportUpload, + mockFindOwnedTableImport, + mockStartUploadedTableImport, + mockToV2TableImport, + mockCompleteUploadSession, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockGetOwnedTableImportUpload: vi.fn(), + mockFindOwnedTableImport: vi.fn(), + mockStartUploadedTableImport: vi.fn(), + mockToV2TableImport: vi.fn(), + mockCompleteUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/app/api/v2/tables/utils', () => ({ + v2TableLockError: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + findOwnedTableImport: mockFindOwnedTableImport, + getOwnedTableImportUpload: mockGetOwnedTableImportUpload, + startUploadedTableImport: mockStartUploadedTableImport, + toV2TableImport: mockToV2TableImport, +})) + +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + completeUploadSession: mockCompleteUploadSession, +})) + +import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} +const UPLOAD = { + id: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', +} + +function request() { + return POST( + new NextRequest( + `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'upload-token': 'signed-upload-token', + }, + body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }), + } + ), + { params: Promise.resolve({ importId: 'import-1' }) } + ) +} + +describe('POST /api/v2/tables/imports/[importId]/complete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD) + }) + + it('returns the existing table job when completion is retried', async () => { + const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' } + const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' } + mockFindOwnedTableImport.mockResolvedValue(existing) + mockToV2TableImport.mockReturnValue(responseBody) + + const response = await request() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: responseBody }) + expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({ + importId: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + uploadToken: 'signed-upload-token', + }) + expect(mockFindOwnedTableImport).toHaveBeenCalledWith({ + importId: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + expect(mockCompleteUploadSession).not.toHaveBeenCalled() + expect(mockStartUploadedTableImport).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..4e44d28dafe --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findOwnedTableImport, + getOwnedTableImportUpload, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +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 { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2CompleteTableImportAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + 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(v2CompleteTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + }) + if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async () => ({ value: null }), + }) + const started = await startUploadedTableImport(completed.session) + return v2Data(await toV2TableImport(started), { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to complete table import upload', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts new file mode 100644 index 00000000000..74f3153a9be --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' +import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +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' + +const logger = createLogger('V2TableImportPartsAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + 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(v2CreateTableImportPartUrlsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const session = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table import part URLs', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts new file mode 100644 index 00000000000..22005ef907e --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CancelTableImportContract, + v2GetTableImportContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + abortTableImportUpload, + cancelTableImportResource, + getOwnedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +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' + +const logger = createLogger('V2TableImportAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + 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(v2GetTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + return v2Data(await toV2TableImport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + 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(v2CancelTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const uploadToken = parsed.data.headers['upload-token'] + const record = uploadToken + ? await abortTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + uploadToken, + }) + : await cancelTableImportResource( + await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + ) + return v2Data(toV2TableImport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to cancel table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts new file mode 100644 index 00000000000..2a0aeaf07a5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -0,0 +1,53 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableImportResource, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +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 { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + 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( + v2CreateTableImportContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const created = await createTableImportResource(parsed.data.body, userId) + return v2Data(await toV2TableImport(created.record), { rateLimit, status: 201 }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table import', { error: getErrorMessage(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..bcc780e23ab 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,59 @@ 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) +export function v2TableLockError( + error: unknown, + /** Merged into `details` — e.g. which operations of a composite write landed. */ + extraDetails?: Record +): NextResponse | null { + if (error instanceof TableLockedError) { + return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } }) + } 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/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts new file mode 100644 index 00000000000..3baff93ec04 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { writeLocalMultipartPart } from '@/lib/uploads/multipart-session/provider' +import { + expectedUploadPartSize, + type UploadSessionRecord, + verifyUploadSessionToken, +} from '@/lib/uploads/multipart-session/service' + +interface LocalPartRouteParams { + params: Promise<{ uploadId: string; partNumber: string }> +} + +/** + * Local-storage data plane for signed multipart PUT URLs. Cloud deployments return provider URLs + * instead, so this route is never in the cloud byte path. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: LocalPartRouteParams): Promise => { + const { uploadId } = await context.params + const token = request.nextUrl.searchParams.get('token') ?? '' + let session: UploadSessionRecord + try { + session = verifyUploadSessionToken(token) + } catch { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + const parsed = await parseRequest(localUploadPartContract, request, context) + if (!parsed.success) return parsed.response + + if (session.id !== uploadId || session.storageProvider !== 'local') { + return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + } + if (session.status !== 'uploading') { + return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + } + + const { partNumber } = parsed.data.params + const expectedSize = expectedUploadPartSize(session, partNumber) + const contentLength = request.headers.get('content-length') + if (contentLength !== null && Number(contentLength) !== expectedSize) { + return NextResponse.json( + { error: `Part ${partNumber} must contain exactly ${expectedSize} bytes` }, + { status: 400 } + ) + } + if (!request.body) { + return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 }) + } + + await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) + return new NextResponse(null, { status: 204 }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts index b91d1b99318..efd99ab7b40 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts @@ -2,14 +2,13 @@ import { useCallback, useEffect, useRef } from 'react' import { toast } from '@sim/emcn' -import { generateId } from '@sim/utils/id' import { useRouter } from 'next/navigation' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useImportFileAsTable } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' -export type CsvImportFileDescriptor = Pick +export type CsvImportFileDescriptor = Pick /** * Wires the "Import as a table" affordance for a capped CSV preview. When the preview is @@ -32,10 +31,7 @@ export function useCsvTruncationImport( const importAsTable = useCallback(() => { if (importingRef.current) return importingRef.current = true - const pendingId = `pending_${generateId()}` - useImportTrayStore - .getState() - .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) + let importId: string | null = null toast.success(`Importing "${file.name}" as a table`, { description: 'This runs in the background.', action: { @@ -44,17 +40,29 @@ export function useCsvTruncationImport( }, }) importFile.mutate( - { workspaceId, fileKey: file.key, fileName: file.name }, + { + workspaceId, + fileId: file.id, + fileName: file.name, + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + workspaceId, + title: file.name, + }) + }, + }, { onSettled: () => { importingRef.current = false - useImportTrayStore.getState().endUpload(pendingId) + if (importId) useImportTrayStore.getState().endUpload(importId) }, } ) // importFile.mutate and router are stable references // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, file.key, file.name]) + }, [workspaceId, file.id, file.key, file.name]) // Surface the cap as a warning toast with an import action, once per file. const notifiedKeyRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 006012191ac..33effd87945 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -288,6 +288,7 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({ mimeType={file.type} filename={file.name} workspaceId={workspaceId} + fileId={file.id} fileKey={file.key} readOnly /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 764349c42ad..4dbb0528483 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -42,6 +42,7 @@ interface PreviewPanelProps { mimeType: string | null filename: string workspaceId: string + fileId: string fileKey: string isStreaming?: boolean /** @@ -57,6 +58,7 @@ export const PreviewPanel = memo(function PreviewPanel({ mimeType, filename, workspaceId, + fileId, fileKey, isStreaming, readOnly, @@ -69,7 +71,7 @@ export const PreviewPanel = memo(function PreviewPanel({ ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 6316f141f7d..0aa57e987ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -637,6 +637,7 @@ export const TextEditor = memo(function TextEditor({ mimeType={file.type} filename={file.name} workspaceId={workspaceId} + fileId={file.id} fileKey={file.key} isStreaming={isStreaming} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 2b063f54dc4..030e540c708 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -59,7 +59,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI import { useFolders } from '@/hooks/queries/folders' import { useLogDetail } from '@/hooks/queries/logs' import { useScheduleById } from '@/hooks/queries/schedules' -import { downloadTableExport } from '@/hooks/queries/tables' +import { exportTable } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -331,13 +331,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) ) case 'table': - return ( - - ) + return case 'log': return case 'scheduledtask': @@ -495,10 +489,9 @@ const tableLogger = createLogger('EmbeddedTableActions') interface EmbeddedTableActionsProps { workspaceId: string tableId: string - tableName: string } -function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTableActionsProps) { +function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProps) { const router = useRouter() const handleOpenTable = () => { @@ -507,7 +500,7 @@ function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTable const handleExport = async () => { try { - await downloadTableExport(tableId, tableName) + await exportTable(workspaceId, tableId) } catch (err) { tableLogger.error('Failed to export table:', err) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 6d7fa21927e..341c581af1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -253,7 +253,7 @@ export function useTableEventStream({ // Keep the tray's export list fresh between its polls. void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) if (status === 'ready' && jobId && consumeInitiatedExport(jobId)) { - void downloadExportResult(workspaceId, tableId, jobId) + void downloadExportResult(workspaceId, jobId) .then(() => toast.success('Export ready — downloading')) .catch((err) => { logger.error('Export download failed', { tableId, jobId, err }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 13191a7347a..e193f6e3da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -21,7 +21,6 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { TABLE_LIMITS } from '@/lib/table/constants' import { type BreadcrumbItem, type ColumnOption, @@ -35,13 +34,13 @@ import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' import { useLogByExecutionId } from '@/hooks/queries/logs' import { - downloadTableExport, + downloadExportResult, useCancelTableRuns, useCreateTableView, useDeleteTable, useDeleteTableRowsAsync, useDeleteTableView, - useExportTableAsync, + useExportTable, useRenameTable, useRunColumn, useTableViews, @@ -1026,16 +1025,11 @@ export function Table({ const handleExportCsv = useCallback(async () => { if (!tableData) return try { - // Big tables export as a background job (the file downloads when the job completes via the - // SSE stream); small ones keep the instant synchronous stream. While a delete job runs, - // rowCount is a doomed-estimate-adjusted number — not ground truth — so always take the - // async path (safe at any size; exports bypass the one-job-per-table gate). - const deleteRunning = tableData.jobType === 'delete' && tableData.jobStatus === 'running' - if (deleteRunning || tableData.rowCount > TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { - await exportTableAsync.mutateAsync({ format: 'csv' }) - toast.success('Export started — the download will begin when it finishes') + const exported = await exportTableAsync.mutateAsync({ format: 'csv' }) + if (exported.status === 'completed') { + await downloadExportResult(workspaceId, exported.id) } else { - await downloadTableExport(tableData.id, tableData.name) + toast.success('Export started — the download will begin when it finishes') } captureEvent(posthogRef.current, 'table_exported', { table_id: tableData.id, @@ -1256,7 +1250,7 @@ export function Table({ const deleteTableMutation = useDeleteTable(workspaceId) const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId }) - const exportTableAsync = useExportTableAsync({ workspaceId, tableId }) + const exportTableAsync = useExportTable({ workspaceId, tableId }) const handleDeleteTable = async () => { try { await deleteTableMutation.mutateAsync(tableId) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index e306aeac52d..de5983f975b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -24,7 +24,6 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' -import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants' import { buildAutoMapping, CSV_DELIMITER_SNIFF_BYTES, @@ -33,12 +32,7 @@ import { parseCsvBuffer, } from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' -import { - type CsvImportMode, - cancelTableJob, - useImportCsvIntoTable, - useImportCsvIntoTableAsync, -} from '@/hooks/queries/tables' +import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('ImportCsvDialog') @@ -152,7 +146,6 @@ export function ImportCsvDialog({ const [createHeaders, setCreateHeaders] = useState>(new Set()) const [mode, setMode] = useState('append') const importMutation = useImportCsvIntoTable() - const importAsyncMutation = useImportCsvIntoTableAsync() function resetState() { setParsed(null) @@ -306,7 +299,6 @@ export function ImportCsvDialog({ const canSubmit = parsed !== null && !importMutation.isPending && - !importAsyncMutation.isPending && missingRequired.length === 0 && duplicateTargets.length === 0 && mappedCount + createCount > 0 @@ -320,76 +312,44 @@ export function ImportCsvDialog({ const createColumns = canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined - // Large files can't be POSTed through the server (request-body cap) — upload them - // straight to storage and import in the background instead. Seed the header tray and - // close the dialog immediately so the indicator is visible during the upload, then run - // the upload + kickoff in the background (don't block the dialog on it). - if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) { - useImportTrayStore.getState().startUpload({ - uploadId: table.id, - workspaceId, - title: parsed.file.name, - }) - onOpenChange(false) - toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`) - importAsyncMutation.mutate( - { - workspaceId, - tableId: table.id, - file: parsed.file, - mode: effectiveMode, - mapping, - createColumns, - onProgress: (percent) => { - useImportTrayStore.getState().setUploadPercent(table.id, percent) - }, - }, - { - onSuccess: (data) => { - useImportTrayStore.getState().endUpload(table.id) - // The server row drives the tray once the list refetches. If canceled mid-upload, flag - // the id so it's not shown and cancel the worker server-side. - if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) { - useImportTrayStore.getState().cancel(table.id) - void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {}) - } - }, - onError: () => { - // The hook's onError surfaces the toast; just clear the tray indicator here. - useImportTrayStore.getState().endUpload(table.id) - }, - } - ) - return - } - - try { - const result = await importMutation.mutateAsync({ + let importId: string | null = null + onOpenChange(false) + toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`) + importMutation.mutate( + { workspaceId, tableId: table.id, file: parsed.file, mode: effectiveMode, mapping, createColumns, - }) - const data = result.data - if (effectiveMode === 'append') { - toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`) - } else { - toast.success( - `Replaced rows in "${table.name}": deleted ${data?.deletedCount ?? 0}, inserted ${data?.insertedCount ?? 0}` - ) + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + tableId: table.id, + workspaceId, + title: parsed.file.name, + }) + }, + onProgress: (percent) => { + if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent) + }, + }, + { + onSuccess: () => { + if (importId) { + useImportTrayStore.getState().endUpload(importId) + useImportTrayStore.getState().consumeCanceled(importId) + } + onImported?.({}) + }, + onError: (error) => { + if (importId) useImportTrayStore.getState().endUpload(importId) + setSubmitError(summarizeImportError(error.message)) + }, } - onImported?.({ - insertedCount: data?.insertedCount, - deletedCount: data?.deletedCount, - }) - onOpenChange(false) - } catch (err) { - const message = getErrorMessage(err, 'Failed to import CSV') - setSubmitError(summarizeImportError(message)) - logger.error('CSV import into existing table failed', err) - } + ) } const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index 46deda65b1c..9cb0c793d6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -10,7 +10,7 @@ import { } from '@sim/emcn' import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables' +import { cancelTableImport, downloadExportResult } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' import { getImportStage } from './import-stage' import { type ImportRow, useWorkspaceImports } from './use-workspace-imports' @@ -49,13 +49,13 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP // Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by // the kickoff handler once its jobId is known; see the `consumeCanceled` branches.) if (row.jobId) { - void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {}) + void cancelTableImport(row.workspaceId, row.jobId).catch(() => {}) } } const download = (row: ImportRow) => { if (!row.jobId) return - void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => { + void downloadExportResult(row.workspaceId, row.jobId).catch((err) => { logger.error('Export download failed', { jobId: row.jobId, err }) toast.error('Download failed — the export may have expired') }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts index d72ed8b6d20..934757a198e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts @@ -132,17 +132,18 @@ export function useWorkspaceImports( for (const upload of uploads) { if (upload.workspaceId !== workspaceId) continue - if (scopeTableId && upload.uploadId !== scopeTableId) continue + if (scopeTableId && upload.tableId !== scopeTableId) continue if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue rows.push({ id: upload.uploadId, - tableId: upload.uploadId, + tableId: upload.tableId ?? upload.uploadId, workspaceId: upload.workspaceId, title: upload.title, phase: 'importing', jobType: 'import', rowsProcessed: 0, percent: upload.percent, + jobId: upload.uploadId, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 81a44336bbd..d5443a320d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -6,11 +6,10 @@ import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn' import { Columns3, FolderPlus, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -58,15 +57,13 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { - cancelTableJob, - downloadTableExport, + exportTable, useCreateTable, useDeleteTable, - useImportCsvAsync, + useImportCsv, useMoveTable, useRenameTable, useTablesList, - useUploadCsvToTable, } from '@/hooks/queries/tables' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' @@ -150,8 +147,7 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) - const uploadCsv = useUploadCsvToTable() - const importCsvAsync = useImportCsvAsync() + const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() const deleteFolder = useDeleteFolderMutation() @@ -869,112 +865,64 @@ export function Tables() { } } - const handleCsvChange = useCallback( - async (e: React.ChangeEvent) => { - const list = e.target.files - if (!list || list.length === 0 || !workspaceId) return + const handleCsvChange = async (e: React.ChangeEvent) => { + const list = e.target.files + if (!list || list.length === 0 || !workspaceId) return - const csvFiles = Array.from(list).filter((f) => { - const ext = f.name.split('.').pop()?.toLowerCase() - return ext === 'csv' || ext === 'tsv' - }) - - if (csvFiles.length === 0) { - toast.error('No CSV or TSV files selected') - if (csvInputRef.current) csvInputRef.current.value = '' - return - } - - // Large files can't be POSTed through the server (request-body cap) — upload them - // straight to storage and import in the background. These are tracked by the import - // tray, never the header upload button, so don't touch uploading/uploadProgress here. - const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - - try { - for (const file of asyncFiles) { - // Show the indicator immediately under a temporary id (the real table id doesn't - // exist until kickoff returns), then let the tray track it. Don't redirect — the - // table is still empty/importing, so stay on the list. - const pendingId = `pending_${generateId()}` - useImportTrayStore - .getState() - .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) - toast.success(`Importing "${file.name}" in the background`) - try { - const result = await importCsvAsync.mutateAsync({ - workspaceId, - folderId: currentFolderId, - file, - onProgress: (percent) => { - useImportTrayStore.getState().setUploadPercent(pendingId, percent) - }, - }) - useImportTrayStore.getState().endUpload(pendingId) - // The server row drives the tray once the list refetches (mutation invalidates it). - // If canceled mid-upload, flag the real id so it's not shown and cancel server-side. - if ( - result?.tableId && - result.importId && - useImportTrayStore.getState().consumeCanceled(pendingId) - ) { - useImportTrayStore.getState().cancel(result.tableId) - void cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {}) - } - } catch { - // The hook's onError surfaces the toast; just clear the tray indicator here. - useImportTrayStore.getState().endUpload(pendingId) - } - } - - if (syncFiles.length === 0) return + const csvFiles = Array.from(list).filter((f) => { + const ext = f.name.split('.').pop()?.toLowerCase() + return ext === 'csv' || ext === 'tsv' + }) - setUploadProgress({ completed: 0, total: syncFiles.length }) - const failed: string[] = [] + if (csvFiles.length === 0) { + toast.error('No CSV or TSV files selected') + if (csvInputRef.current) csvInputRef.current.value = '' + return + } - for (let i = 0; i < syncFiles.length; i++) { - const file = syncFiles[i] - try { - const result = await uploadCsv.mutateAsync({ - workspaceId, - folderId: currentFolderId, - file, - }) - - if (syncFiles.length === 1 && asyncFiles.length === 0) { - const tableId = result?.data?.table?.id - if (tableId) { - router.push(`/workspace/${workspaceId}/tables/${tableId}`) - } - } - } catch (err) { - failed.push(file.name) - logger.error('Error uploading CSV:', err) - } finally { - setUploadProgress({ completed: i + 1, total: syncFiles.length }) + try { + setUploadProgress({ completed: 0, total: csvFiles.length }) + for (let index = 0; index < csvFiles.length; index++) { + const file = csvFiles[index] + let importId: string | null = null + toast.success(`Importing "${file.name}" in the background`) + try { + await importCsv.mutateAsync({ + workspaceId, + folderId: currentFolderId, + file, + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + workspaceId, + title: file.name, + }) + }, + onProgress: (percent) => { + if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent) + }, + }) + if (importId) { + useImportTrayStore.getState().endUpload(importId) + useImportTrayStore.getState().consumeCanceled(importId) } - } - - if (failed.length > 0) { - toast.error( - failed.length === 1 - ? `Failed to import ${failed[0]}` - : `Failed to import ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed.join(', ')}` - ) - } - } catch (err) { - logger.error('Error uploading CSV:', err) - toast.error('Failed to import CSV') - } finally { - setUploadProgress({ completed: 0, total: 0 }) - if (csvInputRef.current) { - csvInputRef.current.value = '' + } catch { + if (importId) useImportTrayStore.getState().endUpload(importId) + } finally { + setUploadProgress({ completed: index + 1, total: csvFiles.length }) } } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 - [workspaceId, currentFolderId, router] - ) + } catch (err) { + logger.error('Error uploading CSV:', err) + toast.error('Failed to import CSV') + } finally { + setUploadProgress({ completed: 0, total: 0 }) + if (csvInputRef.current) { + csvInputRef.current.value = '' + } + } + } const handleListUploadCsv = useCallback(() => { csvInputRef.current?.click() @@ -1132,7 +1080,8 @@ export function Tables() { onExportCsv={async () => { if (!activeTable) return try { - await downloadTableExport(activeTable.id, activeTable.name) + const status = await exportTable(workspaceId, activeTable.id) + if (status === 'processing') toast.success('Export started') } catch (err) { logger.error('Failed to export table:', err) toast.error('Failed to export table') diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 42bf64f7237..b9ffd3c932b 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -113,7 +113,9 @@ async function selectExpiredWorkspaceFiles( key: workspaceFiles.key, workspaceId: workspaceFiles.workspaceId, context: workspaceFiles.context, - size: workspaceFiles.size, + size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith( + Number + ), }) .from(workspaceFiles) .where( @@ -325,7 +327,12 @@ async function deleteExpiredBillableWorkspaceFileRows( lt(workspaceFiles.deletedAt, retentionDate) ) ) - .returning({ id: workspaceFiles.id, size: workspaceFiles.size }) + .returning({ + id: workspaceFiles.id, + size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith( + Number + ), + }) if (deletedRows.some(({ size }) => size < 0)) { throw new Error('Cannot delete workspace files with negative stored-byte metadata') } diff --git a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts index 760cd187c76..f936afaa055 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts @@ -48,7 +48,7 @@ export async function sumForkCopyBytes( fileSelectors.length === 0 ? sql`0` : sql`( - SELECT coalesce(sum(${workspaceFiles.size}), 0) + SELECT coalesce(sum(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})), 0) FROM ${workspaceFiles} WHERE ${and( fileSelectors.length === 1 ? fileSelectors[0] : or(...fileSelectors), diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index a1f13eba635..f519258ad33 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -17,13 +17,20 @@ import { } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { - ApiClientError, extractValidationIssues, isApiClientError, isValidationError, } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' +import { + cancelTableImportResourceContract, + completeTableImportResourceContract, + createTableExportResourceContract, + createTableImportPartUrlsContract, + createTableImportResourceContract, + downloadTableExportResourceContract, +} from '@/lib/api/contracts/table-transfers' import { type ActiveDispatch, type AddWorkflowGroupBodyInput, @@ -35,7 +42,6 @@ import { batchUpdateTableRowsContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, - cancelTableJobContract, cancelTableRunsContract, createTableContract, createTableRowContract, @@ -48,14 +54,10 @@ import { deleteTableRowsContract, deleteTableViewContract, deleteWorkflowGroupContract, - exportDownloadContract, - exportTableAsyncContract, findTableRowsContract, getEnrichmentDetailContract, getTableContract, type InsertTableRowBodyInput, - importIntoTableAsyncContract, - importTableAsyncContract, listActiveDispatchesContract, listTableJobsContract, listTableRowsContract, @@ -84,6 +86,7 @@ import { updateTableViewContract, updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' +import type { V2TableImportSource, V2TableImportTarget } from '@/lib/api/contracts/v2/tables' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import type { CsvHeaderMapping, @@ -107,7 +110,8 @@ import { isExecInFlight, optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' -import { runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { sanitizeName } from '@/lib/table/import' +import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' import { useTimezone } from '@/hooks/queries/general-settings' import { TABLE_LIST_STALE_TIME, @@ -1722,105 +1726,112 @@ export function useRestoreTable() { }) } -interface UploadCsvParams { - workspaceId: string - /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null - file: File -} - -/** - * Upload a CSV file to create a new table with inferred schema. - */ -export function useUploadCsvToTable() { - const queryClient = useQueryClient() - const timezone = useTimezone() - - return useMutation({ - mutationFn: async ({ workspaceId, folderId, file }: UploadCsvParams) => { - // Text fields must precede the file part: the server parses the body as a - // stream and resolves as soon as it reaches the file, so any field appended - // after it is never seen. - const formData = new FormData() - formData.append('workspaceId', workspaceId) - if (folderId) formData.append('folderId', folderId) - formData.append('timezone', timezone) - formData.append('file', file) - - // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies - const response = await fetch('/api/table/import-csv', { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - // Carry the status: a plain Error drops it, and the 423 self-heal below - // keys off `error.status`. - throw new ApiClientError({ - status: response.status, - body: data, - message: data.error || 'CSV import failed', - }) - } - - return response.json() - }, - onError: (error) => { - logger.error('Failed to upload CSV:', error) - toast.error(error.message, { duration: 5000 }) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) - }, - }) -} - interface ImportCsvAsyncParams { workspaceId: string /** Folder to create the imported table in; omitted imports to the workspace root. */ folderId?: string | null file: File + onCreated?: (importId: string) => void onProgress?: (percent: number) => void } -/** - * Uploads a CSV/TSV straight to workspace storage (bypassing the server's request-body - * cap) and returns its storage key. Shared by the async-import kickoff hooks. - */ -async function uploadCsvToWorkspaceStorage( - file: File, - workspaceId: string, +async function createAndUploadTableImport(params: { + workspaceId: string + source: V2TableImportSource + target: V2TableImportTarget + file?: File + mapping?: CsvHeaderMapping + createColumns?: string[] + timezone: string + onCreated?: (importId: string) => void onProgress?: (percent: number) => void -): Promise { - const upload = await runUploadStrategy({ - file, - workspaceId, - context: 'workspace', - presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, - onProgress: onProgress ? (event) => onProgress(event.percent) : undefined, +}) { + const created = await requestJson(createTableImportResourceContract, { + body: { + workspaceId: params.workspaceId, + source: params.source, + target: params.target, + mapping: params.mapping, + createColumns: params.createColumns, + timezone: params.timezone, + }, + }) + params.onCreated?.(created.data.id) + if (params.source.type === 'workspace_file') return created.data + if (!params.file || !created.data.upload) { + throw new Error('Upload-backed table import returned no upload session') + } + const upload = created.data.upload + return uploadMultipartSession({ + file: params.file, + partSize: upload.partSize, + partCount: upload.partCount, + onProgress: params.onProgress ? (event) => params.onProgress?.(event.percent) : undefined, + getPartUrls: async (partNumbers) => { + const response = await requestJson(createTableImportPartUrlsContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + }) + return response.data.parts + }, + complete: async (parts) => { + const response = await requestJson(completeTableImportResourceContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + }) + return response.data + }, + abort: async () => { + await requestJson(cancelTableImportResourceContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + }, }) - return upload.key } -/** - * Uploads a large CSV/TSV straight to storage, then kicks off a background import into a - * new table. Resolves with `{ tableId, importId }` immediately — load progress and the - * terminal state arrive over the table-events SSE stream (see `useTableEventStream`). - */ -export function useImportCsvAsync() { +/** Uploads a CSV/TSV through a signed multipart session and creates a table from it. */ +export function useImportCsv() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, folderId, file, onProgress }: ImportCsvAsyncParams) => { - const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) - const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, folderId, fileKey, fileName: file.name, timezone }, + mutationFn: async ({ + workspaceId, + folderId, + file, + onCreated, + onProgress, + }: ImportCsvAsyncParams) => { + const imported = await createAndUploadTableImport({ + workspaceId, + source: { + type: 'upload', + name: file.name, + contentType: file.type || 'text/csv', + size: file.size, + }, + target: { + type: 'new', + name: sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ), + folderId: folderId ?? undefined, + }, + file, + timezone, + onCreated, + onProgress, }) - return response.data + return { tableId: imported.tableId, importId: imported.id } }, onError: (error) => { - logger.error('Failed to start async CSV import:', error) + logger.error('Failed to start CSV import:', error) toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -1831,8 +1842,9 @@ export function useImportCsvAsync() { interface ImportFileAsTableParams { workspaceId: string - fileKey: string + fileId: string fileName: string + onCreated?: (importId: string) => void } /** @@ -1846,11 +1858,21 @@ export function useImportFileAsTable() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, fileKey, fileName }: ImportFileAsTableParams) => { - const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, fileKey, fileName, deleteSourceFile: false, timezone }, + mutationFn: async ({ workspaceId, fileId, fileName, onCreated }: ImportFileAsTableParams) => { + const imported = await createAndUploadTableImport({ + workspaceId, + source: { type: 'workspace_file', fileId }, + target: { + type: 'new', + name: sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ), + }, + timezone, + onCreated, }) - return response.data + return { tableId: imported.tableId, importId: imported.id } }, onError: (error) => { logger.error('Failed to start import from file:', error) @@ -1871,15 +1893,12 @@ interface ImportCsvIntoTableAsyncParams { mode: CsvImportMode mapping?: CsvHeaderMapping createColumns?: string[] + onCreated?: (importId: string) => void onProgress?: (percent: number) => void } -/** - * Async append/replace import into an existing table for large files: uploads straight to - * storage (bypassing the server's request-body cap), then kicks off the background worker. - * Resolves immediately; progress + completion arrive over the table-events SSE stream. - */ -export function useImportCsvIntoTableAsync() { +/** Imports a CSV/TSV into an existing table through the same durable resource for every size. */ +export function useImportCsvIntoTable() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ @@ -1890,98 +1909,30 @@ export function useImportCsvIntoTableAsync() { mode, mapping, createColumns, + onCreated, onProgress, }: ImportCsvIntoTableAsyncParams) => { - const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) - const response = await requestJson(importIntoTableAsyncContract, { - params: { tableId }, - body: { workspaceId, fileKey, fileName: file.name, mode, mapping, createColumns, timezone }, - }) - return response.data - }, - onError: (error, variables) => { - if (handleTableLockRejection(error, queryClient, variables.tableId)) return - logger.error('Failed to start async CSV import:', error) - toast.error(error.message, { duration: 5000 }) - }, - onSettled: (_data, _error, variables) => { - invalidateRowCount(queryClient, variables.tableId) - }, - }) -} - -interface ImportCsvIntoTableParams { - workspaceId: string - tableId: string - file: File - mode: CsvImportMode - mapping?: CsvHeaderMapping - /** CSV headers to auto-create as new columns on the target table. */ - createColumns?: string[] -} - -interface ImportCsvIntoTableResponse { - success: boolean - data?: { - tableId: string - mode: CsvImportMode - insertedCount?: number - deletedCount?: number - mappedColumns?: string[] - skippedHeaders?: string[] - unmappedColumns?: string[] - sourceFile?: string - } -} - -/** - * Upload a CSV file to an existing table in append or replace mode. Supports - * an optional explicit header-to-column mapping; when omitted the server - * auto-maps headers by sanitized name. - */ -export function useImportCsvIntoTable() { - const queryClient = useQueryClient() - const timezone = useTimezone() - - return useMutation({ - mutationFn: async ({ - workspaceId, - tableId, - file, - mode, - mapping, - createColumns, - }: ImportCsvIntoTableParams): Promise => { - // Text fields must precede the file part: the server parses the body as a - // stream and needs these fields before it reaches the (large) file. - const formData = new FormData() - formData.append('workspaceId', workspaceId) - formData.append('mode', mode) - formData.append('timezone', timezone) - if (mapping) { - formData.append('mapping', JSON.stringify(mapping)) - } - if (createColumns && createColumns.length > 0) { - formData.append('createColumns', JSON.stringify(createColumns)) - } - formData.append('file', file) - - // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies - const response = await fetch(`/api/table/${tableId}/import`, { - method: 'POST', - body: formData, + const imported = await createAndUploadTableImport({ + workspaceId, + source: { + type: 'upload', + name: file.name, + contentType: file.type || 'text/csv', + size: file.size, + }, + target: { type: 'existing', tableId, mode }, + file, + mapping, + createColumns, + timezone, + onCreated, + onProgress, }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'CSV import failed') - } - - return response.json() + return { tableId: imported.tableId, importId: imported.id } }, onError: (error, variables) => { if (handleTableLockRejection(error, queryClient, variables.tableId)) return - logger.error('Failed to import CSV into table:', error) + logger.error('Failed to start CSV import:', error) toast.error(error.message, { duration: 5000 }) }, onSettled: (_data, _error, variables) => { @@ -1990,19 +1941,12 @@ export function useImportCsvIntoTable() { }) } -/** - * Cancels an in-flight async table job (import or delete). Plain function (not a hook) because the - * job tray lists multiple tables and cancels a chosen one by id rather than binding to a single - * table. - */ -export async function cancelTableJob( - workspaceId: string, - tableId: string, - jobId: string -): Promise { - await requestJson(cancelTableJobContract, { - params: { tableId }, - body: { workspaceId, jobId }, +/** Cancels an in-flight table import resource. */ +export async function cancelTableImport(workspaceId: string, importId: string): Promise { + await requestJson(cancelTableImportResourceContract, { + params: { importId }, + query: { workspaceId }, + headers: {}, }) } @@ -2046,23 +1990,17 @@ export function consumeInitiatedExport(jobId: string): boolean { } /** - * Kicks off a background export job for large tables (small ones stream synchronously via - * {@link downloadTableExport}). The SSE job stream auto-downloads the file when the job is ready. + * Creates an export resource. The server completes small exports before responding and processes + * large exports in the background; the client follows the same path for both. */ -export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext) { +export function useExportTable({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ format }: { format: 'csv' | 'json' }) => { - const response = await requestJson(exportTableAsyncContract, { - params: { tableId }, - body: { workspaceId, format }, - }) - initiatedExportJobIds.add(response.data.jobId) - return response.data + return createTableExport(workspaceId, tableId, format) }, - onSuccess: () => { - // Surface the new running job in the tray immediately — its poll only - // self-sustains once a running job is already in the cache. + onSettled: () => { + // Reconcile failed creation and seed polling after a successful background export. void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) }, onError: (error) => { @@ -2073,15 +2011,20 @@ export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext }) } -/** Resolves a ready export job to its presigned URL and triggers the browser download. */ -export async function downloadExportResult( - workspaceId: string, - tableId: string, - jobId: string -): Promise { - const response = await requestJson(exportDownloadContract, { +async function createTableExport(workspaceId: string, tableId: string, format: 'csv' | 'json') { + const response = await requestJson(createTableExportResourceContract, { params: { tableId }, - query: { workspaceId, jobId }, + body: { workspaceId, format }, + }) + if (response.data.status !== 'completed') initiatedExportJobIds.add(response.data.id) + return response.data +} + +/** Resolves a ready export job to its presigned URL and triggers the browser download. */ +export async function downloadExportResult(workspaceId: string, exportId: string): Promise { + const response = await requestJson(downloadTableExportResourceContract, { + params: { exportId }, + query: { workspaceId }, }) const a = document.createElement('a') a.href = response.data.url @@ -2091,32 +2034,18 @@ export async function downloadExportResult( document.body.removeChild(a) } -/** - * Downloads the full contents of a table to the user's device by streaming - * `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON. - */ -export async function downloadTableExport( +/** Creates one export resource and downloads it immediately when the server completed it inline. */ +export async function exportTable( + workspaceId: string, tableId: string, - fileName: string, format: 'csv' | 'json' = 'csv' -): Promise { - const url = `/api/table/${tableId}/export?format=${format}&t=${Date.now()}` - // boundary-raw-fetch: streaming download to a Blob, requestJson cannot consume non-JSON streams - const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `Failed to export table: ${response.statusText}`) +): Promise<'completed' | 'processing'> { + const exported = await createTableExport(workspaceId, tableId, format) + if (exported.status === 'completed') { + await downloadExportResult(workspaceId, exported.id) + return 'completed' } - const blob = await response.blob() - const objectUrl = URL.createObjectURL(blob) - const safeName = fileName.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'table' - const a = document.createElement('a') - a.href = objectUrl - a.download = `${safeName}.${format}` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(objectUrl) + return 'processing' } export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index ad49ba3e283..922236c4918 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -1,26 +1,21 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ApiClientError, isApiClientError } from '@/lib/api/client/errors' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { deleteWorkspaceFileContract, listWorkspaceFilesContract, - registerWorkspaceFileContract, renameWorkspaceFileContract, restoreWorkspaceFileContract, updateWorkspaceFileContentContract, } from '@/lib/api/contracts/workspace-files' -import { - DirectUploadError, - runUploadStrategy, - type UploadProgressEvent, -} from '@/lib/uploads/client/direct-upload' +import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' +import { uploadWorkspaceFileSession } from '@/lib/uploads/client/session-upload' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UserFile } from '@/executor/types' import { useFileContentSource } from '@/hooks/use-file-content-source' @@ -335,41 +330,6 @@ interface UploadFileResponse { file: UserFile } -async function uploadViaApiFallback( - workspaceId: string, - file: File, - folderId?: string | null, - signal?: AbortSignal -): Promise { - const formData = new FormData() - formData.append('file', file) - if (folderId) formData.append('folderId', folderId) - - // boundary-raw-fetch: multipart/form-data fallback upload, requestJson only supports JSON bodies - const response = await fetch(`/api/workspaces/${workspaceId}/files`, { - method: 'POST', - body: formData, - signal, - }) - - return parseUploadResponse(response, 'Upload failed') -} - -async function parseUploadResponse( - response: Response, - fallbackMessage: string -): Promise { - let data: { success?: boolean; error?: string; file?: UserFile } | null = null - try { - data = await response.json() - } catch {} - - if (!response.ok || !data?.success) { - throw new Error(data?.error || `${fallbackMessage} (${response.status})`) - } - return data as UploadFileResponse -} - async function uploadWorkspaceFile( workspaceId: string, file: File, @@ -377,69 +337,25 @@ async function uploadWorkspaceFile( onProgress?: (event: UploadProgressEvent) => void, signal?: AbortSignal ): Promise { - let result - try { - result = await runUploadStrategy({ - file, - presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, - presignedBody: { folderId }, - workspaceId, + const uploaded = await uploadWorkspaceFileSession({ + workspaceId, + folderId, + file, + onProgress, + signal, + }) + return { + success: true, + file: { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + type: uploaded.type, + url: `/api/files/serve/${encodeURIComponent(uploaded.key)}?context=workspace`, + key: uploaded.key, context: 'workspace', - onProgress, - signal, - }) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - return uploadViaApiFallback(workspaceId, file, folderId, signal) - } - throw error - } - - const data = await registerWithRetry(workspaceId, result, folderId, signal) - - if (!data.success || !data.file) { - throw new Error(data.error || 'Failed to register file') - } - return { success: true, file: data.file } -} - -const REGISTER_MAX_ATTEMPTS = 3 -const REGISTER_RETRY_DELAY_MS = 500 - -/** - * Register the uploaded object with bounded retries. The server-side handler - * is idempotent (existing-record short-circuit), so safely retrying handles - * dropped responses that would otherwise orphan the object in storage. - */ -async function registerWithRetry( - workspaceId: string, - result: { key: string; name: string; contentType: string }, - folderId?: string | null, - signal?: AbortSignal -) { - let lastError: unknown - for (let attempt = 1; attempt <= REGISTER_MAX_ATTEMPTS; attempt++) { - try { - return await requestJson(registerWorkspaceFileContract, { - params: { id: workspaceId }, - body: { - key: result.key, - name: result.name, - contentType: result.contentType, - folderId, - }, - signal, - }) - } catch (error) { - lastError = error - if (signal?.aborted) throw error - const isTransient = - !(error instanceof ApiClientError) || (error.status >= 500 && error.status < 600) - if (!isTransient || attempt === REGISTER_MAX_ATTEMPTS) throw error - await sleep(REGISTER_RETRY_DELAY_MS * attempt) - } + }, } - throw lastError } export function useUploadWorkspaceFile() { diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts new file mode 100644 index 00000000000..69a5c4f4ec6 --- /dev/null +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -0,0 +1,95 @@ +import { exportTableAsyncBodySchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CreateTableImportBodySchema, + v2TableExportDownloadDataSchema, + v2TableExportParamsSchema, + v2TableExportSchema, + v2TableImportParamsSchema, + v2TableImportSchema, + v2TableTransferWorkspaceQuerySchema, +} from '@/lib/api/contracts/v2/tables' +import { + v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const getTableImportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const cancelTableImportResourceContract = defineRouteContract({ + method: 'DELETE', + path: '/api/table/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableExportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/exports', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const getTableExportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const cancelTableExportResourceContract = defineRouteContract({ + method: 'DELETE', + path: '/api/table/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const downloadTableExportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/exports/[exportId]/download', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 89e68b9715f..666b9d55552 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -127,7 +127,7 @@ function refineColumnOptions( * Identifier for tables/columns: starts with letter or underscore, contains * only alphanumerics + underscores, capped at `MAX_TABLE_NAME_LENGTH`. */ -const tableNameSchema = z +export const tableNameSchema = z .string() .min(1, 'Name is required') .max( @@ -1402,7 +1402,7 @@ const workflowGroupInputMappingSchema = z.object({ columnName: z.string().min(1, 'columnName cannot be empty'), }) -const workflowGroupOutputColumnSchema = z.object({ +export const workflowGroupOutputColumnSchema = z.object({ name: z.string().min(1), type: columnTypeSchema, required: z.boolean().optional(), @@ -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/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts new file mode 100644 index 00000000000..c56d8ca014b --- /dev/null +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateFileUploadBodySchema, + v2FileUploadParamsSchema, + v2FileUploadSchema, + v2FileUploadWorkspaceQuerySchema, +} from '@/lib/api/contracts/v2/files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createWorkspaceFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads', + body: v2CreateFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const abortWorkspaceFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const createWorkspaceFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeWorkspaceFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/complete', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const localUploadPartParamsSchema = z.object({ + uploadId: z.string().min(1, 'uploadId is required'), + partNumber: z.coerce.number().int().min(1), +}) + +export const localUploadPartQuerySchema = z.object({ + token: z.string().min(1, 'token is required'), +}) + +export const localUploadPartContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/uploads/[uploadId]/parts/[partNumber]', + params: localUploadPartParamsSchema, + query: localUploadPartQuerySchema, + response: { mode: 'empty', status: 204 }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index fc7f81c0697..749d4a9bda7 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -8,6 +8,14 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' /** * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in @@ -25,11 +33,8 @@ import { * contract; folder management belongs on `/api/v2/folders` once that surface * serves `resourceType: 'file'`. * - * Presigned upload is deliberately absent. Presign only performs an advisory - * quota pre-check; the storage debit happens in the separate register step, so - * a caller that presigns, PUTs bytes, and never registers leaves unaccounted - * bytes in the bucket. The buffered multipart upload debits inside - * `uploadWorkspaceFile`'s own transaction, so it is the only public path. + * Uploads use a signed stateless control token. The storage provider owns the + * multipart part state; completion atomically registers the workspace file. */ /** A workspace file as exposed by the v2 surface. */ @@ -52,6 +57,38 @@ export const v2FileSchema = z.object({ export type V2File = z.output +export const v2FileUploadParamsSchema = z.object({ uploadId: z.string().min(1) }) +export type V2FileUploadParams = z.output + +export const v2CreateFileUploadBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + }) + .strict() +export type V2CreateFileUploadBody = z.input + +export const v2FileUploadWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2FileUploadWorkspaceQuery = z.output + +export const v2FileUploadSchema = z.object({ + id: z.string(), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().positive(), + partSize: z.number().int().positive(), + partCount: z.number().int().positive(), + uploadToken: z.string().min(1), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + file: v2FileSchema.nullable(), +}) +export type V2FileUpload = z.output + /** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), @@ -125,19 +162,6 @@ export const v2ListFilesQuerySchema = z.object({ export type V2ListFilesQuery = z.output -/** - * Upload carries the workspace as a query param so auth runs before buffering. - * `folderId` is a query param for the same reason — the multipart body is never - * read until the caller is authorized. - */ -export const v2UploadFileQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - /** Target file folder. Omit to upload to the workspace root. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), -}) - -export type V2UploadFileQuery = z.output - /** Download/delete both target a single file within a workspace-scoped query. */ export const v2FileWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema, @@ -297,14 +321,40 @@ export const v2ListFilesContract = defineRouteContract({ }, }) -export const v2UploadFileContract = defineRouteContract({ +export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', - path: '/api/v2/files', - query: v2UploadFileQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FileSchema), - }, + path: '/api/v2/files/uploads', + body: v2CreateFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2AbortFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2CreateFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) export const v2DownloadFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index fc9d4046979..18e817d58a8 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,20 +1,39 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { + addWorkflowGroupBodySchema, + cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, + createTableViewBodySchema, + csvImportCreateColumnsSchema, + csvImportMappingSchema, deleteTableColumnBodySchema, + deleteWorkflowGroupBodySchema, + exportTableAsyncBodySchema, predicateSchema, + refineCancelTableRunsScope, + runColumnBodyBaseSchema, + runColumnExcludeMutexRefine, + runColumnScopeMutexRefine, sortSpecSchema, tableColumnSchema, tableIdParamsSchema, + tableLocksSchema, + tableNameSchema, tableRowParamsSchema, tableRowsQueryBaseSchema, + tableViewConfigSchema, + tableViewParamsSchema, updateRowsByFilterBodySchema, updateTableColumnBodySchema, updateTableRowBodySchema, + updateTableViewBodySchema, + updateWorkflowGroupBodySchema, upsertTableRowBodySchema, + workflowGroupOutputColumnSchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { v1CreateTableBodySchema, v1CreateTableRowsBodySchema, @@ -26,7 +45,15 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' import { TABLE_LIMITS } from '@/lib/table/constants' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' /** * v2 tables contracts. @@ -52,13 +79,29 @@ import { TABLE_LIMITS } from '@/lib/table/constants' /** Default page size when a row query/list `limit` is omitted. */ export const V2_DEFAULT_ROW_LIMIT = 100 -/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or the async export. */ +/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or an export resource. */ 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. + * + * Import and delete jobs are also derived onto the table (one write job per table at a time). + * Durable imports and exports have their own resource endpoints for complete lifecycle state. + */ +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(), @@ -66,6 +109,16 @@ 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, read-only on the public API. They are enforced on every + * write (a locked verb returns 423), but flipping them is a first-party admin + * action — see {@link v2UpdateTableBodySchema}. + */ + locks: tableLocksSchema, + /** In-flight background job, or `null` when the table is idle. */ + job: v2TableJobStateSchema.nullable(), createdAt: z.string(), updatedAt: z.string(), }) @@ -204,6 +257,48 @@ export const v2GetTableContract = defineRouteContract({ }, }) +/** + * Table update. Every field is optional but at least one must be present: + * `name` renames and `folderId` moves the table (explicit `null` moves it to + * the workspace root; omission leaves the placement untouched). + * + * `locks` is deliberately **not** accepted here, which is why this body is + * declared rather than reusing the first-party `updateTableBodySchema`. The + * governance flags are read-only on the public surface: an API key that can + * write a table must not also be able to clear the lock that was put there to + * stop it. Flipping a lock stays a first-party admin action. The body is + * `.strict()`, so a caller sending `locks` gets a 400 naming the field instead + * of a silent no-op that reads as success. + */ +export const v2UpdateTableBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: tableNameSchema.optional(), + folderId: folderIdSchema.nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.folderId === undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Provide a new name or folder', + path: ['name'], + }) + } + }) + +export const v2UpdateTableContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + body: v2UpdateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) +export type V2UpdateTableBody = z.input + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -290,7 +385,7 @@ export const v2QueryRowsBodySchema = z.object({ .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') .max( V2_MAX_ROW_LIMIT, - `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or the async export for large datasets` + `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or create an export resource for large datasets` ) .optional(), cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), @@ -432,3 +527,628 @@ 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), + }, +}) + +/** + * Output column of a group, as the public surface accepts it. The first-party + * shape carries `workflowGroupId` because the client mints the group id before + * posting; v2 server-generates it, so the field is stamped from the group being + * written rather than being a caller's to supply (and get wrong). + */ +const v2WorkflowGroupOutputColumnSchema = workflowGroupOutputColumnSchema.omit({ + workflowGroupId: true, +}) + +/** + * A group names its producer two mutually exclusive ways, and the underlying + * shape leaves both optional. Rejecting the mismatch here means the route never + * has to guess which one a half-specified group meant. + */ +function refineGroupSource( + group: { type?: 'manual' | 'enrichment'; workflowId?: string; enrichmentId?: string }, + ctx: z.RefinementCtx, + path: (string | number)[] +): void { + // `manual` is the workflow-backed default — it does not mean hand-entered. + const type = group.type ?? 'manual' + if (type === 'enrichment' && !group.enrichmentId) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'enrichmentId'], + message: 'enrichmentId is required when type is "enrichment"', + }) + } + if (type === 'manual' && !group.workflowId) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'workflowId'], + message: 'workflowId is required when type is "manual"', + }) + } +} + +/** + * Create a group and the columns its runs populate, in one call. + * + * Two deliberate departures from the first-party body: + * - `group.id` is optional and server-generated. The UI mints an id so it can + * render optimistically; a public caller has no such need and a client-chosen + * id is a collision waiting to happen. + * - `autoRun` defaults to **false**. On the first-party surface it defaults to + * true so a UI add fills cells immediately, but here it would make one POST + * fan out a metered run across every existing row. Callers opt in, or fire + * explicitly via `POST /columns/run`. + */ +export const v2AddWorkflowGroupBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + group: addWorkflowGroupBodySchema.shape.group.extend({ + id: z.string().min(1).optional(), + }), + outputColumns: z.array(v2WorkflowGroupOutputColumnSchema).min(1), + autoRun: z.boolean().optional().default(false), + }) + .strict() + .superRefine((body, ctx) => refineGroupSource(body.group, ctx, ['group'])) +export type V2AddWorkflowGroupBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowGroupBodySchema = updateWorkflowGroupBodySchema + .extend({ + newOutputColumns: z.array(v2WorkflowGroupOutputColumnSchema).optional(), + }) + .strict() +export type V2UpdateWorkflowGroupBody = z.input + +export const v2DeleteWorkflowGroupBodySchema = deleteWorkflowGroupBodySchema.strict() +export type V2DeleteWorkflowGroupBody = z.input + +/** + * Create and update both mutate the group *and* the table's columns, so both + * are returned — otherwise a caller has to re-read the table to learn which + * columns it just got. + */ +export const v2WorkflowGroupDataSchema = z.object({ + group: v2WorkflowGroupSchema, + columns: z.array(tableColumnSchema), +}) +export type V2WorkflowGroupData = z.output + +/** + * Delete acknowledgement. Removing a group removes the columns it fed, so the + * surviving column list is returned rather than left for the caller to guess. + */ +export const v2DeleteWorkflowGroupDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + columns: z.array(tableColumnSchema), +}) +export type V2DeleteWorkflowGroupData = z.output + +export const v2AddWorkflowGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2AddWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGroupDataSchema), + }, +}) + +export const v2UpdateWorkflowGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2UpdateWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGroupDataSchema), + }, +}) + +export const v2DeleteWorkflowGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2DeleteWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowGroupDataSchema), + }, +}) + +/** + * 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), + }, +}) + +export const v2TableImportParamsSchema = z.object({ importId: z.string().min(1) }) +export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1) }) +export const v2TableTransferWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) + +export const v2TableImportSourceSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('upload'), + name: z.string().trim().min(1, 'name is required').max(255), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), + }) + .strict(), + z.object({ type: z.literal('workspace_file'), fileId: z.string().min(1) }).strict(), +]) +export type V2TableImportSource = z.input + +export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('new'), + name: tableNameSchema, + folderId: folderIdSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal('existing'), + tableId: z.string().min(1), + mode: z.enum(['append', 'replace']), + }) + .strict(), +]) +export type V2TableImportTarget = z.input + +export const v2CreateTableImportBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + source: v2TableImportSourceSchema, + target: v2TableImportTargetSchema, + mapping: csvImportMappingSchema.optional(), + createColumns: csvImportCreateColumnsSchema.optional(), + timezone: ianaTimezoneSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.target.type === 'new' && body.mapping !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['mapping'], + message: 'mapping is only supported for an existing table target', + }) + } + if (body.target.type === 'new' && body.createColumns !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['createColumns'], + message: 'createColumns is only supported for an existing table target', + }) + } + }) +export type V2CreateTableImportBody = z.input + +export const v2TableImportStatusSchema = z.enum([ + 'uploading', + 'queued', + 'processing', + 'completed', + 'failed', + 'canceled', + 'expired', +]) +export type V2TableImportStatus = z.output + +export const v2TableImportUploadSchema = z.object({ + uploadToken: z.string().min(1), + partSize: z.number().int().positive(), + partCount: z.number().int().positive(), + expiresAt: z.string().datetime(), +}) + +export const v2TableImportSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + status: v2TableImportStatusSchema, + source: v2TableImportSourceSchema, + target: v2TableImportTargetSchema, + tableId: z.string().nullable(), + rowsProcessed: z.number().int().nonnegative(), + error: z.string().nullable(), + upload: v2TableImportUploadSchema.nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) +export type V2TableImport = z.output + +export const v2CreateTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2GetTableImportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CancelTableImportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CreateTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2TableExportStatusSchema = z.enum([ + 'queued', + 'processing', + 'completed', + 'failed', + 'canceled', +]) +export type V2TableExportStatus = z.output + +export const v2TableExportSchema = z.object({ + id: z.string(), + tableId: z.string(), + workspaceId: z.string(), + format: z.enum(['csv', 'json']), + status: v2TableExportStatusSchema, + rowsProcessed: z.number().int().nonnegative(), + error: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) +export type V2TableExport = z.output + +export const v2CreateTableExportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/exports', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2GetTableExportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2CancelTableExportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2TableExportDownloadDataSchema = z.object({ + url: z.string().url(), + fileName: z.string(), + expiresAt: z.string().datetime(), +}) + +export const v2TableExportDownloadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/exports/[exportId]/download', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, +}) + +/** + * 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`. Import and export work is canceled by deleting its + * resource instead. + */ +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/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts new file mode 100644 index 00000000000..d18f9124692 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' + +export const v2UploadStatusSchema = z.enum([ + 'uploading', + 'finalizing', + 'completed', + 'failed', + 'aborted', + 'expired', +]) +export type V2UploadStatus = z.output + +export const v2UploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header is required'), +}) +export type V2UploadTokenHeaders = z.input + +export const v2OptionalUploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header cannot be empty').optional(), +}) + +export const v2CompletedPartSchema = z + .object({ + partNumber: z.number().int().min(1), + etag: z.string().min(1).optional(), + }) + .strict() +export type V2CompletedPart = z.input + +export const v2CompleteUploadBodySchema = z + .object({ + parts: z.array(v2CompletedPartSchema).min(1).max(640), + }) + .strict() +export type V2CompleteUploadBody = z.input + +export const v2PartUrlsBodySchema = z + .object({ + partNumbers: z.array(z.number().int().min(1)).min(1).max(100), + }) + .strict() +export type V2PartUrlsBody = z.input + +export const v2UploadPartUrlSchema = z.object({ + partNumber: z.number().int().min(1), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + expiresAt: z.string().datetime(), +}) +export type V2UploadPartUrl = z.output + +export const v2PartUrlsDataSchema = z.object({ parts: z.array(v2UploadPartUrlSchema).max(100) }) +export type V2PartUrlsData = z.output diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index d5f74aa9660..8740d11a541 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -83,7 +83,7 @@ export function textKey(column: Column, read: (row: Row) => string): Keyset } /** A numeric key — sizes, counts, manual positions. */ -export function numberKey(column: Column, read: (row: Row) => number): KeysetKey { +export function numberKey(column: SQLWrapper, read: (row: Row) => number): KeysetKey { return { expr: column, encode: read, diff --git a/apps/sim/lib/billing/storage/payer-transfer.ts b/apps/sim/lib/billing/storage/payer-transfer.ts index 6eb9ca9337a..50d7ee2fe76 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.ts @@ -82,7 +82,7 @@ async function getExactWorkspaceStorageBytes(tx: DbOrTx, workspaceId: string): P const [row] = await tx.execute(sql` SELECT COALESCE(( - SELECT SUM(${workspaceFiles.size}::bigint) + SELECT SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) FROM ${workspaceFiles} WHERE ${workspaceFiles.workspaceId} = ${workspaceId} AND ${workspaceFiles.context} = 'workspace' @@ -171,7 +171,7 @@ async function getExactWorkspaceStorageBytesBatch( FROM ( SELECT ${workspaceFiles.workspaceId} AS workspace_id, - SUM(${workspaceFiles.size}::bigint) AS workspace_file_bytes, + SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) AS workspace_file_bytes, 0::bigint AS document_bytes FROM ${workspaceFiles} WHERE ${inArray(workspaceFiles.workspaceId, workspaceIds)} diff --git a/apps/sim/lib/table/export-stream.ts b/apps/sim/lib/table/export-stream.ts new file mode 100644 index 00000000000..abf2d027820 --- /dev/null +++ b/apps/sim/lib/table/export-stream.ts @@ -0,0 +1,100 @@ +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 + +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(',') +} + +export function exportContentType(format: TableExportFormat): string { + return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' +} + +/** + * 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. + */ +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/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 93ae112d415..b0eba05c14d 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -77,6 +77,8 @@ export interface TableImportPayload { * worker never needs a settings lookup. */ timezone?: string + /** Storage context for the source object. Legacy imports default to `workspace`. */ + storageContext?: 'workspace' | 'table-import' } /** @@ -89,12 +91,14 @@ export interface TableImportPayload { */ export async function runTableImport(payload: TableImportPayload): Promise { const { importId, tableId, workspaceId, userId, fileKey, fileName, delimiter, mode } = payload + const storageContext = payload.storageContext ?? 'workspace' const requestId = generateId().slice(0, 8) // Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks // open until it times out. let source: Readable | undefined try { + if (!(await updateJobProgress(tableId, 0, importId))) throw new ImportSupersededError() const loaded = await getTableById(tableId, { includeArchived: true }) if (!loaded) throw new Error(`Import target table ${tableId} not found`) const table = loaded @@ -131,10 +135,10 @@ export async function runTableImport(payload: TableImportPayload): Promise // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on // the local dev provider, in which case the bar stays indeterminate (rows still show). - const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0 + const totalBytes = (await headObject(fileKey, storageContext))?.size ?? 0 // Stream the file rather than buffering it — a ~1M-row import must never be held in memory. - source = await downloadFileStream({ key: fileKey, context: 'workspace' }) + source = await downloadFileStream({ key: fileKey, context: storageContext }) // The kickoff route's extension-derived delimiter is only the fallback — the separator is // sniffed from the file's head so semicolon/pipe exports don't collapse into one column. @@ -183,6 +187,9 @@ export async function runTableImport(payload: TableImportPayload): Promise * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { + if (!(await updateJobProgress(tableId, inserted, importId))) { + throw new ImportSupersededError() + } const headers = csvHeaders if (mode === 'create') { @@ -433,7 +440,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // import is terminal so the workspace bucket doesn't accumulate. Best-effort. Skipped for // persistent workspace files (deleteSourceFile: false). if (payload.deleteSourceFile !== false) { - await deleteFile({ key: fileKey, context: 'workspace' }).catch((err) => { + await deleteFile({ key: fileKey, context: storageContext }).catch((err) => { logger.warn(`[${requestId}] Failed to delete imported file`, { fileKey, 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/export-resource.ts b/apps/sim/lib/table/orchestration/export-resource.ts new file mode 100644 index 00000000000..381ae860051 --- /dev/null +++ b/apps/sim/lib/table/orchestration/export-resource.ts @@ -0,0 +1,129 @@ +import { db } from '@sim/db' +import { tableJobs } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { V2TableExport, V2TableExportStatus } from '@/lib/api/contracts/v2/tables' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import type { TableDefinition, TableExportJobPayload } from '@/lib/table/types' + +export type TableExportRecord = typeof tableJobs.$inferSelect + +export async function createTableExportResource(params: { + table: TableDefinition + format: 'csv' | 'json' +}): Promise { + const exportId = generateId() + const payload: TableExportJobPayload = { format: params.format } + if (!(await markTableJobRunning(params.table.id, exportId, 'export', payload))) { + throw new OrchestrationError('conflict', 'Failed to start export') + } + const runnerPayload: TableExportPayload = { + jobId: exportId, + tableId: params.table.id, + workspaceId: params.table.workspaceId, + format: params.format, + } + + if (params.table.rowCount <= TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { + await runTableExport(runnerPayload) + } else { + try { + if (isTriggerDevEnabled) { + 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', runnerPayload, { + tags: [`tableId:${params.table.id}`, `jobId:${exportId}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-export', () => runTableExport(runnerPayload)) + } + } catch (error) { + await markJobFailed( + params.table.id, + exportId, + getErrorMessage(error, 'Failed to dispatch table export') + ) + throw error + } + } + + return requireTableExport(exportId, params.table.workspaceId) +} + +export async function requireTableExport( + exportId: string, + workspaceId: string +): Promise { + const [record] = await db + .select() + .from(tableJobs) + .where( + and( + eq(tableJobs.id, exportId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.type, 'export') + ) + ) + .limit(1) + if (!record) throw new OrchestrationError('not_found', 'Table export not found') + return record +} + +export async function cancelTableExportResource( + record: TableExportRecord +): Promise { + if (record.status === 'canceled') return record + if (record.status !== 'running') { + throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) + } + await markJobCanceled(record.tableId, record.id) + return requireTableExport(record.id, record.workspaceId) +} + +export function toV2TableExport(record: TableExportRecord, queued = false): V2TableExport { + const payload = record.payload as TableExportJobPayload | null + if (!payload?.format) throw new Error(`Table export ${record.id} has no format`) + return { + id: record.id, + tableId: record.tableId, + workspaceId: record.workspaceId, + format: payload.format, + status: queued && record.status === 'running' ? 'queued' : publicExportStatus(record.status), + rowsProcessed: record.rowsProcessed, + error: record.error, + createdAt: record.startedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +export function tableExportResult(record: TableExportRecord): { + resultKey: string + format: 'csv' | 'json' +} { + if (record.status !== 'ready') { + throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) + } + const payload = record.payload as TableExportJobPayload | null + if (!payload?.resultKey || !payload.format) { + throw new OrchestrationError('not_found', 'Export file is no longer available') + } + return { resultKey: payload.resultKey, format: payload.format } +} + +function publicExportStatus(status: string): V2TableExportStatus { + if (status === 'running') return 'processing' + if (status === 'ready') return 'completed' + if (status === 'failed' || status === 'canceled') return status + throw new Error(`Invalid table export status: ${status}`) +} diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts new file mode 100644 index 00000000000..4e0902dc9db --- /dev/null +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -0,0 +1,455 @@ +import { db } from '@sim/db' +import { tableJobs } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { + type V2CreateTableImportBody, + type V2TableImport, + type V2TableImportSource, + type V2TableImportTarget, + v2CreateTableImportBodySchema, + v2TableImportSourceSchema, + v2TableImportTargetSchema, +} from '@/lib/api/contracts/v2/tables' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { findActiveFolder } from '@/lib/folders/queries' +import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' +import { createTable, getTableById } from '@/lib/table/service' +import type { TableImportJobPayload } from '@/lib/table/types' +import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + abortUploadSession, + createUploadSession, + getOwnedUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/multipart-session/service' +import { getUserSettings } from '@/lib/users/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' + +interface TableImportResource { + id: string + workspaceId: string + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] + tableId: string | null + status: TableImportStatus + rowsProcessed: number + error: string | null + upload: UploadSessionRecord | null + createdAt: Date + updatedAt: Date + completedAt: Date | null +} + +interface CreateTableImportResult { + record: TableImportResource + upload: UploadSessionRecord | null +} + +export async function createTableImportResource( + body: V2CreateTableImportBody, + userId: string +): Promise { + await assertWorkspaceWrite(userId, body.workspaceId) + await validateTarget(body.workspaceId, body.target) + const importId = generateId() + const options = importOptions(body) + + if (body.source.type === 'upload') { + assertCsvFileName(body.source.name) + const upload = await createUploadSession({ + id: importId, + workspaceId: body.workspaceId, + userId, + purpose: 'table_import', + fileName: body.source.name, + contentType: body.source.contentType, + fileSize: body.source.size, + metadata: { tableImport: body }, + }) + return { record: resourceFromUpload(upload, body), upload } + } + + const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) + assertCsvFileName(file.name) + return { + record: await startTableImport({ + id: importId, + workspaceId: body.workspaceId, + userId, + source: body.source, + target: body.target, + options, + fileKey: file.key, + fileName: file.name, + storageContext: 'workspace', + deleteSourceFile: false, + }), + upload: null, + } +} + +export async function startUploadedTableImport( + upload: UploadSessionRecord +): Promise { + const body = tableImportBodyFromUpload(upload) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + }) + if (existing) return existing + return startTableImport({ + id: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + source: body.source, + target: body.target, + options: importOptions(body), + fileKey: upload.storageKey, + fileName: upload.fileName, + storageContext: 'table-import', + deleteSourceFile: true, + }) +} + +export function getOwnedTableImportUpload(params: { + importId: string + workspaceId: string + userId: string + uploadToken: string +}): UploadSessionRecord { + const upload = getOwnedUploadSession({ + uploadId: params.importId, + workspaceId: params.workspaceId, + userId: params.userId, + uploadToken: params.uploadToken, + }) + tableImportBodyFromUpload(upload) + return upload +} + +export async function abortTableImportUpload(params: { + importId: string + workspaceId: string + userId: string + uploadToken: string +}): Promise { + const upload = getOwnedTableImportUpload(params) + const body = tableImportBodyFromUpload(upload) + return resourceFromUpload(await abortUploadSession(upload), body) +} + +export async function getOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const record = await findOwnedTableImport(params) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +export async function findOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const [job] = await db + .select() + .from(tableJobs) + .where( + and( + eq(tableJobs.id, params.importId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'import') + ) + ) + .limit(1) + if (!job) return null + const payload = parseImportJobPayload(job.payload) + if (payload.userId !== params.userId) return null + return { + id: job.id, + workspaceId: job.workspaceId, + userId: payload.userId, + source: v2TableImportSourceSchema.parse(payload.source), + target: v2TableImportTargetSchema.parse(payload.target), + options: payload.options, + tableId: job.tableId, + status: tableImportStatus(job.status), + rowsProcessed: job.rowsProcessed, + error: job.error, + upload: null, + createdAt: job.startedAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + } +} + +export async function cancelTableImportResource( + record: TableImportResource +): Promise { + if (record.status === 'canceled') return record + if (record.status !== 'running' || !record.tableId) { + throw new OrchestrationError('conflict', `Table import is ${publicImportStatus(record.status)}`) + } + await markJobCanceled(record.tableId, record.id) + return getOwnedTableImport({ + importId: record.id, + workspaceId: record.workspaceId, + userId: record.userId, + }) +} + +export function toV2TableImport(record: TableImportResource): V2TableImport { + return { + id: record.id, + workspaceId: record.workspaceId, + status: publicImportStatus(record.status), + source: record.source, + target: record.target, + tableId: record.tableId, + rowsProcessed: record.rowsProcessed, + error: record.error, + upload: record.upload + ? { + uploadToken: record.upload.uploadToken, + partSize: record.upload.partSize, + partCount: record.upload.partCount, + expiresAt: record.upload.expiresAt.toISOString(), + } + : null, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +interface StartTableImportParams { + id: string + workspaceId: string + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] + fileKey: string + fileName: string + storageContext: 'workspace' | 'table-import' + deleteSourceFile: boolean +} + +async function startTableImport(params: StartTableImportParams): Promise { + const requestId = generateRequestId() + const jobPayload: TableImportJobPayload = { + kind: 'table_import', + userId: params.userId, + source: params.source, + target: params.target, + options: params.options, + } + let tableId: string | null = null + try { + if (params.target.type === 'new') { + const limits = await getWorkspaceTableLimits(params.workspaceId) + const table = await createTable( + { + name: params.target.name, + description: `Imported from ${params.fileName}`, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId: params.workspaceId, + folderId: params.target.folderId ?? null, + userId: params.userId, + maxTables: limits.maxTables, + jobStatus: 'running', + jobType: 'import', + jobId: params.id, + jobPayload, + }, + requestId + ) + tableId = table.id + } else { + const table = await requireExistingTarget(params.workspaceId, params.target) + tableId = table.id + if (!(await markTableJobRunning(tableId, params.id, 'import', jobPayload))) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + } + + const payload: TableImportPayload = { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + userId: params.userId, + fileKey: params.fileKey, + fileName: params.fileName, + delimiter: params.fileName.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: params.target.type === 'new' ? 'create' : params.target.mode, + mapping: params.options.mapping as TableImportPayload['mapping'], + createColumns: params.options.createColumns, + deleteSourceFile: params.deleteSourceFile, + storageContext: params.storageContext, + timezone: params.options.timezone ?? (await getUserSettings(params.userId)).timezone ?? 'UTC', + } + + if (isTriggerDevEnabled) { + 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:${params.id}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-import', () => runTableImport(payload)) + } + return getOwnedTableImport({ + importId: params.id, + workspaceId: params.workspaceId, + userId: params.userId, + }) + } catch (error) { + const message = getErrorMessage(error, 'Failed to dispatch table import') + if (tableId) await markJobFailed(tableId, params.id, message).catch(() => {}) + if (params.deleteSourceFile) { + const { deleteFile } = await import('@/lib/uploads/core/storage-service') + await deleteFile({ key: params.fileKey, context: params.storageContext }).catch(() => {}) + } + throw error + } +} + +function resourceFromUpload( + upload: UploadSessionRecord, + body: V2CreateTableImportBody +): TableImportResource { + return { + id: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + source: body.source, + target: body.target, + options: importOptions(body), + tableId: body.target.type === 'existing' ? body.target.tableId : null, + status: upload.status === 'aborted' ? 'canceled' : 'uploading', + rowsProcessed: 0, + error: null, + upload, + createdAt: upload.createdAt, + updatedAt: upload.updatedAt, + completedAt: upload.completedAt, + } +} + +function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { + if (upload.purpose !== 'table_import' || upload.storageContext !== 'table-import') { + throw new OrchestrationError('conflict', 'Upload is not a table import') + } + const body = v2CreateTableImportBodySchema.parse(upload.metadata.tableImport) + if (body.workspaceId !== upload.workspaceId || body.source.type !== 'upload') { + throw new OrchestrationError('conflict', 'Upload token table import metadata does not match') + } + return body +} + +function importOptions(body: V2CreateTableImportBody): TableImportJobPayload['options'] { + return { + ...(body.mapping ? { mapping: body.mapping } : {}), + ...(body.createColumns ? { createColumns: body.createColumns as string[] } : {}), + ...(body.timezone ? { timezone: body.timezone } : {}), + } +} + +function parseImportJobPayload(payload: unknown): TableImportJobPayload { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Table import job is missing its payload') + } + const candidate = payload as Partial + if ( + candidate.kind !== 'table_import' || + typeof candidate.userId !== 'string' || + !candidate.options || + typeof candidate.options !== 'object' + ) { + throw new Error('Table import job has an invalid payload') + } + v2TableImportSourceSchema.parse(candidate.source) + v2TableImportTargetSchema.parse(candidate.target) + return candidate as TableImportJobPayload +} + +async function validateTarget(workspaceId: string, target: V2TableImportTarget): Promise { + if (target.type === 'new') { + if (target.folderId && !(await findActiveFolder(target.folderId, workspaceId, 'table'))) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + return + } + await requireExistingTarget(workspaceId, target) +} + +async function requireExistingTarget( + workspaceId: string, + target: Extract +) { + const table = await getTableById(target.tableId) + if (!table || table.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + if (table.archivedAt) { + throw new OrchestrationError('validation', 'Cannot import into an archived table') + } + assertRowInsert(table) + if (target.mode === 'replace') assertRowDelete(table) + return table +} + +async function requireWorkspaceSource( + workspaceId: string, + fileId: string +): Promise { + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'Workspace file not found') + return file +} + +async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + throw new OrchestrationError('forbidden', 'Access denied') + } +} + +function assertCsvFileName(fileName: string): void { + const normalized = fileName.toLowerCase() + if (!normalized.endsWith('.csv') && !normalized.endsWith('.tsv')) { + throw new OrchestrationError('validation', 'Only CSV and TSV files are supported') + } +} + +function tableImportStatus(status: string): TableImportStatus { + if (status !== 'running' && status !== 'ready' && status !== 'failed' && status !== 'canceled') { + throw new Error(`Invalid table import job status: ${status}`) + } + return status +} + +function publicImportStatus(status: TableImportStatus): V2TableImport['status'] { + if (status === 'running') return 'processing' + if (status === 'ready') return 'completed' + return status +} 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/service.ts b/apps/sim/lib/table/service.ts index dd41b525317..3cfb06a5842 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -431,6 +431,7 @@ export async function createTable( workspaceId: data.workspaceId, type: initialJob.type, status: 'running', + payload: data.jobPayload ?? null, startedAt: initialJob.startedAt, updatedAt: initialJob.startedAt, }) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 40231c77900..fbe997dd29f 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -348,16 +348,31 @@ export interface TableUpdateJobPayload { maxRows?: number } +export type TableExportFormat = 'csv' | 'json' + /** * Persisted scope of an export job (`table_jobs.payload`). `resultKey` is merged in by the worker * 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. */ export interface TableExportJobPayload { - format: 'csv' | 'json' + format: TableExportFormat resultKey?: string } +/** Durable import descriptor stored on the existing `table_jobs` row. */ +export interface TableImportJobPayload { + kind: 'table_import' + userId: string + source: unknown + target: unknown + options: { + mapping?: unknown + createColumns?: string[] + timezone?: string + } +} + /** * Keyset cursor for paginating a table's default row order, `(order_key, id)`. The grid's * infinite scroll threads this instead of an OFFSET — offset paging re-scans every prior row per @@ -644,6 +659,8 @@ export interface CreateTableData { jobType?: TableJobType /** Async job id stamped on the table when `jobStatus` is set. */ jobId?: string + /** Type-specific payload stored on the initial async job. */ + jobPayload?: unknown } export interface InsertRowData { 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/apps/sim/lib/uploads/client/multipart-session.test.ts b/apps/sim/lib/uploads/client/multipart-session.test.ts new file mode 100644 index 00000000000..0f8b495a2b1 --- /dev/null +++ b/apps/sim/lib/uploads/client/multipart-session.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { V2CompletedPart } from '@/lib/api/contracts/v2/uploads' +import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' + +describe('uploadMultipartSession', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('requests fresh URL batches and completes with every uploaded part in order', async () => { + const file = new File(['abcdefghijklmnopqrstuvwxyz'], 'letters.txt') + const getPartUrls = vi.fn(async (partNumbers: number[]) => + partNumbers.map((partNumber) => ({ + partNumber, + url: `https://storage.example/part/${partNumber}`, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt: '2026-08-03T22:00:00.000Z', + })) + ) + const complete = vi.fn(async (parts: V2CompletedPart[]) => parts) + const abort = vi.fn(async () => {}) + const onProgress = vi.fn() + vi.stubGlobal( + 'fetch', + vi.fn( + async (_url: string) => new Response(null, { status: 200, headers: { etag: '"etag"' } }) + ) + ) + + const result = await uploadMultipartSession({ + file, + partSize: 1, + partCount: 26, + getPartUrls, + complete, + abort, + onProgress, + }) + + expect(getPartUrls).toHaveBeenCalledTimes(2) + expect(getPartUrls.mock.calls[0][0]).toEqual( + Array.from({ length: 25 }, (_, index) => index + 1) + ) + expect(getPartUrls.mock.calls[1][0]).toEqual([26]) + expect(result).toHaveLength(26) + expect(result[0]).toEqual({ partNumber: 1, etag: 'etag' }) + expect(result[25]).toEqual({ partNumber: 26, etag: 'etag' }) + expect(onProgress).toHaveBeenLastCalledWith({ loaded: 26, total: 26, percent: 100 }) + expect(abort).not.toHaveBeenCalled() + }) + + it('aborts the signed session when a part upload is aborted', async () => { + const file = new File(['part'], 'part.txt') + const complete = vi.fn() + const abort = vi.fn(async () => {}) + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new DOMException('The operation was aborted', 'AbortError') + }) + ) + + await expect( + uploadMultipartSession({ + file, + partSize: 4, + partCount: 1, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/part/1', + headers: {}, + expiresAt: '2026-08-03T22:00:00.000Z', + }, + ], + complete, + abort, + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(abort).toHaveBeenCalledTimes(1) + expect(complete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/client/multipart-session.ts b/apps/sim/lib/uploads/client/multipart-session.ts new file mode 100644 index 00000000000..2c3b1da05fd --- /dev/null +++ b/apps/sim/lib/uploads/client/multipart-session.ts @@ -0,0 +1,88 @@ +import { sleep } from '@sim/utils/helpers' +import type { V2CompletedPart, V2UploadPartUrl } from '@/lib/api/contracts/v2/uploads' +import { + MULTIPART_MAX_RETRIES, + MULTIPART_PART_CONCURRENCY, + MULTIPART_RETRY_BACKOFF, + MULTIPART_RETRY_DELAY_MS, + runWithConcurrency, + type UploadProgressEvent, +} from '@/lib/uploads/client/direct-upload' +import { isAbortError } from '@/lib/uploads/utils/file-utils' + +interface UploadMultipartSessionParams { + file: File + partSize: number + partCount: number + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void + getPartUrls: (partNumbers: number[]) => Promise + complete: (parts: V2CompletedPart[]) => Promise + abort: () => Promise +} + +export async function uploadMultipartSession( + params: UploadMultipartSessionParams +): Promise { + const { file, partSize, partCount, signal, onProgress } = params + const completedBytes = new Array(partCount).fill(0) + const completedParts: V2CompletedPart[] = [] + try { + for (let start = 1; start <= partCount; start += 25) { + const partNumbers = Array.from( + { length: Math.min(25, partCount - start + 1) }, + (_, index) => start + index + ) + const partUrls = await params.getPartUrls(partNumbers) + const results = await runWithConcurrency( + partUrls, + MULTIPART_PART_CONCURRENCY, + async (part): Promise => { + const partStart = (part.partNumber - 1) * partSize + const end = Math.min(partStart + partSize, file.size) + const chunk = file.slice(partStart, end) + for (let attempt = 0; attempt <= MULTIPART_MAX_RETRIES; attempt++) { + try { + // boundary-raw-fetch: signed multipart data-plane URL may target cloud storage or local Sim + const response = await fetch(part.url, { + method: 'PUT', + body: chunk, + headers: part.headers, + signal, + }) + if (!response.ok) { + throw new Error(`Part ${part.partNumber} failed (${response.status})`) + } + completedBytes[part.partNumber - 1] = end - partStart + const loaded = completedBytes.reduce((sum, bytes) => sum + bytes, 0) + onProgress?.({ + loaded, + total: file.size, + percent: Math.min(100, Math.round((loaded / file.size) * 100)), + }) + const etag = response.headers.get('etag') + return { + partNumber: part.partNumber, + ...(etag ? { etag: etag.replaceAll('"', '') } : {}), + } + } catch (error) { + if (isAbortError(error) || attempt >= MULTIPART_MAX_RETRIES) throw error + await sleep(MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt) + } + } + throw new Error(`Retries exhausted for part ${part.partNumber}`) + } + ) + completedParts.push( + ...results.map((result) => { + if (result.status === 'rejected') throw result.reason + return result.value + }) + ) + } + return await params.complete(completedParts) + } catch (error) { + await params.abort().catch(() => {}) + throw error + } +} diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts new file mode 100644 index 00000000000..eecc4237419 --- /dev/null +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -0,0 +1,68 @@ +import { requestJson } from '@/lib/api/client/request' +import { + abortWorkspaceFileUploadContract, + completeWorkspaceFileUploadContract, + createWorkspaceFileUploadContract, + createWorkspaceFileUploadPartUrlsContract, +} from '@/lib/api/contracts/upload-sessions' +import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' +import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' +import { getFileContentType } from '@/lib/uploads/utils/file-utils' + +interface UploadWorkspaceFileSessionParams { + workspaceId: string + folderId?: string | null + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + +export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSessionParams) { + const { workspaceId, folderId, file, signal, onProgress } = params + const created = await requestJson(createWorkspaceFileUploadContract, { + body: { + workspaceId, + name: file.name, + contentType: getFileContentType(file), + size: file.size, + ...(folderId ? { folderId } : {}), + }, + signal, + }) + const upload = created.data + return uploadMultipartSession({ + file, + partSize: upload.partSize, + partCount: upload.partCount, + signal, + onProgress, + getPartUrls: async (partNumbers) => { + const batch = await requestJson(createWorkspaceFileUploadPartUrlsContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + signal, + }) + return batch.data.parts + }, + complete: async (parts) => { + const completed = await requestJson(completeWorkspaceFileUploadContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + signal, + }) + if (!completed.data.file) throw new Error('Completed upload returned no workspace file') + return completed.data.file + }, + abort: async () => { + await requestJson(abortWorkspaceFileUploadContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + }, + }) +} diff --git a/apps/sim/lib/uploads/config.ts b/apps/sim/lib/uploads/config.ts index 10cb9a2eff7..55cf13d7803 100644 --- a/apps/sim/lib/uploads/config.ts +++ b/apps/sim/lib/uploads/config.ts @@ -226,6 +226,7 @@ function getS3Config(context: StorageContext): StorageConfig { } case 'mothership': case 'workspace': + case 'table-import': return { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region, @@ -288,6 +289,7 @@ function getBlobConfig(context: StorageContext): StorageConfig { } case 'mothership': case 'workspace': + case 'table-import': return { accountName: BLOB_CONFIG.accountName, accountKey: BLOB_CONFIG.accountKey, @@ -347,6 +349,7 @@ function getGcsConfig(context: StorageContext): StorageConfig { return { bucket: GCS_EXECUTION_FILES_CONFIG.bucket || GCS_CONFIG.bucket } case 'mothership': case 'workspace': + case 'table-import': return { bucket: GCS_CONFIG.bucket } case 'profile-pictures': return { bucket: GCS_PROFILE_PICTURES_CONFIG.bucket || GCS_CONFIG.bucket } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 6e141853a9a..368bf422e90 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,7 +9,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, type SQL } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' @@ -43,11 +43,10 @@ import { getServePathPrefix } from '@/lib/uploads' import { deleteFile, downloadFile, - hasCloudStorage, headObject, uploadFile, } from '@/lib/uploads/core/storage-service' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { MAX_WORKSPACE_FILE_SIZE, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { isUuid, sanitizeFileName } from '@/executor/constants' @@ -175,6 +174,10 @@ interface WorkspaceFileMetadataInsert { size: number } +function workspaceFileSize(file: typeof workspaceFiles.$inferSelect): number { + return file.sizeBytes ?? file.size +} + /** * Attempts one active workspace-file insert and reports the row that this call * created. Conflict losers receive `undefined` and must inspect the active key @@ -188,6 +191,8 @@ async function insertWorkspaceFileMetadataInTx( .insert(workspaceFiles) .values({ ...metadata, + size: toLegacyWorkspaceFileSize(metadata.size), + sizeBytes: metadata.size, context: 'workspace', displayName: metadata.originalName, deletedAt: null, @@ -267,7 +272,7 @@ function isSameWorkspaceFileRegistration( file.folderId === params.folderId && file.context === 'workspace' && file.contentType === params.contentType && - file.size === params.size && + workspaceFileSize(file) === params.size && file.deletedAt === null ) } @@ -489,10 +494,6 @@ export async function registerUploadedWorkspaceFile(params: { const { workspaceId, userId, key, originalName, contentType } = params const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File') - if (!hasCloudStorage()) { - throw new Error('Direct-upload registration requires cloud storage') - } - if (parseWorkspaceFileKey(key) !== workspaceId) { throw new Error('Storage key does not belong to this workspace') } @@ -528,7 +529,7 @@ export async function registerUploadedWorkspaceFile(params: { file: { id: existing.id, name: existing.originalName, - size: existing.size, + size: workspaceFileSize(existing), type: existing.contentType, url: `${pathPrefix}${encodeURIComponent(existing.key)}?context=workspace`, key: existing.key, @@ -591,7 +592,7 @@ export async function registerUploadedWorkspaceFile(params: { file: { id: finalized.file.id, name: finalized.file.originalName, - size: finalized.file.size, + size: workspaceFileSize(finalized.file), type: finalized.file.contentType, url: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`, key: finalized.file.key, @@ -730,7 +731,7 @@ function mapWorkspaceFileRecord( name: file.originalName, key: file.key, path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`, - size: file.size, + size: workspaceFileSize(file), type: file.contentType, uploadedBy: file.userId, folderId: file.folderId, @@ -853,7 +854,13 @@ const fileId = textKey(workspaceFiles.id, (row) => row.id) const WORKSPACE_FILE_SORTS = { name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId], - size: [numberKey(workspaceFiles.size, (row) => row.size), fileId], + size: [ + numberKey( + sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(Number), + (row) => row.size + ), + fileId, + ], uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId], updatedAt: [timestampKey(workspaceFiles.updatedAt, (row) => row.updatedAt), fileId], } satisfies Record[]> @@ -1256,7 +1263,7 @@ export async function updateWorkspaceFileContent( throw new ContentVersionConflictError(fileId) } - const sizeDiff = content.length - currentFile.size + const sizeDiff = content.length - workspaceFileSize(currentFile) const now = new Date() // `contentUpdatedAt` is the persist If-Match token, so it MUST be strictly monotonic per file — a // bare `new Date()` is not: cross-instance clock skew can stamp a later write with an earlier time, @@ -1271,7 +1278,8 @@ export async function updateWorkspaceFileContent( .update(workspaceFiles) .set({ key: uploadResult.key, - size: content.length, + size: toLegacyWorkspaceFileSize(content.length), + sizeBytes: content.length, contentType: nextContentType, updatedAt: now, contentUpdatedAt, @@ -1357,7 +1365,7 @@ export async function updateWorkspaceFileContent( name: finalized.file.originalName, key: finalized.file.key, path: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`, - size: finalized.file.size, + size: workspaceFileSize(finalized.file), type: finalized.file.contentType, uploadedBy: finalized.file.userId, folderId: finalized.file.folderId, diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 0a49d320e02..619116c72ae 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -34,7 +34,7 @@ const logger = createLogger('StorageService') * Create a Blob config from StorageConfig * @throws Error if required properties are missing */ -function createBlobConfig(config: StorageConfig): BlobConfig { +export function createBlobConfig(config: StorageConfig): BlobConfig { if (!config.containerName) { throw new Error('Blob configuration missing required property: containerName') } @@ -57,7 +57,7 @@ function createBlobConfig(config: StorageConfig): BlobConfig { * Create an S3 config from StorageConfig * @throws Error if required properties are missing */ -function createS3Config(config: StorageConfig): S3Config { +export function createS3Config(config: StorageConfig): S3Config { if (!config.bucket || !config.region) { throw new Error('S3 configuration missing required properties: bucket and region') } @@ -72,7 +72,7 @@ function createS3Config(config: StorageConfig): S3Config { * Create a GCS config from StorageConfig * @throws Error if required properties are missing */ -function createGcsConfig(config: StorageConfig): GcsConfig { +export function createGcsConfig(config: StorageConfig): GcsConfig { if (!config.bucket) { throw new Error('GCS configuration missing required property: bucket') } @@ -634,7 +634,17 @@ export async function headObject( return headGcsObject(key, createGcsConfig(config)) } - return null + const { stat } = await import('fs/promises') + const { join } = await import('path') + const { UPLOAD_DIR_SERVER } = await import('./setup.server') + try { + const file = await stat(join(UPLOAD_DIR_SERVER, sanitizeFileKey(key))) + return { size: file.size } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return null + throw error + } } /** diff --git a/apps/sim/lib/uploads/core/upload-token.test.ts b/apps/sim/lib/uploads/core/upload-token.test.ts new file mode 100644 index 00000000000..532985f5411 --- /dev/null +++ b/apps/sim/lib/uploads/core/upload-token.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' + +describe('upload token', () => { + it('round-trips stateless multipart session state', () => { + const token = signUploadToken({ + uploadId: 'upload-1', + key: 'workspace-1/file.csv', + userId: 'user-1', + workspaceId: 'workspace-1', + context: 'workspace', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + purpose: 'workspace_file', + provider: 's3', + providerUploadId: 'provider-upload-1', + partSize: 8, + partCount: 2, + metadata: { folderId: 'folder-1' }, + createdAt: '2026-08-03T20:00:00.000Z', + expiresAt: '2026-08-04T20:00:00.000Z', + }) + + expect(verifyUploadToken(token)).toEqual({ + valid: true, + payload: { + uploadId: 'upload-1', + key: 'workspace-1/file.csv', + userId: 'user-1', + workspaceId: 'workspace-1', + context: 'workspace', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + purpose: 'workspace_file', + provider: 's3', + providerUploadId: 'provider-upload-1', + partSize: 8, + partCount: 2, + metadata: { folderId: 'folder-1' }, + createdAt: '2026-08-03T20:00:00.000Z', + expiresAt: '2026-08-04T20:00:00.000Z', + }, + }) + }) + + it('rejects a modified token', () => { + const token = signUploadToken({ + uploadId: 'upload-1', + key: 'workspace-1/file.csv', + userId: 'user-1', + workspaceId: 'workspace-1', + context: 'workspace', + }) + const [payload, signature] = token.split('.') + + expect(verifyUploadToken(`${payload}x.${signature}`)).toEqual({ valid: false }) + }) +}) diff --git a/apps/sim/lib/uploads/core/upload-token.ts b/apps/sim/lib/uploads/core/upload-token.ts index 21a7b199a9a..d030655b58b 100644 --- a/apps/sim/lib/uploads/core/upload-token.ts +++ b/apps/sim/lib/uploads/core/upload-token.ts @@ -15,6 +15,21 @@ export interface UploadTokenPayload { contentType?: string /** File size in bytes, carried for ownership metadata at completion. */ fileSize?: number + /** Multipart-session purpose. Omitted by the legacy multipart endpoint. */ + purpose?: 'workspace_file' | 'table_import' + /** Storage provider that owns the multipart upload state. */ + provider?: 's3' | 'blob' | 'gcs' | 'local' + /** Provider-issued multipart upload id. Local and block-blob uploads do not need one. */ + providerUploadId?: string | null + /** Fixed byte size of every part except the final part. */ + partSize?: number + /** Exact number of parts the client must complete. */ + partCount?: number + /** Signed purpose-specific data needed during finalization. */ + metadata?: Record + /** ISO timestamps used to reconstruct the stateless session response. */ + createdAt?: string + expiresAt?: string } interface SignedPayload extends UploadTokenPayload { @@ -91,6 +106,25 @@ export function verifyUploadToken(token: string): UploadTokenVerification { ...(typeof parsed.fileName === 'string' ? { fileName: parsed.fileName } : {}), ...(typeof parsed.contentType === 'string' ? { contentType: parsed.contentType } : {}), ...(typeof parsed.fileSize === 'number' ? { fileSize: parsed.fileSize } : {}), + ...(parsed.purpose === 'workspace_file' || parsed.purpose === 'table_import' + ? { purpose: parsed.purpose } + : {}), + ...(parsed.provider === 's3' || + parsed.provider === 'blob' || + parsed.provider === 'gcs' || + parsed.provider === 'local' + ? { provider: parsed.provider } + : {}), + ...(typeof parsed.providerUploadId === 'string' || parsed.providerUploadId === null + ? { providerUploadId: parsed.providerUploadId } + : {}), + ...(typeof parsed.partSize === 'number' ? { partSize: parsed.partSize } : {}), + ...(typeof parsed.partCount === 'number' ? { partCount: parsed.partCount } : {}), + ...(parsed.metadata && typeof parsed.metadata === 'object' && !Array.isArray(parsed.metadata) + ? { metadata: parsed.metadata } + : {}), + ...(typeof parsed.createdAt === 'string' ? { createdAt: parsed.createdAt } : {}), + ...(typeof parsed.expiresAt === 'string' ? { expiresAt: parsed.expiresAt } : {}), }, } } diff --git a/apps/sim/lib/uploads/multipart-session/provider.ts b/apps/sim/lib/uploads/multipart-session/provider.ts new file mode 100644 index 00000000000..64e258cc879 --- /dev/null +++ b/apps/sim/lib/uploads/multipart-session/provider.ts @@ -0,0 +1,319 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { mkdir, rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { + getStorageConfig, + USE_BLOB_STORAGE, + USE_GCS_STORAGE, + USE_S3_STORAGE, +} from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { + createBlobConfig, + createGcsConfig, + createS3Config, +} from '@/lib/uploads/core/storage-service' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' + +export type MultipartStorageProvider = 's3' | 'blob' | 'gcs' | 'local' + +export interface CompletedUploadPart { + partNumber: number + etag?: string +} + +export interface MultipartPartUrl { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +export function multipartStorageProvider(): MultipartStorageProvider { + if (USE_BLOB_STORAGE) return 'blob' + if (USE_S3_STORAGE) return 's3' + if (USE_GCS_STORAGE) return 'gcs' + return 'local' +} + +export async function initiateMultipartProviderUpload(params: { + key: string + fileName: string + contentType: string + fileSize: number + context: StorageContext + localUploadId: string +}): Promise<{ provider: MultipartStorageProvider; providerUploadId: string | null }> { + const { key, fileName, contentType, fileSize, context, localUploadId } = params + const provider = multipartStorageProvider() + const config = getStorageConfig(context) + + if (provider === 's3') { + const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + const result = await initiateS3MultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: createS3Config(config), + customKey: key, + purpose: context, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'blob') { + const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + const result = await initiateMultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: createBlobConfig(config), + customKey: key, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'gcs') { + const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + const result = await initiateGcsMultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: createGcsConfig(config), + customKey: key, + purpose: context, + }) + return { provider, providerUploadId: result.uploadId } + } + + await mkdir(localPartsDirectory(localUploadId), { recursive: true }) + return { provider, providerUploadId: null } +} + +export async function getMultipartProviderPartUrls(params: { + provider: MultipartStorageProvider + providerUploadId: string | null + key: string + context: StorageContext + partNumbers: number[] + localUrl: (partNumber: number) => string +}): Promise { + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const { provider, providerUploadId, key, context, partNumbers } = params + if (provider === 'local') { + return partNumbers.map((partNumber) => ({ + partNumber, + url: params.localUrl(partNumber), + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) + const config = getStorageConfig(context) + + if (provider === 's3') { + const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') + const urls = await getS3MultipartPartUrls( + key, + providerUploadId, + partNumbers, + createS3Config(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (provider === 'blob') { + const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') + const urls = await getMultipartPartUrls(key, partNumbers, createBlobConfig(config)) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') + const urls = await getGcsMultipartPartUrls( + key, + providerUploadId, + partNumbers, + createGcsConfig(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) +} + +export async function completeMultipartProviderUpload(params: { + provider: MultipartStorageProvider + providerUploadId: string | null + uploadId: string + key: string + contentType: string + context: StorageContext + parts: CompletedUploadPart[] +}): Promise { + const { provider, providerUploadId, uploadId, key, contentType, context, parts } = params + if (provider === 'local') { + await assembleLocalParts(uploadId, key, parts) + return + } + if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) + const config = getStorageConfig(context) + if (provider === 's3') { + const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await completeS3MultipartUpload( + key, + providerUploadId, + parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag(provider, part), + })), + createS3Config(config) + ) + return + } + if (provider === 'blob') { + const { completeMultipartUpload, deriveBlobBlockId } = await import( + '@/lib/uploads/providers/blob/client' + ) + await completeMultipartUpload( + key, + parts.map((part) => ({ + partNumber: part.partNumber, + blockId: deriveBlobBlockId(part.partNumber), + })), + createBlobConfig(config), + contentType + ) + return + } + const { completeGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await completeGcsMultipartUpload( + key, + providerUploadId, + parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag(provider, part), + })), + createGcsConfig(config) + ) +} + +export async function abortMultipartProviderUpload(params: { + provider: MultipartStorageProvider + providerUploadId: string | null + uploadId: string + key: string + context: StorageContext +}): Promise { + const { provider, providerUploadId, uploadId, key, context } = params + if (provider === 'local') { + await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) + const destination = join(UPLOAD_DIR_SERVER, sanitizeFileKey(key)) + await rm(`${destination}.uploading-${uploadId}`, { force: true }) + return + } + if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) + const config = getStorageConfig(context) + if (provider === 's3') { + const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await abortS3MultipartUpload(key, providerUploadId, createS3Config(config)) + return + } + if (provider === 'blob') { + const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + await abortMultipartUpload(key, createBlobConfig(config)) + return + } + const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await abortGcsMultipartUpload(key, providerUploadId, createGcsConfig(config)) +} + +export async function writeLocalMultipartPart(params: { + uploadId: string + partNumber: number + body: ReadableStream + expectedSize: number +}): Promise { + const { Readable, Transform } = await import('node:stream') + const directory = localPartsDirectory(params.uploadId) + await mkdir(directory, { recursive: true }) + const destination = localPartPath(params.uploadId, params.partNumber) + let bytes = 0 + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > params.expectedSize) { + callback(new Error(`Part ${params.partNumber} exceeds ${params.expectedSize} bytes`)) + return + } + callback(null, chunk) + }, + }) + try { + await pipeline( + Readable.fromWeb(params.body as Parameters[0]), + counter, + createWriteStream(destination, { flags: 'w' }) + ) + if (bytes !== params.expectedSize) { + throw new Error( + `Part ${params.partNumber} has ${bytes} bytes; expected ${params.expectedSize}` + ) + } + } catch (error) { + await rm(destination, { force: true }).catch(() => {}) + throw new Error(getErrorMessage(error, `Failed to store part ${params.partNumber}`), { + cause: error, + }) + } +} + +function localPartsDirectory(uploadId: string): string { + return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) +} + +function localPartPath(uploadId: string, partNumber: number): string { + return join(localPartsDirectory(uploadId), `${partNumber}.part`) +} + +async function assembleLocalParts( + uploadId: string, + key: string, + parts: CompletedUploadPart[] +): Promise { + const safeKey = sanitizeFileKey(key) + const destination = join(UPLOAD_DIR_SERVER, safeKey) + const temporary = `${destination}.uploading-${uploadId}` + await mkdir(dirname(destination), { recursive: true }) + await rm(temporary, { force: true }) + try { + for (const part of parts) { + await pipeline( + createReadStream(localPartPath(uploadId, part.partNumber)), + createWriteStream(temporary, { flags: 'a' }) + ) + } + const assembled = await stat(temporary) + if (assembled.size === 0) throw new Error('Assembled upload is empty') + await rename(temporary, destination) + await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + throw error + } +} + +function requiredEtag(provider: 's3' | 'gcs', part: CompletedUploadPart): string { + if (!part.etag) throw new Error(`Missing etag for ${provider} part ${part.partNumber}`) + return part.etag +} diff --git a/apps/sim/lib/uploads/multipart-session/service.ts b/apps/sim/lib/uploads/multipart-session/service.ts new file mode 100644 index 00000000000..fba4eb353ef --- /dev/null +++ b/apps/sim/lib/uploads/multipart-session/service.ts @@ -0,0 +1,386 @@ +import { generateId } from '@sim/utils/id' +import { + checkStorageQuotaForBillingContext, + resolveStorageBillingContext, +} from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' +import { headObject } from '@/lib/uploads/core/storage-service' +import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' +import { + abortMultipartProviderUpload, + type CompletedUploadPart, + completeMultipartProviderUpload, + getMultipartProviderPartUrls, + initiateMultipartProviderUpload, + type MultipartPartUrl, + type MultipartStorageProvider, +} from '@/lib/uploads/multipart-session/provider' +import { MAX_WORKSPACE_FILE_SIZE, type StorageContext } from '@/lib/uploads/shared/types' +import { sanitizeFileName } from '@/executor/constants' + +export const MULTIPART_SESSION_PART_SIZE = 8 * 1024 * 1024 +export const MULTIPART_SESSION_MAX_PART_URLS = 100 +export const MULTIPART_SESSION_TTL_MS = 24 * 60 * 60 * 1000 + +export type UploadSessionPurpose = 'workspace_file' | 'table_import' +export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' + +export interface UploadSessionRecord { + id: string + workspaceId: string + userId: string + purpose: UploadSessionPurpose + storageContext: StorageContext + storageKey: string + storageProvider: MultipartStorageProvider + providerUploadId: string | null + fileName: string + contentType: string + fileSize: number + partSize: number + partCount: number + status: UploadSessionStatus + metadata: Record + uploadToken: string + createdAt: Date + expiresAt: Date + completedFileId: string | null + error: string | null + completedAt: Date | null + updatedAt: Date +} + +export class UploadSessionError extends OrchestrationError { + constructor( + code: 'validation' | 'not_found' | 'forbidden' | 'conflict' | 'payload_too_large' | 'internal', + message: string + ) { + super(code, message) + this.name = 'UploadSessionError' + } +} + +interface CreateUploadSessionParams { + id?: string + workspaceId: string + userId: string + purpose: UploadSessionPurpose + fileName: string + contentType: string + fileSize: number + metadata?: Record +} + +export async function createUploadSession( + params: CreateUploadSessionParams +): Promise { + validateFileSize(params.fileSize) + const id = params.id ?? generateId() + const storageContext: StorageContext = + params.purpose === 'workspace_file' ? 'workspace' : 'table-import' + const storageKey = + params.purpose === 'workspace_file' + ? generateWorkspaceFileKey(params.workspaceId, params.fileName) + : `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}` + const partCount = Math.ceil(params.fileSize / MULTIPART_SESSION_PART_SIZE) + + if (params.purpose === 'workspace_file') { + const billingContext = await resolveStorageBillingContext(params.workspaceId) + const quota = await checkStorageQuotaForBillingContext(billingContext, params.fileSize) + if (!quota.allowed) { + throw new UploadSessionError('payload_too_large', quota.error ?? 'Storage limit exceeded') + } + } + + const initiated = await initiateMultipartProviderUpload({ + key: storageKey, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + localUploadId: id, + }) + const createdAt = new Date() + const expiresAt = new Date(createdAt.getTime() + MULTIPART_SESSION_TTL_MS) + const metadata = params.metadata ?? {} + const uploadToken = signUploadToken( + { + uploadId: id, + key: storageKey, + userId: params.userId, + workspaceId: params.workspaceId, + context: storageContext, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + purpose: params.purpose, + provider: initiated.provider, + providerUploadId: initiated.providerUploadId, + partSize: MULTIPART_SESSION_PART_SIZE, + partCount, + metadata, + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + }, + MULTIPART_SESSION_TTL_MS / 1000 + ) + + return { + id, + workspaceId: params.workspaceId, + userId: params.userId, + purpose: params.purpose, + storageContext, + storageKey, + storageProvider: initiated.provider, + providerUploadId: initiated.providerUploadId, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + partSize: MULTIPART_SESSION_PART_SIZE, + partCount, + status: 'uploading', + metadata, + uploadToken, + createdAt, + expiresAt, + completedFileId: null, + error: null, + completedAt: null, + updatedAt: createdAt, + } +} + +export function getOwnedUploadSession(params: { + uploadId: string + workspaceId: string + userId?: string + uploadToken: string +}): UploadSessionRecord { + const session = verifyUploadSessionToken(params.uploadToken) + if (session.id !== params.uploadId || session.workspaceId !== params.workspaceId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + if (params.userId && session.userId !== params.userId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + return session +} + +export function verifyUploadSessionToken(uploadToken: string): UploadSessionRecord { + const verified = verifyUploadToken(uploadToken) + if (!verified.valid) throw new UploadSessionError('forbidden', 'Invalid or expired upload token') + const payload = verified.payload + if ( + !payload.fileName || + !payload.contentType || + typeof payload.fileSize !== 'number' || + !Number.isSafeInteger(payload.fileSize) || + !payload.purpose || + !payload.provider || + typeof payload.partSize !== 'number' || + !Number.isSafeInteger(payload.partSize) || + typeof payload.partCount !== 'number' || + !Number.isSafeInteger(payload.partCount) || + !payload.createdAt || + !payload.expiresAt + ) { + throw new UploadSessionError('forbidden', 'Upload token is not a multipart session token') + } + if (payload.context !== 'workspace' && payload.context !== 'table-import') { + throw new UploadSessionError('forbidden', 'Upload token has an invalid storage context') + } + const createdAt = new Date(payload.createdAt) + const expiresAt = new Date(payload.expiresAt) + if (!Number.isFinite(createdAt.getTime()) || !Number.isFinite(expiresAt.getTime())) { + throw new UploadSessionError('forbidden', 'Upload token has invalid timestamps') + } + const now = new Date() + return { + id: payload.uploadId, + workspaceId: payload.workspaceId, + userId: payload.userId, + purpose: payload.purpose, + storageContext: payload.context, + storageKey: payload.key, + storageProvider: payload.provider, + providerUploadId: payload.providerUploadId ?? null, + fileName: payload.fileName, + contentType: payload.contentType, + fileSize: payload.fileSize, + partSize: payload.partSize, + partCount: payload.partCount, + status: 'uploading', + metadata: payload.metadata ?? {}, + uploadToken, + createdAt, + expiresAt, + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + } +} + +export async function createUploadPartUrls(params: { + session: UploadSessionRecord + partNumbers: number[] + localOrigin: string +}): Promise { + assertUploadable(params.session) + const unique = new Set(params.partNumbers) + if (unique.size !== params.partNumbers.length) { + throw new UploadSessionError('validation', 'partNumbers must not contain duplicates') + } + if ( + params.partNumbers.length === 0 || + params.partNumbers.length > MULTIPART_SESSION_MAX_PART_URLS + ) { + throw new UploadSessionError( + 'validation', + `partNumbers must contain between 1 and ${MULTIPART_SESSION_MAX_PART_URLS} entries` + ) + } + for (const partNumber of params.partNumbers) { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > params.session.partCount) { + throw new UploadSessionError( + 'validation', + `partNumber must be between 1 and ${params.session.partCount}` + ) + } + } + + return getMultipartProviderPartUrls({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + key: params.session.storageKey, + context: params.session.storageContext, + partNumbers: params.partNumbers, + localUrl: (partNumber) => + `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(params.session.uploadToken)}`, + }) +} + +export async function completeUploadSession(params: { + session: UploadSessionRecord + parts: CompletedUploadPart[] + finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> +}): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { + assertUploadable(params.session) + validateCompletedParts(params.session, params.parts) + + const existingObject = await headObject(params.session.storageKey, params.session.storageContext) + const alreadyCompleted = existingObject?.size === params.session.fileSize + if (existingObject && !alreadyCompleted) { + throw new UploadSessionError( + 'conflict', + `Upload object has ${existingObject.size} bytes; expected ${params.session.fileSize}` + ) + } + if (!alreadyCompleted) { + await completeMultipartProviderUpload({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + uploadId: params.session.id, + key: params.session.storageKey, + contentType: params.session.contentType, + context: params.session.storageContext, + parts: params.parts, + }) + } + + const head = await headObject(params.session.storageKey, params.session.storageContext) + if (!head) throw new Error('Completed upload object not found') + if (head.size !== params.session.fileSize) { + throw new UploadSessionError( + 'validation', + `Uploaded object has ${head.size} bytes; expected ${params.session.fileSize}` + ) + } + + const finalized = await params.finalize(params.session) + const completedAt = new Date() + return { + session: { + ...params.session, + status: 'completed', + completedFileId: finalized.completedFileId ?? null, + completedAt, + updatedAt: completedAt, + }, + value: finalized.value, + alreadyCompleted, + } +} + +export async function abortUploadSession( + session: UploadSessionRecord +): Promise { + assertUploadable(session) + await abortMultipartProviderUpload({ + provider: session.storageProvider, + providerUploadId: session.providerUploadId, + uploadId: session.id, + key: session.storageKey, + context: session.storageContext, + }) + const completedAt = new Date() + return { ...session, status: 'aborted', completedAt, updatedAt: completedAt } +} + +export function expectedUploadPartSize(session: UploadSessionRecord, partNumber: number): number { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > session.partCount) { + throw new UploadSessionError('validation', 'Invalid upload part number') + } + if (partNumber < session.partCount) return session.partSize + return session.fileSize - session.partSize * (session.partCount - 1) +} + +function assertUploadable(session: UploadSessionRecord): void { + if (session.status !== 'uploading') { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + if (session.expiresAt.getTime() <= Date.now()) { + throw new UploadSessionError('conflict', 'Upload session has expired') + } +} + +function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { + if (parts.length !== session.partCount) { + throw new UploadSessionError( + 'validation', + `Expected ${session.partCount} completed parts; received ${parts.length}` + ) + } + const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) + for (let index = 0; index < sorted.length; index++) { + if (sorted[index].partNumber !== index + 1) { + throw new UploadSessionError( + 'validation', + 'Completed parts must contain every part exactly once' + ) + } + if ( + (session.storageProvider === 's3' || session.storageProvider === 'gcs') && + !sorted[index].etag + ) { + throw new UploadSessionError( + 'validation', + `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` + ) + } + } +} + +function validateFileSize(fileSize: number): void { + if (!Number.isSafeInteger(fileSize) || fileSize < 1) { + throw new UploadSessionError('validation', 'fileSize must be a positive integer') + } + if (fileSize > MAX_WORKSPACE_FILE_SIZE) { + throw new UploadSessionError( + 'validation', + `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` + ) + } +} diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index eda490a8b82..076933db181 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -691,7 +691,8 @@ export async function commitBlobBlockList( export async function completeMultipartUpload( key: string, parts: AzureMultipartPart[], - customConfig?: BlobConfig + customConfig?: BlobConfig, + contentType?: string ): Promise<{ location: string; path: string; key: string }> { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType @@ -726,6 +727,7 @@ export async function completeMultipartUpload( .map((part) => part.blockId) await blockBlobClient.commitBlockList(sortedBlockIds, { + ...(contentType ? { blobHTTPHeaders: { blobContentType: contentType } } : {}), metadata: { multipartUpload: 'completed', uploadCompletedAt: new Date().toISOString(), diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index 4a3faa58366..94b8317cdc9 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -3,7 +3,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' -import type { StorageContext } from '../shared/types' +import { type StorageContext, toLegacyWorkspaceFileSize } from '../shared/types' const logger = createLogger('FileMetadata') @@ -49,7 +49,8 @@ export async function insertFileMetadata( originalName, displayName: originalName, contentType, - size, + size: toLegacyWorkspaceFileSize(size), + sizeBytes: size, deletedAt: null, uploadedAt: new Date(), }) @@ -86,7 +87,8 @@ export async function insertFileMetadata( originalName, displayName: originalName, contentType, - size, + size: toLegacyWorkspaceFileSize(size), + sizeBytes: size, deletedAt: null, uploadedAt: new Date(), }) @@ -142,7 +144,8 @@ export async function insertFileMetadataMany( originalName: row.originalName, displayName: row.originalName, contentType: row.contentType, - size: row.size, + size: toLegacyWorkspaceFileSize(row.size), + sizeBytes: row.size, deletedAt: null, uploadedAt: new Date(), })) diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index 65f2570ecaf..0c24f770444 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -5,6 +5,17 @@ */ export const MAX_WORKSPACE_FILE_SIZE = 5 * 1024 * 1024 * 1024 +const MAX_POSTGRES_INTEGER = 2_147_483_647 + +/** + * Keeps the legacy int4 metadata projection writable while `size_bytes` stores the exact value. + */ +export function toLegacyWorkspaceFileSize(size: number): number { + if (!Number.isSafeInteger(size) || size < 0) + throw new Error(`Invalid workspace file size: ${size}`) + return Math.min(size, MAX_POSTGRES_INTEGER) +} + /** * Cap on the legacy FormData upload route, which buffers the whole file in * worker memory. Direct-to-storage uploads use {@link MAX_WORKSPACE_FILE_SIZE}. @@ -18,6 +29,7 @@ export type StorageContext = | 'mothership' | 'execution' | 'workspace' + | 'table-import' | 'profile-pictures' | 'og-images' | 'logs' diff --git a/apps/sim/stores/table/import-tray/store.ts b/apps/sim/stores/table/import-tray/store.ts index 174485acb61..9b547b5f6f4 100644 --- a/apps/sim/stores/table/import-tray/store.ts +++ b/apps/sim/stores/table/import-tray/store.ts @@ -2,12 +2,12 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' /** - * An in-flight client upload, shown optimistically before its server import row exists or the - * table list has refreshed. Keyed by `uploadId`: a `pending_*` id (creating a new table, no row - * yet) or the target tableId (append/replace into an existing table). + * An in-flight client upload, shown after its signed upload session is created but before the + * table list has refreshed. `uploadId` is the import id across upload and processing. */ export interface ImportUpload { uploadId: string + tableId?: string workspaceId: string title: string /** Byte-based upload percent from the client XHR. */ diff --git a/packages/db/migrations/0280_smart_la_nuit.sql b/packages/db/migrations/0280_smart_la_nuit.sql new file mode 100644 index 00000000000..c4ee77d7e2a --- /dev/null +++ b/packages/db/migrations/0280_smart_la_nuit.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_files" ADD COLUMN "size_bytes" bigint; \ No newline at end of file diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json new file mode 100644 index 00000000000..2dee9eb794e --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18376 @@ +{ + "id": "006931de-2a8e-418b-91ea-92f853773e45", + "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30be907c184..43444b15f85 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,13 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785800321549, + "tag": "0280_smart_la_nuit", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..75dc5b04d60 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1910,7 +1910,10 @@ export const workspaceFiles = pgTable( */ displayName: text('display_name'), contentType: text('content_type').notNull(), + // contract-pending(after #6188 is fully deployed and sizeBytes is backfilled): drop size — new code dual-writes and reads sizeBytes first size: integer('size').notNull(), + /** Exact byte size for files above PostgreSQL's int4 ceiling; legacy rows fall back to `size`. */ + sizeBytes: bigint('size_bytes', { mode: 'number' }), deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index f8a7a617938..712093e3aea 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: 1048, - zodRoutes: 1048, + totalRoutes: 1079, + zodRoutes: 1079, nonZodRoutes: 0, } as const