From e1513f5ad2b0b70c50bdc043c917e16cd7fa4b0e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 20:53:47 -0700 Subject: [PATCH 01/13] feat(api): expand the public v2 tables surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 16 operations so a v2 caller can do what the internal surface can: rename/move/lock a table, restore it, manage saved views, run enrichment columns, look up rows, and import/export with observable job control. Extracts lib/table/orchestration/import.ts (performTableCsvImport, performCreateTableFromCsv) and lib/table/export-stream.ts from the first-party routes, then repoints those routes at them, so v1 and v2 cannot drift on what an import or export actually does. events/stream, metadata and dispatches stay internal — they are editor state, not public API. --- apps/docs/openapi-v2-tables.json | 4427 ++++++++++++++--- .../app/api/table/[tableId]/export/route.ts | 100 +- .../app/api/table/[tableId]/import/route.ts | 294 +- apps/sim/app/api/table/import-csv/route.ts | 161 +- apps/sim/app/api/v1/middleware.ts | 9 + .../[tableId]/cancel-runs/route.test.ts | 192 + .../v2/tables/[tableId]/cancel-runs/route.ts | 100 + .../[tableId]/columns/run/route.test.ts | 195 + .../v2/tables/[tableId]/columns/run/route.ts | 118 + .../[tableId]/export-async/route.test.ts | 177 + .../v2/tables/[tableId]/export-async/route.ts | 129 + .../[tableId]/export/download/route.test.ts | 169 + .../tables/[tableId]/export/download/route.ts | 90 + .../v2/tables/[tableId]/export/route.test.ts | 170 + .../api/v2/tables/[tableId]/export/route.ts | 111 + .../v2/tables/[tableId]/groups/route.test.ts | 134 + .../api/v2/tables/[tableId]/groups/route.ts | 76 + .../[tableId]/import-async/route.test.ts | 213 + .../v2/tables/[tableId]/import-async/route.ts | 148 + .../v2/tables/[tableId]/import/route.test.ts | 233 + .../api/v2/tables/[tableId]/import/route.ts | 140 + .../tables/[tableId]/job/cancel/route.test.ts | 162 + .../v2/tables/[tableId]/job/cancel/route.ts | 90 + .../v2/tables/[tableId]/restore/route.test.ts | 189 + .../api/v2/tables/[tableId]/restore/route.ts | 88 + .../app/api/v2/tables/[tableId]/route.test.ts | 252 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 165 +- .../enrichment/[groupId]/route.test.ts | 160 + .../[rowId]/enrichment/[groupId]/route.ts | 96 + .../tables/[tableId]/rows/find/route.test.ts | 207 + .../v2/tables/[tableId]/rows/find/route.ts | 116 + .../[tableId]/views/[viewId]/route.test.ts | 240 + .../tables/[tableId]/views/[viewId]/route.ts | 183 + .../v2/tables/[tableId]/views/route.test.ts | 204 + .../api/v2/tables/[tableId]/views/route.ts | 127 + .../api/v2/tables/import-csv/route.test.ts | 227 + .../sim/app/api/v2/tables/import-csv/route.ts | 140 + apps/sim/app/api/v2/tables/jobs/route.test.ts | 127 + apps/sim/app/api/v2/tables/jobs/route.ts | 69 + apps/sim/app/api/v2/tables/utils.ts | 68 +- apps/sim/lib/api/contracts/tables.ts | 159 +- apps/sim/lib/api/contracts/v2/tables.ts | 546 +- apps/sim/lib/table/export-stream.ts | 103 + .../lib/table/orchestration/import.test.ts | 235 + apps/sim/lib/table/orchestration/import.ts | 514 ++ apps/sim/lib/table/orchestration/index.ts | 1 + apps/sim/lib/table/types.ts | 5 +- apps/sim/lib/table/views/service.test.ts | 35 + apps/sim/lib/table/views/service.ts | 15 + scripts/check-api-validation-contracts.ts | 4 +- 50 files changed, 10603 insertions(+), 1310 deletions(-) create mode 100644 apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/groups/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/import/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/route.ts create mode 100644 apps/sim/app/api/v2/tables/import-csv/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/import-csv/route.ts create mode 100644 apps/sim/app/api/v2/tables/jobs/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/jobs/route.ts create mode 100644 apps/sim/lib/table/export-stream.ts create mode 100644 apps/sim/lib/table/orchestration/import.test.ts create mode 100644 apps/sim/lib/table/orchestration/import.ts diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3f50df8b4b0..dca3fc1786f 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -96,7 +96,14 @@ "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 + } } ], "nextCursor": null @@ -197,7 +204,14 @@ "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 + } } } } @@ -353,6 +367,138 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "operationId": "updateTable", + "summary": "Update Table", + "description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"customers\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTableBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "ws_123", + "name": "customers" + } + }, + "move": { + "summary": "Move to the workspace root", + "value": { + "workspaceId": "ws_123", + "folderId": null + } + }, + "lock": { + "summary": "Lock deletes (workspace admin)", + "value": { + "workspaceId": "ws_123", + "locks": { + "deleteLocked": true + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "customers", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + } + ] + }, + "rowCount": 42, + "maxRows": 100000, + "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": true + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/tables/{tableId}/columns": { @@ -1495,1004 +1641,3785 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." - } - }, - "parameters": { - "TableId": { - "name": "tableId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "description": "The unique identifier of the table." - }, - "RowId": { - "name": "rowId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, - "description": "The unique identifier of the row." - }, - "WorkspaceIdQuery": { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1 - }, - "description": "The unique identifier of the workspace that owns the table." - }, - "LimitQuery": { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum rows to return (1-1000, default 100).", - "schema": { - "type": "integer", - "default": 100, - "minimum": 1, - "maximum": 1000 - } - }, - "CursorQuery": { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", - "schema": { - "type": "string", - "minLength": 1 - } - } - }, - "headers": { - "RateLimitLimit": { - "description": "Maximum number of requests permitted in the current rate-limit window.", - "schema": { - "type": "integer" - } - }, - "RateLimitRemaining": { - "description": "Number of requests remaining in the current rate-limit window.", - "schema": { - "type": "integer" - } - }, - "RateLimitReset": { - "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time" - } - }, - "RetryAfter": { - "description": "Number of seconds to wait before retrying the request.", - "schema": { - "type": "integer" - } - } }, - "schemas": { - "V2Error": { - "type": "object", - "description": "Canonical v2 error envelope.", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "description": "Machine-readable error code.", - "example": "BAD_REQUEST" - }, - "message": { - "type": "string", - "description": "Human-readable error message." + "/api/v2/tables/{tableId}/restore": { + "post": { + "operationId": "restoreTable", + "summary": "Restore Table", + "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table\u2019s name \u2014 rename that table first, then retry.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/restore\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" }, - "details": { - "description": "Optional structured error details, such as per-field validation issues." + "example": { + "workspaceId": "ws_123" } } } - } - }, - "Column": { - "type": "object", - "description": "A column definition in a table schema.", - "required": ["name", "type"], - "properties": { - "id": { - "type": "string", - "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", - "example": "col_a1b2c3" - }, - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "email" - }, + }, + "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" + } + } + } + } + } + }, + "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\u2019s saved views.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewListEnvelope" + }, + "example": { + "data": [ + { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "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 \u2014 rows it hides stay readable through the row and query endpoints.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"Active customers\",\"config\":{}}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateViewBody" + }, + "example": { + "workspaceId": "ws_123", + "name": "Active customers", + "config": { + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + } + } + } + } + }, + "responses": { + "201": { + "description": "The created view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "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\u2019s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table\u2019s existing default in the same transaction.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"isDefault\":true}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateViewBody" + }, + "examples": { + "promote": { + "summary": "Make this the table\u2019s default view", + "value": { + "workspaceId": "ws_123", + "isDefault": true + } + }, + "replaceConfig": { + "summary": "Replace the saved filter", + "value": { + "workspaceId": "ws_123", + "config": { + "filter": { + "any": [ + { + "field": "col_a1b2c3", + "op": "isNotEmpty" + } + ] + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableView", + "summary": "Delete View", + "description": "Remove a saved view. Deleting the table\u2019s default simply leaves the table unfiltered; no rows are affected.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The view was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteViewEnvelope" + }, + "example": { + "data": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/groups": { + "get": { + "operationId": "listTableWorkflowGroups", + "summary": "List Workflow Groups", + "description": "The table\u2019s workflow and enrichment groups \u2014 the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table\u2019s workflow groups.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupListEnvelope" + }, + "example": { + "data": [ + { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "dependencies": { + "columns": ["col_a1b2c3"] + }, + "outputs": [ + { + "blockId": "blk_agent1", + "path": "content", + "columnName": "summary" + } + ], + "deploymentMode": "deployed", + "autoRun": true + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns/run": { + "post": { + "operationId": "runTableColumns", + "summary": "Run Column Groups", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) \u2014 never both. Omit both to run every row. Starting a run clears the target groups\u2019 cells to pending, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns/run\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"groupIds\":[\"grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204\"]}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunColumnBody" + }, + "examples": { + "everyRow": { + "summary": "Run a group across the whole table", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] + } + }, + "backfillFiltered": { + "summary": "Backfill only unfinished rows matching a predicate, capped at 500", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"], + "runMode": "incomplete", + "filter": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "limit": { + "type": "rows", + "max": 500 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}": { + "post": { + "operationId": "runRowEnrichment", + "summary": "Run Enrichment For One Row", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** \u2014 the response acknowledges the dispatch; read the row back for the result.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/GroupId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" + }, + "example": { + "workspaceId": "ws_123" + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/find": { + "post": { + "operationId": "findTableRows", + "summary": "Find Rows", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row\u2019s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor \u2014 when `truncated` is true, narrow the predicate rather than paging.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/find\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"q\":\"acme\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsBody" + }, + "examples": { + "wholeTable": { + "summary": "Search every cell", + "value": { + "workspaceId": "ws_123", + "q": "acme" + } + }, + "withinFilter": { + "summary": "Search inside a filtered, sorted view", + "value": { + "workspaceId": "ws_123", + "q": "acme", + "predicate": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "name", + "direction": "asc" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The matching cells.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsEnvelope" + }, + "example": { + "data": { + "matches": [ + { + "ordinal": 12, + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "column": "company" + } + ], + "truncated": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/import-csv": { + "post": { + "operationId": "createTableFromCsv", + "summary": "Create Table From CSV", + "description": "Create a table from a CSV or TSV file. The column schema is inferred from the file\u2019s first rows and the table is named after the file.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part, so an unauthorized upload is rejected before its bytes are read. Rows stream in as they are parsed, so a file larger than memory still imports; a failure part way through drops the half-populated table rather than leaving it behind.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/import-csv\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"workspaceId=YOUR_WORKSPACE_ID\" \\\n -F \"file=@contacts.csv\"" + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 10 MB are rejected with 413 \u2014 use the async import instead.", + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateTableFromCsvForm" + } + } + } + }, + "responses": { + "201": { + "description": "The created table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/jobs": { + "get": { + "operationId": "listTableJobs", + "summary": "List Export Jobs", + "description": "Export jobs across a workspace \u2014 running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/jobs?workspaceId=YOUR_WORKSPACE_ID&type=export\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobTypeQuery" + } + ], + "responses": { + "200": { + "description": "The workspace\u2019s export jobs.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableJobListEnvelope" + }, + "example": { + "data": [ + { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "tableName": "customers", + "status": "ready", + "rowsProcessed": 12043, + "format": "csv", + "hasResult": true, + "error": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/import": { + "post": { + "operationId": "importTableCsv", + "summary": "Import CSV", + "description": "Import a CSV or TSV into an existing table, appending or replacing its rows.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part. Omit `mapping` to auto-map CSV headers to same-named columns; pass `createColumns` to have unmatched headers created as new columns, with types inferred from the file. The response reports what was written AND what was not (`skippedHeaders`, `unmappedColumns`), so a partial mapping is visible without diffing the schema.\n\nThe table\u2019s single write-job slot is held for the whole import, so a concurrent import or delete gets 409. Files over 10 MB must use `POST /import-async`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"workspaceId=YOUR_WORKSPACE_ID\" \\\n -F \"mode=append\" \\\n -F \"file=@contacts.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 10 MB are rejected with 413 \u2014 use the async import instead.", + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ImportTableForm" + } + } + } + }, + "responses": { + "200": { + "description": "The import summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportTableEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "mode": "append", + "insertedCount": 250, + "mappedColumns": ["Email", "Full Name"], + "skippedHeaders": ["Notes"], + "unmappedColumns": ["created_by"], + "sourceFile": "contacts.csv" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/import-async": { + "post": { + "operationId": "importTableCsvAsync", + "summary": "Import CSV (Background)", + "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it with `GET /api/v2/tables/jobs` and stop it with `POST /job/cancel`. `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"fileKey\":\"workspace/YOUR_WORKSPACE_ID/imports/contacts.csv\",\"fileName\":\"contacts.csv\",\"mode\":\"append\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "fileKey": "workspace/ws_123/imports/contacts.csv", + "fileName": "contacts.csv", + "mode": "append" + } + } + } + }, + "responses": { + "200": { + "description": "The import was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "importId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export": { + "get": { + "operationId": "exportTable", + "summary": "Export Table", + "description": "Stream the whole table as a CSV or JSON file attachment.\n\nThe only endpoint whose success body is the file itself rather than the `{ data }` envelope. Rows are written as they are read, so nothing is buffered \u2014 but once the stream has started a failure can only tear the connection down. Large tables should use `POST /export-async`, which survives a dropped connection and leaves a re-downloadable result.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export?workspaceId=YOUR_WORKSPACE_ID&format=csv\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o table.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/ExportFormatQuery" + } + ], + "responses": { + "200": { + "description": "The table contents. CSV carries a header row of column names; JSON is an array of name-keyed row objects.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + }, + "Content-Disposition": { + "description": "Attachment filename, derived from the table name.", + "schema": { + "type": "string", + "example": "attachment; filename=\"customers.csv\"" + } + } + }, + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export-async": { + "post": { + "operationId": "exportTableAsync", + "summary": "Export Table (Background)", + "description": "Start a background export. Export jobs are read-only, so they bypass the one-write-job-per-table gate and can run alongside an import or delete.\n\nReturns as soon as the job is queued. Poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download` once the job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"format\":\"csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "format": "csv" + } + } + } + }, + "responses": { + "200": { + "description": "The export was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export/download": { + "get": { + "operationId": "downloadTableExport", + "summary": "Download Export", + "description": "Resolve a finished export job to a short-lived presigned download URL.\n\nThe failure modes are deliberately distinct: a job that is not an export of this table is 404, one still running is 409 (retry later), and one whose file has aged out of storage is 410 (start a new export). A caller polling to completion needs to tell \"not yet\" from \"never again\".", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export/download?workspaceId=YOUR_WORKSPACE_ID&jobId=YOUR_JOB_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobIdQuery" + } + ], + "responses": { + "200": { + "description": "The presigned download URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportDownloadEnvelope" + }, + "example": { + "data": { + "url": "https://storage.sim.ai/workspace/ws_123/exports/customers.csv?X-Amz-Signature=...", + "fileName": "customers.csv" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "410": { + "$ref": "#/components/responses/Gone" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/job/cancel": { + "post": { + "operationId": "cancelTableJob", + "summary": "Cancel Job", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place \u2014 there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/job/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"jobId\":\"YOUR_JOB_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobBody" + }, + "example": { + "workspaceId": "ws_123", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + }, + "responses": { + "200": { + "description": "The cancel outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobEnvelope" + }, + "example": { + "data": { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "canceled": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/cancel-runs": { + "post": { + "operationId": "cancelTableRuns", + "summary": "Cancel Column Runs", + "description": "Stop in-flight and pending workflow or enrichment cell runs \u2014 the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row\u2019s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/cancel-runs\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"scope\":\"all\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsBody" + }, + "examples": { + "everything": { + "summary": "Stop every run on the table", + "value": { + "workspaceId": "ws_123", + "scope": "all" + } + }, + "oneRow": { + "summary": "Stop one row\u2019s runs", + "value": { + "workspaceId": "ws_123", + "scope": "row", + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "How many runs were stopped.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsEnvelope" + }, + "example": { + "data": { + "cancelled": 17 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ViewId": { + "name": "viewId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "description": "The unique identifier of the saved view." + }, + "GroupId": { + "name": "groupId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + }, + "description": "The unique identifier of the workflow or enrichment group." + }, + "JobIdQuery": { + "name": "jobId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "example": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + }, + "description": "The export job to resolve." + }, + "ExportFormatQuery": { + "name": "format", + "in": "query", + "required": false, + "description": "Serialization for the exported file. Defaults to `csv`.", + "schema": { + "enum": ["csv", "json"], + "default": "csv" + } + }, + "JobTypeQuery": { + "name": "type", + "in": "query", + "required": true, + "description": "Job kind to list. Only `export` is supported today; the parameter is required so widening it later cannot silently change what an existing caller receives.", + "schema": { + "enum": ["export"] + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "id": { + "type": "string", + "description": "Stable column id. Server-assigned \u2014 normally omit." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "folderId", + "locks", + "createdAt", + "updatedAt" + ], + "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" + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] + } + } + } + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + ], + "description": "The column definition to add." + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowsEnvelope": { + "type": "object", + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowEnvelope": { + "type": "object", + "description": "Result of a single-row delete.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } + } + } + }, + "UpsertRowEnvelope": { + "type": "object", + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row", "operation"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + }, + "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" + } + } + } }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/PredicateNode" + } + } + } + } + ] + }, + "PredicateNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/Predicate" }, - "workflowGroupId": { + { + "$ref": "#/components/schemas/Condition" + } + ] + }, + "Condition": { + "type": "object", + "required": ["field", "op"], + "additionalProperties": false, + "properties": { + "field": { "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." + "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)." }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + "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)." }, - "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" + "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." } } }, - "ColumnInput": { + "SelectOption": { "type": "object", - "description": "Column definition supplied when creating a table or adding a column.", - "required": ["name", "type"], + "required": ["id", "name"], "properties": { - "name": { + "id": { "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" + "description": "Stable option id \u2014 the value stored in cells." }, - "type": { + "name": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." + "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." }, - "required": { + "insertLocked": { "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." + "description": "Blocks new rows." }, - "unique": { + "updateLocked": { "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." + "description": "Blocks cell writes to existing rows." }, - "id": { - "type": "string", - "description": "Stable column id. Server-assigned \u2014 normally omit." + "deleteLocked": { + "type": "boolean", + "description": "Blocks row deletes and archiving the table." + } + } + }, + "TableLocksPatch": { + "type": "object", + "description": "Lock flags to change. Omitted flags are left as they are.", + "properties": { + "schemaLocked": { + "type": "boolean", + "description": "Blocks column adds, edits, and deletes." }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." + "insertLocked": { + "type": "boolean", + "description": "Blocks new rows." }, - "multiple": { + "updateLocked": { "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + "description": "Blocks cell writes to existing rows." }, - "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" + "deleteLocked": { + "type": "boolean", + "description": "Blocks row deletes and archiving the table." } } }, - "Table": { + "UpdateTableBody": { "type": "object", - "description": "A user-defined table with a typed column schema.", - "required": [ - "id", - "name", - "description", - "schema", - "rowCount", - "maxRows", - "createdAt", - "updatedAt" - ], + "description": "Rename, move, and/or re-lock a table. Every field beyond `workspaceId` is optional, but at least one must be present.", + "required": ["workspaceId"], "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Unique table identifier.", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + "minLength": 1, + "description": "The workspace that owns the table." }, "name": { "type": "string", - "description": "Table name.", - "example": "contacts" + "minLength": 1, + "description": "New table name." }, - "description": { + "folderId": { "type": ["string", "null"], - "description": "Optional description of the table. Null when not set.", - "example": "Customer contact records" + "description": "Folder to move the table into. Pass null to move it to the workspace root; omit to leave the placement untouched." }, - "schema": { + "locks": { + "$ref": "#/components/schemas/TableLocksPatch" + } + } + }, + "WorkspaceScopedBody": { + "type": "object", + "description": "Endpoints whose only input is the workspace the table must belong to.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + } + } + }, + "SortSpec": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first. Fields are column names.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { + "type": "string" + }, + "direction": { + "enum": ["asc", "desc"] + } + } + } + }, + "ViewConfig": { + "type": "object", + "description": "A view\u2019s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "properties": { + "columnWidths": { "type": "object", - "description": "Table schema definition.", - "required": ["columns"], - "properties": { - "columns": { - "type": "array", - "description": "Array of column definitions for the table.", - "items": { - "$ref": "#/components/schemas/Column" - } - } + "description": "Pixel widths keyed by column id.", + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0 } }, - "rowCount": { - "type": "integer", - "description": "Current number of rows in the table." + "columnOrder": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Left-to-right column order, as column ids." }, - "maxRows": { - "type": "integer", - "description": "Maximum rows allowed by the current billing plan." + "pinnedColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids pinned while scrolling horizontally." }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was created." + "hiddenColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids hidden by the view. A deny-list \u2014 a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was last modified." + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" } } }, - "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": { + "View": { "type": "object", - "description": "A single row in a table.", - "required": ["id", "data", "createdAt", "updatedAt"], + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only \u2014 a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "required": [ + "id", + "tableId", + "name", + "config", + "isDefault", + "createdBy", + "createdAt", + "updatedAt" + ], "properties": { "id": { "type": "string", - "description": "Unique row identifier.", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + "description": "Unique view identifier.", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "tableId": { + "type": "string", + "description": "The table the view belongs to." + }, + "name": { + "type": "string", + "description": "Display name." }, - "data": { - "$ref": "#/components/schemas/RowData" + "config": { + "$ref": "#/components/schemas/ViewConfig" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this view is the table\u2019s default. At most one view per table is." + }, + "createdBy": { + "type": ["string", "null"], + "description": "User who saved the view, or null when that user no longer exists." }, "createdAt": { "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was created." + "format": "date-time" }, "updatedAt": { "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was last modified." + "format": "date-time" } } }, - "CreateTableBody": { + "CreateViewBody": { "type": "object", - "description": "Payload to create a new table.", - "required": ["workspaceId", "name", "schema"], + "description": "Save a filter/sort/layout preset as a named view.", + "required": ["workspaceId", "name", "config"], "properties": { "workspaceId": { "type": "string", "minLength": 1, - "description": "The workspace that will own the table." + "description": "The workspace that owns 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." - } - } - } - ] - } - } - } + "minLength": 1, + "description": "Display name for the view." }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + "config": { + "$ref": "#/components/schemas/ViewConfig" } } }, - "AddColumnBody": { + "UpdateViewBody": { "type": "object", - "description": "Payload to add a column to a table.", - "required": ["workspaceId", "column"], + "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." }, - "column": { + "name": { + "type": "string", + "minLength": 1, + "description": "New display name." + }, + "config": { "allOf": [ { - "$ref": "#/components/schemas/ColumnInput" - }, + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Replaces the stored config wholesale. Use when dropping a removed filter must persist." + }, + "configPatch": { + "allOf": [ { - "type": "object", - "properties": { - "position": { - "type": "integer", - "minimum": 0, - "description": "Zero-based insert position in the column order. Appended at the end when omitted." - } - } + "$ref": "#/components/schemas/ViewConfig" } ], - "description": "The column definition to add." + "description": "Shallow-merged into the stored config server-side, so two overlapping partial writes cannot clobber each other from stale snapshots." + }, + "isDefault": { + "type": "boolean", + "description": "Promote this view to the table\u2019s default. Setting it demotes the table\u2019s existing default in the same transaction." } } }, - "UpdateColumnBody": { + "ViewEnvelope": { "type": "object", - "description": "Payload to update an existing column by name.", - "required": ["workspaceId", "columnName", "updates"], + "description": "A single view wrapped in the v2 data envelope.", + "required": ["data"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." - }, - "columnName": { - "type": "string", - "description": "The current name of the column to update.", - "example": "phone" - }, - "updates": { + "data": { "type": "object", - "description": "Fields to change. Provide at least one.", + "required": ["view"], "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" + "view": { + "$ref": "#/components/schemas/View" } } } } }, - "DeleteColumnBody": { + "ViewListEnvelope": { "type": "object", - "description": "Payload to delete a column by name.", - "required": ["workspaceId", "columnName"], + "description": "Saved views wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/View" + } }, - "columnName": { - "type": "string", - "description": "The name of the column to delete.", - "example": "phone_number" + "nextCursor": { + "type": ["string", "null"], + "description": "Always null \u2014 a table carries a bounded set of views, so the list is a single full page." } } }, - "CreateRowSingleBody": { + "DeleteViewEnvelope": { "type": "object", - "description": "Insert a single row.", - "required": ["workspaceId", "data"], + "description": "Delete confirmation carrying the id of the removed view.", + "required": ["data"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." - }, "data": { - "$ref": "#/components/schemas/RowData" - }, - "afterRowId": { - "type": "string", - "minLength": 1, - "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." - }, - "beforeRowId": { - "type": "string", - "minLength": 1, - "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The view that was deleted." + } + } } } }, - "CreateRowBatchBody": { + "WorkflowGroup": { "type": "object", - "description": "Insert multiple rows in one request.", - "required": ["workspaceId", "rows"], + "description": "A workflow or enrichment group: a backing workflow (or registry enrichment) plus the output columns its runs populate. Authored in the workflow builder; exposed here so a caller can discover the group ids the run endpoints take.", + "required": ["id", "workflowId", "outputs"], "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "description": "Group id \u2014 pass to the run endpoints." }, - "rows": { - "type": "array", - "minItems": 1, - "maxItems": 1000, - "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", - "items": { - "$ref": "#/components/schemas/RowData" - } - } - } - }, - "CreateRowsBody": { - "description": "Either a single-row payload or a batch payload.", - "oneOf": [ - { - "$ref": "#/components/schemas/CreateRowSingleBody" + "workflowId": { + "type": "string", + "description": "Backing workflow id for manual groups; empty string for enrichment groups." }, - { - "$ref": "#/components/schemas/CreateRowBatchBody" - } - ] - }, - "UpdateRowsByFilterBody": { - "type": "object", - "description": "Bulk-update rows matching a filter.", - "required": ["workspaceId", "filter", "data"], - "properties": { - "workspaceId": { + "enrichmentId": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "description": "Registry enrichment id, present on enrichment groups." }, - "filter": { - "$ref": "#/components/schemas/Predicate" + "name": { + "type": "string", + "description": "Display name." }, - "data": { - "$ref": "#/components/schemas/RowData" + "type": { + "enum": ["manual", "enrichment"], + "description": "Provenance of the group. Defaults to manual when absent." }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of matching rows to update." + "dependencies": { + "type": "object", + "description": "Columns whose values must be present before the group is eligible to run.", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "outputs": { + "type": "array", + "description": "Which produced value flows into which column.", + "items": { + "type": "object", + "required": ["blockId", "path", "columnName"], + "properties": { + "blockId": { + "type": "string", + "description": "Source block in the workflow. Empty on enrichment outputs." + }, + "path": { + "type": "string", + "description": "Path into the block output. Empty on enrichment outputs." + }, + "outputId": { + "type": "string", + "description": "Enrichment output id, on enrichment groups." + }, + "columnName": { + "type": "string", + "description": "Column the value is written to." + } + } + } + }, + "inputMappings": { + "type": "array", + "description": "Which table column supplies each workflow Start-block input.", + "items": { + "type": "object", + "required": ["inputName", "columnName"], + "properties": { + "inputName": { + "type": "string" + }, + "columnName": { + "type": "string" + } + } + } + }, + "deploymentMode": { + "enum": ["live", "deployed"], + "description": "Which workflow state per-cell runs execute against. Defaults to live (the editable draft)." + }, + "autoRun": { + "type": "boolean", + "description": "When false the group never auto-fires; it runs only on an explicit request. Defaults to true." } } }, - "DeleteRowsByFilterBody": { + "WorkflowGroupListEnvelope": { "type": "object", - "description": "Delete rows matching a filter.", - "required": ["workspaceId", "filter"], + "description": "Workflow groups wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." - }, - "filter": { - "$ref": "#/components/schemas/Predicate" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroup" + } }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of matching rows to delete." + "nextCursor": { + "type": ["string", "null"], + "description": "Always null \u2014 groups are bounded per table, so the list is a single full page." } } }, - "DeleteRowsByIdsBody": { + "RunColumnBody": { "type": "object", - "description": "Delete an explicit list of rows by id.", - "required": ["workspaceId", "rowIds"], + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) \u2014 never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "required": ["workspaceId", "groupIds"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, + "groupIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Groups to run, from `GET /api/v2/tables/{tableId}/groups`." + }, + "runMode": { + "enum": ["all", "incomplete"], + "default": "all", + "description": "`all` re-runs every dep-satisfied row. `incomplete` restricts to rows whose group has never run or whose last run failed or aborted." + }, "rowIds": { "type": "array", "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Run only these rows. Mutually exclusive with `filter`." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", "maxItems": 1000, - "description": "Row ids to delete. Up to 1000 ids per request.", "items": { "type": "string", "minLength": 1 - } + }, + "description": "Rows to skip within the `filter` scope." }, "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of rows to delete." + "type": "object", + "description": "Cap the run to the first N eligible rows. Omit for an unbounded run.", + "required": ["type", "max"], + "properties": { + "type": { + "enum": ["rows"] + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + } } } }, - "DeleteRowsBody": { - "description": "Provide exactly one of `filter` or `rowIds`.", - "oneOf": [ - { - "$ref": "#/components/schemas/DeleteRowsByFilterBody" - }, - { - "$ref": "#/components/schemas/DeleteRowsByIdsBody" + "RunEnvelope": { + "type": "object", + "description": "Acknowledgement that a run was dispatched.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["dispatchId"], + "properties": { + "dispatchId": { + "type": ["string", "null"], + "description": "Identifies the dispatch the runner walks. Null where no background runner is configured and cells execute inline." + } + } } - ] + } }, - "UpdateRowBody": { + "FindRowsBody": { "type": "object", - "description": "Partial update for a single row.", - "required": ["workspaceId", "data"], + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`.", + "required": ["workspaceId", "q"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "data": { - "$ref": "#/components/schemas/RowData" + "q": { + "type": "string", + "minLength": 1, + "description": "Substring to search for." + }, + "predicate": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" } } }, - "UpsertRowBody": { + "RowMatch": { "type": "object", - "description": "Insert-or-update a row keyed by a unique column.", - "required": ["workspaceId", "data"], + "description": "One matching cell.", + "required": ["ordinal", "rowId", "column"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "ordinal": { + "type": "integer", + "description": "The row\u2019s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments \u2014 use it to page straight to the match." }, - "data": { - "$ref": "#/components/schemas/RowData" + "rowId": { + "type": "string", + "description": "The row holding the matching cell." }, - "conflictTarget": { + "column": { "type": "string", - "minLength": 1, - "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + "description": "Name of the matching column." } } }, - "TableEnvelope": { + "FindRowsEnvelope": { "type": "object", - "description": "A single table wrapped in the v2 data envelope.", + "description": "Matching cells wrapped in the v2 data envelope.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["table"], + "required": ["matches", "truncated"], "properties": { - "table": { - "$ref": "#/components/schemas/Table" + "matches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RowMatch" + } + }, + "truncated": { + "type": "boolean", + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor \u2014 narrow the predicate instead of paging." } } } } }, - "TableListEnvelope": { + "ImportTableForm": { "type": "object", - "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", - "required": ["data", "nextCursor"], + "description": "Multipart form for a synchronous import. `mapping` and `createColumns` are JSON-encoded strings, since every multipart field arrives as text.", + "required": ["workspaceId", "file"], "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Table" - } + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table. Must appear BEFORE the file part \u2014 the server rejects an unauthorized upload before reading its bytes." }, - "nextCursor": { - "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null when there are no more pages." + "file": { + "type": "string", + "format": "binary", + "description": "The .csv or .tsv file." + }, + "mode": { + "enum": ["append", "replace"], + "default": "append", + "description": "`append` adds rows; `replace` deletes every existing row first." + }, + "mapping": { + "type": "string", + "description": "JSON object mapping each CSV header to a column name, or null to skip that header. Omit to auto-map headers to same-named columns.", + "example": "{\"Email\":\"email\",\"Full Name\":\"name\",\"Notes\":null}" + }, + "createColumns": { + "type": "string", + "description": "JSON array of CSV headers to create as new columns before importing. Their types are inferred from the file.", + "example": "[\"Phone\"]" + }, + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes (Excel and Sheets exports carry no offset). Defaults to the API key owner\u2019s saved timezone, else UTC.", + "example": "America/New_York" } } }, - "DeleteTableEnvelope": { + "CreateTableFromCsvForm": { "type": "object", - "description": "Confirmation that a table was archived.", - "required": ["data"], + "description": "Multipart form for creating a table from a file.", + "required": ["workspaceId", "file"], "properties": { - "data": { - "type": "object", - "required": ["id"], - "properties": { - "id": { - "type": "string", - "description": "The id of the archived table." - } - } + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the table in. Must appear BEFORE the file part \u2014 the server rejects an unauthorized upload before reading its bytes." + }, + "file": { + "type": "string", + "format": "binary", + "description": "The .csv or .tsv file." + }, + "folderId": { + "type": "string", + "description": "Folder to create the table in. Omit to create it at the workspace root." + }, + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes. Defaults to the API key owner\u2019s saved timezone, else UTC.", + "example": "America/New_York" } } }, - "ColumnsEnvelope": { + "ImportTableEnvelope": { "type": "object", - "description": "The table's full column list after a column mutation.", + "description": "Synchronous-import summary wrapped in the v2 data envelope.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["columns"], + "required": [ + "tableId", + "mode", + "insertedCount", + "mappedColumns", + "skippedHeaders", + "unmappedColumns", + "sourceFile" + ], "properties": { - "columns": { + "tableId": { + "type": "string" + }, + "mode": { + "enum": ["append", "replace"] + }, + "insertedCount": { + "type": "integer", + "description": "Rows written." + }, + "deletedCount": { + "type": "integer", + "description": "Rows removed first. Present only for `mode: \"replace\"`." + }, + "mappedColumns": { "type": "array", "items": { - "$ref": "#/components/schemas/Column" - } + "type": "string" + }, + "description": "CSV headers that were written to a column." + }, + "skippedHeaders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CSV headers the mapping explicitly skipped." + }, + "unmappedColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Table columns no CSV header supplied \u2014 left at their existing values." + }, + "sourceFile": { + "type": "string", + "description": "Uploaded filename, echoed back." } } } } }, - "RowEnvelope": { + "ImportAsyncEnvelope": { "type": "object", - "description": "A single row wrapped in the v2 data envelope.", + "description": "Background-import kickoff acknowledgement.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["row"], + "required": ["tableId", "importId"], "properties": { - "row": { - "$ref": "#/components/schemas/Row" + "tableId": { + "type": "string" + }, + "importId": { + "type": "string", + "description": "Job id \u2014 pass to `POST /job/cancel` to stop the import." } } } } }, - "RowListEnvelope": { + "ImportAsyncBody": { "type": "object", - "description": "A cursor-paginated page of rows.", - "required": ["data", "nextCursor"], + "description": "Starts a background import of a file already uploaded to workspace storage. The file is read by the worker, not from this request.", + "required": ["workspaceId", "fileKey", "fileName", "mode"], "properties": { - "data": { + "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\u2019s `workspace/{workspaceId}/` prefix.", + "example": "workspace/ws_123/imports/contacts.csv" + }, + "fileName": { + "type": "string", + "minLength": 1, + "description": "Original filename. Its extension selects the separator (.csv or .tsv)." + }, + "mode": { + "enum": ["append", "replace"], + "description": "`append` adds rows; `replace` deletes every existing row first." + }, + "mapping": { + "type": "object", + "description": "CSV header \u2192 column name, or null to skip the header.", + "additionalProperties": { + "type": ["string", "null"] + } + }, + "createColumns": { "type": "array", "items": { - "$ref": "#/components/schemas/Row" - } + "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 on the final page." + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes.", + "example": "America/New_York" } } }, - "BatchInsertRowsEnvelope": { + "ExportAsyncBody": { "type": "object", - "description": "Result of a batch row insert.", - "required": ["data"], + "description": "Starts a background export.", + "required": ["workspaceId"], "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" + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - { - "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + "format": { + "enum": ["csv", "json"], + "default": "csv", + "description": "Serialization to produce." } - ] + } }, - "UpdateRowsEnvelope": { + "ExportAsyncEnvelope": { "type": "object", - "description": "Result of a bulk update-by-filter.", + "description": "Background-export kickoff acknowledgement.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["updatedCount", "updatedRowIds"], + "required": ["tableId", "jobId"], "properties": { - "updatedCount": { - "type": "integer", - "description": "Number of rows updated." + "tableId": { + "type": "string" }, - "updatedRowIds": { - "type": "array", - "description": "Ids of the updated rows. Empty when nothing matched.", - "items": { - "type": "string" - } + "jobId": { + "type": "string", + "description": "Job id \u2014 poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } } }, - "DeleteRowsEnvelope": { + "ExportDownloadEnvelope": { "type": "object", - "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "description": "A short-lived presigned download URL for a finished export.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "required": ["url", "fileName"], "properties": { - "deletedCount": { - "type": "integer", - "description": "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." + "url": { + "type": "string", + "description": "Presigned URL. Expires shortly after issue \u2014 fetch it promptly." }, - "missingRowIds": { - "type": "array", - "description": "Requested ids that did not exist. Present only for id-based deletes.", - "items": { - "type": "string" - } + "fileName": { + "type": "string", + "description": "Suggested filename for the download." } } } } }, - "DeleteRowEnvelope": { + "TableJob": { "type": "object", - "description": "Result of a single-row delete.", - "required": ["data"], + "description": "One export job.", + "required": [ + "jobId", + "tableId", + "tableName", + "status", + "rowsProcessed", + "format", + "hasResult", + "error" + ], "properties": { - "data": { - "type": "object", - "required": ["deletedCount", "deletedRowIds"], - "properties": { - "deletedCount": { - "type": "integer", - "description": "Always 1 when a row was deleted." - }, - "deletedRowIds": { - "type": "array", - "description": "The id of the deleted row.", - "items": { - "type": "string" - } - } - } + "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." } } }, - "UpsertRowEnvelope": { + "TableJobListEnvelope": { "type": "object", - "description": "Result of an upsert, including whether the row was inserted or updated.", - "required": ["data"], + "description": "Export jobs wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], "properties": { "data": { - "type": "object", - "required": ["row", "operation"], - "properties": { - "row": { - "$ref": "#/components/schemas/Row" - }, - "operation": { - "type": "string", - "enum": ["insert", "update"], - "description": "Whether the row was inserted or updated." - } + "type": "array", + "items": { + "$ref": "#/components/schemas/TableJob" } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null \u2014 the listing is bounded server-side to a single page." } } }, - "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", - "oneOf": [ - { - "type": "object", - "required": ["all"], - "additionalProperties": false, - "properties": { - "all": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/PredicateNode" - } - } - } + "CancelJobBody": { + "type": "object", + "description": "Stops an in-flight import or delete job.", + "required": ["workspaceId", "jobId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - { + "jobId": { + "type": "string", + "minLength": 1, + "description": "The job to stop." + } + } + }, + "CancelJobEnvelope": { + "type": "object", + "description": "Cancel outcome.", + "required": ["data"], + "properties": { + "data": { "type": "object", - "required": ["any"], - "additionalProperties": false, + "required": ["jobId", "canceled"], "properties": { - "any": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/PredicateNode" - } + "jobId": { + "type": "string" + }, + "canceled": { + "type": "boolean", + "description": "False when the job had already finished. Cancelling is idempotent \u2014 a late request is not an error." } } } - ] - }, - "PredicateNode": { - "oneOf": [ - { - "$ref": "#/components/schemas/Predicate" - }, - { - "$ref": "#/components/schemas/Condition" - } - ] + } }, - "Condition": { + "CancelRunsBody": { "type": "object", - "required": ["field", "op"], - "additionalProperties": false, + "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": { - "field": { + "workspaceId": { "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)." + "minLength": 1, + "description": "The workspace that owns the table." }, - "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)." + "scope": { + "enum": ["all", "row"], + "description": "`all` cancels every running and pending cell; `row` cancels one row\u2019s cells." }, - "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." + "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." } } }, - "SelectOption": { + "CancelRunsEnvelope": { "type": "object", - "required": ["id", "name"], + "description": "How many in-flight cell runs were stopped.", + "required": ["data"], "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)." + "data": { + "type": "object", + "required": ["cancelled"], + "properties": { + "cancelled": { + "type": "integer" + } + } } } } @@ -2616,6 +5543,70 @@ } } } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource \u2014 for example a rename to a name another table in the workspace already uses.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A table named \"contacts\" already exists" + } + } + } + } + }, + "Locked": { + "description": "The table has a lock that forbids this operation. Clear the relevant lock with `PATCH /api/v2/tables/{tableId}` (workspace admin only) and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Schema changes are locked for this table" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The upload is too large for a synchronous import. Upload the file to workspace storage and use `POST /api/v2/tables/{tableId}/import-async` instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "CSV import file exceeds maximum size" + } + } + } + } + }, + "Gone": { + "description": "The generated export file has aged out of storage. Start a new export rather than retrying this download.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Export file is no longer available" + } + } + } + } } } } diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 58df047c629..17a845ed0ae 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -1,25 +1,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { namedRowMapper } from '@/lib/table/cell-format' -import { getColumnId } from '@/lib/table/column-keys' -import { formatCsvCell } from '@/lib/table/export-format' -import { queryRows } from '@/lib/table/rows/service' +import { + createTableExportStream, + exportContentType, + sanitizeExportFilename, +} from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableExport') - -const EXPORT_BATCH_SIZE = 1000 - -type ExportFormat = 'csv' | 'json' - interface RouteParams { params: Promise<{ tableId: string }> } @@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou { status: 400 } ) } - const format: ExportFormat = formatValidation.data + const format = formatValidation.data const access = await checkAccess(tableId, auth.userId, 'read') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access - const columns = table.schema.columns - // Stored row data is id-keyed; CSV headers and JSON keys are display names, so - // translate id → name on the way out (export is a name-friendly boundary). - const toNamedRow = namedRowMapper(columns) - const safeName = sanitizeFilename(table.name) - const filename = `${safeName}.${format}` - // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, @@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou ) } - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - try { - if (format === 'csv') { - controller.enqueue( - encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) - ) - } else { - controller.enqueue(encoder.encode('[')) - } - - let offset = 0 - let firstJsonRow = true - while (true) { - const result = await queryRows( - table, - { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, - requestId - ) - - for (const row of result.rows) { - if (format === 'csv') { - const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) - controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) - } else { - const prefix = firstJsonRow ? '' : ',' - firstJsonRow = false - controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) - } - } - - // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, - // so a short page does NOT mean the export is done — only a null cursor does. - if (!result.nextCursor) break - offset += result.rows.length - } - - if (format === 'json') controller.enqueue(encoder.encode(']')) - controller.close() - - logger.info(`[${requestId}] Exported table ${tableId}`, { - format, - rowCount: table.rowCount, - }) - } catch (err) { - logger.error(`[${requestId}] Export failed for table ${tableId}`, err) - controller.error(err) - } - }, - }) - - return new NextResponse(stream, { + return new NextResponse(createTableExportStream(table, format, requestId), { status: 200, headers: { - 'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Type': exportContentType(format), + 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, 'Cache-Control': 'no-store', }, }) }) - -function sanitizeFilename(name: string): string { - const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') - return cleaned || 'table' -} - -function toCsvRow(values: string[]): string { - return values.map(escapeCsvField).join(',') -} - -function escapeCsvField(field: string): string { - if (/[",\n\r]/.test(field)) { - return `"${field.replace(/"/g, '""')}"` - } - return field -} diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 3aa28fe34ee..465777f46a3 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -1,7 +1,5 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, @@ -14,39 +12,18 @@ import { import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - buildAutoMapping, - CSV_MAX_FILE_SIZE_BYTES, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - createCsvParser, - dispatchAfterBatchInsert, - generateColumnId, - getMaxRowsPerTable, - inferColumnType, - markTableJobRunning, - releaseJobClaim, - sanitizeName, - type TableDefinition, - type TableSchema, - validateMapping, - wouldExceedRowLimit, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' -import { signalTableSchemaChanged } from '@/lib/table/events' -import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table' +import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, - tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -63,7 +40,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const requestId = generateRequestId() const { tableId } = tableIdParamsSchema.parse(await params) let fileStream: Readable | undefined - let claimedImportId: string | null = null try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -132,18 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if (table.archivedAt) { - return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) - } - // Don't run a sync import on top of an in-flight background job — concurrent writers - // would insert at colliding row positions. - if (table.jobStatus === 'running') { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - let mapping: CsvHeaderMapping | undefined if (fields.mapping) { const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping) @@ -180,246 +144,46 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro timezone = timezoneValidation.data } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionValidation.data === 'tsv' ? '\t' : ',' - ) - let headers: string[] = [] - const parser = createCsvParser(delimiter, (parsedHeaders) => { - headers = parsedHeaders + const outcome = await performTableCsvImport({ + table, + workspaceId, + userId: authResult.userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionValidation.data === 'tsv' ? '\t' : ',', + mode, + mapping, + createColumns, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (streamErr) => parser.destroy(streamErr)) - csvStream.pipe(parser) - const rows: Record[] = [] - for await (const record of parser as AsyncIterable>) { - rows.push(record) - } - if (rows.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - - let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) - let prospectiveTable: TableDefinition = table - const additions: { id?: string; name: string; type: string }[] = [] - - if (createColumns && createColumns.length > 0) { - const headerSet = new Set(headers) - const unknownHeaders = createColumns.filter((h) => !headerSet.has(h)) - if (unknownHeaders.length > 0) { - return NextResponse.json( - { - error: `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, - }, - { status: 400 } - ) - } - - const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) - const updatedMapping: CsvHeaderMapping = { ...effectiveMapping } - const newColumns: TableSchema['columns'] = [] - for (const header of createColumns) { - const base = sanitizeName(header) - let columnName = base - let suffix = 2 - while (usedNames.has(columnName.toLowerCase())) { - columnName = `${base}_${suffix}` - suffix++ - } - usedNames.add(columnName.toLowerCase()) - const inferredType = inferColumnType(rows.map((r) => r[header])) - // Pre-assign the id so the prospective schema (used to coerce rows) and - // the persisted column (created in importAppendRows) share the same key. - const id = generateColumnId() - additions.push({ id, name: columnName, type: inferredType }) - newColumns.push({ - id, - name: columnName, - type: inferredType as TableSchema['columns'][number]['type'], - required: false, - unique: false, - }) - updatedMapping[header] = columnName + if (!outcome.success) { + // A lock rejection renders `{ error, lock }` and deliberately carries NO + // `details`: the client's `isValidationError` treats any array-valued + // `details` as a field-validation error and swallows the toast. + if (outcome.errorCode === 'locked') { + return NextResponse.json({ error: outcome.error, lock: outcome.lock }, { status: 423 }) } - - prospectiveTable = { - ...table, - schema: { columns: [...table.schema.columns, ...newColumns] }, - } - effectiveMapping = updatedMapping - } - - let validation: ReturnType - try { - validation = validateMapping({ - csvHeaders: headers, - mapping: effectiveMapping, - tableSchema: prospectiveTable.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return NextResponse.json({ error: err.message, details: err.details }, { status: 400 }) - } - throw err - } - - if (validation.mappedHeaders.length === 0) { return NextResponse.json( { - error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveTable.schema.columns.map((c) => c.name).join(', ')}`, + error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error, + ...(outcome.details !== undefined ? { details: outcome.details } : {}), + // The append dialog reads this to distinguish "nothing landed" from a + // partial import; only that mode has ever carried it. + ...(mode === 'append' ? { data: { insertedCount: 0 } } : {}), }, - { status: 400 } + { status: statusForOrchestrationError(outcome.errorCode) } ) } - const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, { - timezone, - }) - - // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot - // taken before the parse/validation; a background import could claim the table in that window. - // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in - // the finally so a sync import can't write concurrently with a background one (corrupts replace). - const syncImportId = generateId() - if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - claimedImportId = syncImportId - - if (mode === 'append') { - const maxRows = await getMaxRowsPerTable(workspaceId) - if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) { - const deficit = prospectiveTable.rowCount + coerced.length - maxRows - return NextResponse.json( - { - error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, - }, - { status: 400 } - ) - } - - try { - const { inserted: insertedRows, table: finalTable } = await importAppendRows( - table, - additions, - coerced, - { workspaceId, userId: authResult.userId, requestId } - ) - const inserted = insertedRows.length - // Fire trigger + scheduler AFTER the tx commits — both read through the - // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, insertedRows, requestId, authResult.userId) - - logger.info(`[${requestId}] Append CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - inserted, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - skippedHeaders: validation.skippedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - insertedCount: inserted, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - // This branch returns rather than rethrowing, so the outer catch's - // mapper is unreachable from here — map the lock error first or a 423 - // degrades into a generic 500 (replace mode rethrows and maps fine). - const lockError = tableLockErrorResponse(err) - if (lockError) return lockError - - const message = toError(err).message - logger.warn(`[${requestId}] Append failed for table ${tableId}`, { - total: coerced.length, - createdColumns: additions.length, - error: message, - }) - const classified = asOrchestrationError(err) - return NextResponse.json( - { - error: classified ? classified.message : 'Failed to import CSV', - data: { insertedCount: 0 }, - }, - { status: classified ? statusForOrchestrationError(classified.code) : 500 } - ) - } - } - - try { - const result = await importReplaceRows( - table, - additions, - { rows: coerced, workspaceId, userId: authResult.userId }, - requestId - ) - - logger.info(`[${requestId}] Replace CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - deleted: result.deletedCount, - inserted: result.insertedCount, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - const classified = asOrchestrationError(err) - if (classified) { - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - throw err - } + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import into existing table failed:`, error) - - const classified = asOrchestrationError(error) - return NextResponse.json( - { error: classified ? classified.message : 'Failed to import CSV' }, - { status: classified ? statusForOrchestrationError(classified.code) : 500 } - ) + return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() - // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index f84f457e820..ef4f1cc7547 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,40 +1,20 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { - batchInsertRows, - CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, - CSV_SCHEMA_SAMPLE_SIZE, - coerceRowsForTable, - createCsvParser, - createTable, - deleteTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - sanitizeName, - TABLE_LIMITS, - type TableDefinition, - type TableSchema, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - csvProxyBodyCapResponse, - multipartErrorResponse, - normalizeColumn, - orchestrationErrorResponse, -} from '@/app/api/table/utils' +import { csvProxyBodyCapResponse, multipartErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') @@ -125,135 +105,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionResult.data === 'tsv' ? '\t' : ',' - ) - let csvHeaders: string[] = [] - const parser = createCsvParser(delimiter, (headers) => { - csvHeaders = headers + const outcome = await performCreateTableFromCsv({ + workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionResult.data === 'tsv' ? '\t' : ',', + folderId, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (err) => parser.destroy(err)) - csvStream.pipe(parser) - - interface ImportState { - table: TableDefinition - schema: TableSchema - headerToColumn: Map - } - - const insertRows = async ( - rows: Record[], - state: ImportState, - currentRowCount: number - ) => { - if (rows.length === 0) return 0 - const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone }) - const result = await batchInsertRows( - { tableId: state.table.id, rows: coerced, workspaceId, userId }, - // The created table's rowCount is frozen at 0; pass the running total so the - // per-batch capacity check sees cumulative rows, not an always-empty table. - { ...state.table, rowCount: currentRowCount }, - generateId().slice(0, 8) - ) - return result.length - } - /** Infer the schema from the buffered sample and create the (empty) table. */ - const buildTable = async (sampleRows: Record[]): Promise => { - const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) - const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } - const planLimits = await getWorkspaceTableLimits(workspaceId) - const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const table = await createTable( - { - name: tableName, - description: `Imported from ${file.filename}`, - schema, - workspaceId, - folderId, - userId, - maxTables: planLimits.maxTables, - }, - requestId + if (!outcome.success) { + return NextResponse.json( + { error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - // Coerce against the *created* schema so rows key by the ids `createTable` - // assigned (the local `schema` is the id-less inferred one). - return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } } - let state: ImportState | null = null - let inserted = 0 - const sample: Record[] = [] - let batch: Record[] = [] - - try { - for await (const record of parser as AsyncIterable>) { - if (!state) { - sample.push(record) - if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } - continue - } - batch.push(record) - if (batch.length >= CSV_MAX_BATCH_SIZE) { - inserted += await insertRows(batch, state, inserted) - batch = [] - } - } - - if (!state) { - if (sample.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } else { - inserted += await insertRows(batch, state, inserted) - } - } catch (streamError) { - if (state) await deleteTable(state.table.id, requestId).catch(() => {}) - throw streamError - } - - logger.info(`[${requestId}] CSV imported`, { - tableId: state.table.id, - fileName: file.filename, - columns: state.schema.columns.length, - rows: inserted, - }) - - return NextResponse.json({ - success: true, - data: { - table: { - id: state.table.id, - name: state.table.name, - description: state.table.description, - schema: state.schema, - rowCount: inserted, - }, - }, - }) + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import failed:`, error) - - // Every caller-fixable failure on this path — the plan row-limit check, the - // schema and CSV-shape validation, a name collision — arrives classified. - const classified = orchestrationErrorResponse(error) - if (classified) return classified - return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 5fb64caf1df..7d3222a08d5 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -35,9 +35,18 @@ export type ApiEndpoint = | 'audit-logs' | 'tables' | 'table-detail' + | 'table-restore' | 'table-rows' | 'table-row-detail' + | 'table-rows-find' | 'table-columns' + | 'table-views' + | 'table-view-detail' + | 'table-groups' + | 'table-enrichment' + | 'table-import' + | 'table-export' + | 'table-jobs' | 'files' | 'file-detail' | 'file-share' diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts new file mode 100644 index 00000000000..fed9a372b17 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + * + * Public v2 cancel-runs — stops workflow/enrichment cell runs, as opposed to + * `job/cancel`, which stops an import or delete. The predicate translates to + * storage keys before the cancel so an unknown field 400s rather than becoming + * a cancel that silently matches nothing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockCancelRuns, + mockPredicateToFilter, + mockSignalRowsChanged, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + 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/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts new file mode 100644 index 00000000000..e3dc1b23c0b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + * + * Public v2 column run. The public predicate is column-NAME keyed and the + * dispatcher compiles a storage-keyed legacy filter, so the route translates + * before dispatching — an unknown field must 400 here rather than becoming a + * run that silently matches nothing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockPredicateToFilter, + mockSignalRowsChanged, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/columns/run', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('dispatches the run and returns the dispatch id', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + filter: undefined, + triggeredByUserId: 'user-1', + }) + ) + // The bulk clear is a row change even when the dispatch is a no-op. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('Unknown column "nope"') + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s rowIds and filter together', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s an empty groupIds list', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: [] }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..f534f57f5fd --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + v2BulkPredicateToFilter, + v2TableAccessError, + v2TableLockError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRunColumnAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. + * + * Asynchronous: the response acknowledges the dispatch, not the results. The + * dispatcher walks the scoped rows and writes cells as runs land, so callers + * poll the row endpoints. `dispatchId` is `null` where no background runner is + * configured and cells execute inline. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the dispatcher compiles the + // storage-keyed legacy filter. Translating up front also makes an unknown + // field a 400 here rather than a dispatch that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + filter: legacyFilter, + excludeRowIds, + limit, + requestId, + triggeredByUserId: userId, + }) + + // Starting a run clears the target groups' cells to pending — a row change + // open readers must pick up. + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running table columns`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts new file mode 100644 index 00000000000..fafe14ee422 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment node + * + * Public v2 background export. Export jobs are read-only, so `read` access is + * enough and the job bypasses the one-write-job-per-table gate. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockMarkTableJobRunning, + mockRunDetached, + mockRecordAudit, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockRunDetached: vi.fn(), + mockRecordAudit: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_EXPORTED: 'table.exported' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: vi.fn(), +})) +vi.mock('@/lib/table/export-runner', () => ({ runTableExport: vi.fn() })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/export-async/route' + +const TABLE = { + id: 'table-1', + name: 'customers', + workspaceId: 'ws-1', + rowCount: 3, + schema: { columns: [] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/export-async', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/export-async', () => { + it('queues the export and returns its job id', async () => { + const res = await callPost({ workspaceId: 'ws-1', format: 'csv' }) + + expect(res.status).toBe(200) + const { data } = await res.json() + expect(data.tableId).toBe('table-1') + expect(data.jobId).toEqual(expect.any(String)) + // Typed `export` so the partial-unique index lets it run alongside a write job. + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', data.jobId, 'export', { + format: 'csv', + }) + expect(mockRunDetached).toHaveBeenCalledWith('table-export', expect.any(Function)) + }) + + it('defaults the format to csv', async () => { + await callPost({ workspaceId: 'ws-1' }) + + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', expect.any(String), 'export', { + format: 'csv', + }) + }) + + it('audits at authorization so an abandoned job still records the request', async () => { + await callPost({ workspaceId: 'ws-1' }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'table-1', + metadata: expect.objectContaining({ async: true }), + }) + ) + }) + + it('409s when the claim is lost', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(409) + expect(mockRunDetached).not.toHaveBeenCalled() + }) + + it('400s an unsupported format', async () => { + const res = await callPost({ workspaceId: 'ws-1', format: 'xml' }) + + expect(res.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts new file mode 100644 index 00000000000..39128148ce5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts @@ -0,0 +1,129 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ExportTableAsyncContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportAsyncAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/export-async — Start a background export. + * + * Export jobs are read-only, so they bypass the one-write-job-per-table gate + * (the partial-unique index excludes them) and can run alongside an import or + * delete. Poll `GET /api/v2/tables/jobs`, then fetch the file from + * `GET /export/download` once the job reports `ready`. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportTableAsyncContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const jobId = generateId() + const jobPayload: TableExportJobPayload = { format } + if (!(await markTableJobRunning(tableId, jobId, 'export', jobPayload))) { + return v2Error('CONFLICT', 'Failed to start export') + } + + const payload: TableExportPayload = { jobId, tableId, workspaceId, format } + if (isTriggerDevEnabled) { + try { + const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-export', payload, { + tags: [`tableId:${tableId}`, `jobId:${jobId}`], + region: await resolveTriggerRegion(), + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job behind. + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-export', () => runTableExport(payload)) + } + + // Audit at authorization (like the streaming route) so an abandoned job + // still records that the data was requested. + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount, async: true }, + request, + }) + captureServerEvent( + userId, + 'table_exported', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) + + return v2Data({ tableId, jobId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error starting async export`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts new file mode 100644 index 00000000000..11183817ff3 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + * + * Public v2 export download. The three failure modes are deliberately + * distinct — a caller polling to completion has to tell "not yet" (409) from + * "never again" (410) from "wrong id" (404). + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableJob, + mockPresignedUrl, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockPresignedUrl: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: mockPresignedUrl, +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/[tableId]/export/download/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } +const READY_JOB = { + type: 'export', + status: 'ready', + payload: { format: 'csv', resultKey: 'workspace/ws-1/exports/customers.csv' }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/export/download?workspaceId=ws-1&jobId=job-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableJob.mockResolvedValue(READY_JOB) + mockPresignedUrl.mockResolvedValue('https://storage.example/signed') + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/export/download', () => { + it('issues a presigned URL for a ready job', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + url: 'https://storage.example/signed', + fileName: 'customers.csv', + }) + expect(mockGetTableJob).toHaveBeenCalledWith('table-1', 'job-1') + expect(mockPresignedUrl).toHaveBeenCalledWith( + 'workspace/ws-1/exports/customers.csv', + 'workspace' + ) + }) + + it('404s a job id that is not an export of this table', async () => { + mockGetTableJob.mockResolvedValue({ type: 'import', status: 'ready' }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockPresignedUrl).not.toHaveBeenCalled() + }) + + it('409s a job that is still running — retry later, not a dead end', async () => { + mockGetTableJob.mockResolvedValue({ ...READY_JOB, status: 'running' }) + + const res = await callGet() + + expect(res.status).toBe(409) + expect((await res.json()).error.message).toBe('Export is not ready') + }) + + it('410s once the generated file has aged out of storage', async () => { + mockGetTableJob.mockResolvedValue({ ...READY_JOB, payload: { format: 'csv' } }) + + const res = await callGet() + + expect(res.status).toBe(410) + expect(mockPresignedUrl).not.toHaveBeenCalled() + }) + + it('400s a request with no jobId', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/export/download?workspaceId=ws-1', + { method: 'GET' } + ) + const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(res.status).toBe(400) + expect(mockGetTableJob).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetTableJob).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockGetTableJob).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts new file mode 100644 index 00000000000..d31fafd4d1c --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ExportDownloadContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableJob } from '@/lib/table/jobs/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportDownloadAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/export/download — Presigned URL for a finished + * export. + * + * The three failure modes are deliberately distinct: a job that isn't an export + * of this table is 404, one still running is 409 (retry later), and one whose + * generated file has aged out of storage is 410 (start a new export) — a caller + * polling to completion needs to tell "not yet" from "never again". + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportDownloadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const job = await getTableJob(tableId, jobId) + if (!job || job.type !== 'export') return v2Error('NOT_FOUND', 'Export job not found') + if (job.status !== 'ready') return v2Error('CONFLICT', 'Export is not ready') + + const payload = job.payload as TableExportJobPayload | null + if (!payload?.resultKey) { + return v2Error('NOT_FOUND', 'Export file is no longer available', { status: 410 }) + } + + const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') + const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` + + logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) + + return v2Data({ url, fileName }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error issuing export download URL`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts new file mode 100644 index 00000000000..82ebb10a801 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + * + * Public v2 streaming export — the one v2 success body that is a file rather + * than the `{ data }` envelope. The audit is recorded BEFORE the first byte: + * rows leave incrementally, so a mid-stream failure has still exfiltrated + * whatever was written. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockCreateExportStream, + mockRecordAudit, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockCreateExportStream: vi.fn(), + mockRecordAudit: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_EXPORTED: 'table.exported' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/export-stream', () => ({ + createTableExportStream: mockCreateExportStream, + exportContentType: (format: string) => + format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', + sanitizeExportFilename: (name: string) => name, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/[tableId]/export/route' + +const TABLE = { + id: 'table-1', + name: 'customers', + workspaceId: 'ws-1', + rowCount: 3, + schema: { columns: [] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet(query = 'workspaceId=ws-1') { + const req = new NextRequest(`http://localhost:3000/api/v2/tables/table-1/export?${query}`, { + method: 'GET', + }) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockCreateExportStream.mockReturnValue( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('email\na@b.c\n')) + controller.close() + }, + }) + ) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/export', () => { + it('streams the file with the rate-limit and attachment headers', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8') + expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="customers.csv"') + // The envelope carries these on every other v2 endpoint; a stream response + // has to set them by hand or the whole surface stops being uniform. + expect(res.headers.get('X-RateLimit-Limit')).toBe('100') + expect(await res.text()).toBe('email\na@b.c\n') + expect(mockCreateExportStream).toHaveBeenCalledWith(TABLE, 'csv', expect.any(String)) + }) + + it('defaults to csv and honours an explicit json format', async () => { + const res = await callGet('workspaceId=ws-1&format=json') + + expect(res.headers.get('Content-Type')).toBe('application/json') + expect(mockCreateExportStream).toHaveBeenCalledWith(TABLE, 'json', expect.any(String)) + }) + + it('audits before the first byte leaves', async () => { + await callGet() + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'table-1', actorId: 'user-1' }) + ) + }) + + it('400s an unsupported format', async () => { + const res = await callGet('workspaceId=ws-1&format=xml') + + expect(res.status).toBe(400) + expect(mockCreateExportStream).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCreateExportStream).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockCreateExportStream).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockCreateExportStream).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export/route.ts new file mode 100644 index 00000000000..6570551b5c1 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/export/route.ts @@ -0,0 +1,111 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { v2ExportTableContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + createTableExportStream, + exportContentType, + sanitizeExportFilename, +} from '@/lib/table/export-stream' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/export — Stream the whole table as a file. + * + * The one v2 endpoint whose success body is NOT the `{ data }` envelope: the + * body is the file. Rate-limit headers are attached by hand for the same + * reason. Errors before the first byte still use the canonical envelope; once + * the stream has started a failure can only tear the connection down, which is + * why large tables belong on the async export. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = access + + // Audit BEFORE streaming: rows leave incrementally, so a mid-stream failure + // has still exfiltrated whatever was written. + recordAudit({ + workspaceId: table.workspaceId ?? null, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Exported table "${table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: table.rowCount }, + request, + }) + captureServerEvent( + userId, + 'table_exported', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return new NextResponse(createTableExportStream(table, format, requestId), { + status: 200, + headers: { + ...rateLimitHeaders(rateLimit), + 'Content-Type': exportContentType(format), + 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, + 'Cache-Control': 'private, no-store', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error exporting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts new file mode 100644 index 00000000000..f42f437eb8a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + * + * Public v2 workflow-group listing — a read-only projection of the table's + * schema, exposed so a caller can discover the group ids the run endpoints + * take. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockGateError } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGateError: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/[tableId]/groups/route' + +const GROUP = { + id: 'group-1', + workflowId: 'wf-1', + name: 'Enrich', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], +} +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [], workflowGroups: [GROUP] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('GET /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + }) + + it('returns the schema groups as one full page', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null }) + }) + + it('returns an empty page for a table with no groups', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } }) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [], nextCursor: null }) + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + }) + + it('400s a request with no workspaceId', async () => { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method: 'GET', + }) + const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(res.status).toBe(400) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts new file mode 100644 index 00000000000..0f4bc8e3fd7 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -0,0 +1,76 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListWorkflowGroupsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableGroupsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. + * + * Read-only: groups are authored in the workflow builder, and the public + * surface exposes them so a caller can discover the `groupIds` the run + * endpoints take. Groups live on the table's schema, so this is a projection of + * the already-loaded definition rather than a second query, and the set is + * bounded per table — one full page, `nextCursor` always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const groups = (result.table.schema as TableSchema).workflowGroups ?? [] + + return v2CursorList(groups, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow groups`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts new file mode 100644 index 00000000000..4cca287b800 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + * + * Public v2 background import. Two orderings are load-bearing: the + * client-supplied `fileKey` is checked against the workspace's own storage + * prefix, and the table's locks are asserted BEFORE the single write-job slot + * is claimed so a locked table never holds the slot. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockRunDetached, + mockAssertRowInsert, + mockAssertRowDelete, + mockGateError, + TableLockedError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockRunDetached: vi.fn(), + mockAssertRowInsert: vi.fn(), + mockAssertRowDelete: vi.fn(), + mockGateError: vi.fn(), + TableLockedError: class TableLockedError 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>()), + v2TableLockError: (error: unknown) => + error instanceof TableLockedError + ? new Response(JSON.stringify({ error: { code: 'LOCKED', message: error.message } }), { + status: 423, + }) + : null, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/mutation-locks', () => ({ + assertRowInsert: mockAssertRowInsert, + assertRowDelete: mockAssertRowDelete, + assertSchemaMutable: vi.fn(), + TableLockedError, +})) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/users/queries', () => ({ + getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/import-async/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] }, archivedAt: null } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const BODY = { + workspaceId: 'ws-1', + fileKey: 'workspace/ws-1/imports/contacts.csv', + fileName: 'contacts.csv', + mode: 'append', +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/import-async', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockMarkTableJobRunning.mockResolvedValue(true) + // `clearAllMocks` drops recorded calls but keeps implementations, so the + // throwing lock assertion below would leak into every later test. + mockAssertRowInsert.mockImplementation(() => {}) + mockAssertRowDelete.mockImplementation(() => {}) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/import-async', () => { + it('claims the job slot and dispatches the import', async () => { + const res = await callPost(BODY) + + expect(res.status).toBe(200) + const { data } = await res.json() + expect(data.tableId).toBe('table-1') + expect(data.importId).toEqual(expect.any(String)) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', data.importId, 'import') + expect(mockRunDetached).toHaveBeenCalledWith('table-import', expect.any(Function)) + }) + + it('rejects a fileKey outside the workspace prefix', async () => { + const res = await callPost({ ...BODY, fileKey: 'workspace/ws-other/imports/contacts.csv' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('Invalid file key for workspace') + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('asserts the insert lock BEFORE claiming the slot, so a locked table never holds it', async () => { + mockAssertRowInsert.mockImplementation(() => { + throw new TableLockedError('Inserts are locked for this table') + }) + + const res = await callPost(BODY) + + expect(res.status).toBe(423) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('asserts the delete lock too when the mode replaces rows', async () => { + await callPost({ ...BODY, mode: 'replace' }) + + expect(mockAssertRowDelete).toHaveBeenCalledWith(TABLE) + }) + + it('409s when another job already holds the slot', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const res = await callPost(BODY) + + expect(res.status).toBe(409) + expect(mockRunDetached).not.toHaveBeenCalled() + }) + + it('400s an unsupported file extension', async () => { + const res = await callPost({ ...BODY, fileName: 'contacts.xlsx' }) + + expect(res.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('400s a body missing fileKey', async () => { + const res = await callPost({ workspaceId: 'ws-1', fileName: 'c.csv', mode: 'append' }) + + expect(res.status).toBe(400) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost(BODY) + + expect(res.status).toBe(403) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost(BODY) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost(BODY) + + expect(res.status).toBe(429) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts new file mode 100644 index 00000000000..474b59a4ef0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts @@ -0,0 +1,148 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ImportTableAsyncContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' +import { getUserSettings } from '@/lib/users/queries' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportAsyncAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/import-async — Start a background import. + * + * The file must already be in the workspace's storage; `fileKey` is + * client-supplied, so it is checked against the workspace's own prefix — a + * caller must not be able to import another workspace's uploaded object. + * Progress is observable through `GET /api/v2/tables/jobs` and the job can be + * stopped with `POST /job/cancel`. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ImportTableAsyncContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = + parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + const { table } = access + if (table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { + return v2Error('BAD_REQUEST', 'Invalid file key for workspace') + } + if (table.archivedAt) { + return v2Error('BAD_REQUEST', 'Cannot import into an archived table') + } + + const extension = fileName.split('.').pop()?.toLowerCase() + if (extension !== 'csv' && extension !== 'tsv') { + return v2Error('BAD_REQUEST', 'Only CSV and TSV files are supported') + } + + // Gate the locks BEFORE claiming the single write-job slot, so a locked + // table reports 423 here instead of holding the slot and failing inside the + // worker. + assertRowInsert(table) + if (mode === 'replace') assertRowDelete(table) + if (createColumns && createColumns.length > 0) assertSchemaMutable(table) + + const importId = generateId() + if (!(await markTableJobRunning(tableId, importId, 'import'))) { + return v2Error('CONFLICT', 'A job is already in progress for this table') + } + + const payload: TableImportPayload = { + importId, + tableId, + workspaceId, + userId, + fileKey, + fileName, + delimiter: extension === 'tsv' ? '\t' : ',', + mode, + mapping, + createColumns, + timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', + } + + if (isTriggerDevEnabled) { + // Runs outside the web container, so the import survives app deploys. + try { + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-import', payload, { + tags: [`tableId:${tableId}`, `jobId:${importId}`], + region: await resolveTriggerRegion(), + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, importId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(payload)) + } + + logger.info(`[${requestId}] Async CSV import started`, { tableId, importId, mode, fileName }) + + return v2Data({ tableId, importId }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + logger.error(`[${requestId}] Error starting async import`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts new file mode 100644 index 00000000000..f7f28c4cd93 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts @@ -0,0 +1,233 @@ +/** + * @vitest-environment node + * + * Public v2 synchronous CSV import. The body is multipart, so it never goes + * through `parseRequest`; the collected text fields are parsed against the + * contract's form schema instead, and the whole import is delegated to the + * orchestration function so v1 and v2 cannot drift on what an import does. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockReadMultipart, + mockPerformImport, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockReadMultipart: vi.fn(), + mockPerformImport: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/core/utils/multipart', () => ({ + readMultipart: mockReadMultipart, + isMultipartError: (error: unknown) => + typeof error === 'object' && error !== null && 'code' in error, +})) + +vi.mock('@/lib/table/orchestration', () => ({ performTableCsvImport: mockPerformImport })) +vi.mock('@/lib/table', () => ({ CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024 })) +vi.mock('@/lib/users/queries', () => ({ + getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/import/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const IMPORT_DATA = { + tableId: 'table-1', + mode: 'append', + insertedCount: 3, + mappedColumns: ['Email'], + skippedHeaders: [], + unmappedColumns: [], + sourceFile: 'contacts.csv', +} + +function fileStream() { + return { destroy: vi.fn() } +} + +function callPost(options: { contentLength?: string } = {}) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/import', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=x', + ...(options.contentLength ? { 'content-length': options.contentLength } : {}), + }, + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1', mode: 'append' }, + file: { filename: 'contacts.csv', stream: fileStream() }, + }) + mockPerformImport.mockResolvedValue({ success: true, data: IMPORT_DATA }) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/import', () => { + it('delegates the whole import and returns the summary', async () => { + const res = await callPost() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual(IMPORT_DATA) + expect(mockPerformImport).toHaveBeenCalledWith( + expect.objectContaining({ + table: TABLE, + workspaceId: 'ws-1', + userId: 'user-1', + fileName: 'contacts.csv', + fallbackDelimiter: ',', + mode: 'append', + }) + ) + }) + + it('picks the tab fallback from a .tsv extension', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1' }, + file: { filename: 'contacts.tsv', stream: fileStream() }, + }) + + await callPost() + + expect(mockPerformImport).toHaveBeenCalledWith( + expect.objectContaining({ fallbackDelimiter: '\t', mode: 'append' }) + ) + }) + + it('requires workspaceId ahead of the file part so an unauthorized upload is never read', async () => { + await callPost() + + expect(mockReadMultipart).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ requiredFieldsBeforeFile: ['workspaceId'] }) + ) + }) + + it('413s an oversize body rather than importing a silently truncated file', async () => { + const res = await callPost({ contentLength: String(11 * 1024 * 1024) }) + + expect(res.status).toBe(413) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('400s an unsupported file extension', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1' }, + file: { filename: 'contacts.xlsx', stream: fileStream() }, + }) + + const res = await callPost() + + expect(res.status).toBe(400) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('400s a form with no workspaceId', async () => { + mockReadMultipart.mockResolvedValue({ + fields: {}, + file: { filename: 'contacts.csv', stream: fileStream() }, + }) + + const res = await callPost() + + expect(res.status).toBe(400) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without importing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost() + + expect(res.status).toBe(403) + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it.each([ + ['conflict', 409, 'CONFLICT'], + ['locked', 423, 'LOCKED'], + ['validation', 400, 'BAD_REQUEST'], + ])('maps a %s import failure to %i', async (errorCode, status, code) => { + mockPerformImport.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await callPost() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformImport).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost() + + expect(res.status).toBe(429) + expect(mockPerformImport).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts new file mode 100644 index 00000000000..bd36334f90d --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts @@ -0,0 +1,140 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { csvExtensionSchema } from '@/lib/api/contracts/tables' +import { + v2ImportIntoTableFormSchema, + v2ImportTableCsvContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { performTableCsvImport } from '@/lib/table/orchestration' +import { getUserSettings } from '@/lib/users/queries' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2CsvBodyCapError, v2MultipartError, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/import — Synchronous CSV/TSV import. + * + * `multipart/form-data`, so the body never goes through `parseRequest` — the + * streaming reader consumes the parts and the collected text fields are parsed + * in one pass against the contract's form schema. Auth still runs first: the + * reader is told to require `workspaceId` ahead of the file part so an + * unauthorized upload is rejected before its bytes are read. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + let fileStream: Readable | undefined + + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ImportTableCsvContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + + const oversize = v2CsvBodyCapError(request) + if (oversize) return oversize + + let multipart: Awaited> + try { + multipart = await readMultipart(request, { + maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + requiredFieldsBeforeFile: ['workspaceId'], + signal: request.signal, + }) + } catch (err) { + if (isMultipartError(err)) return v2MultipartError(err) + throw err + } + + const { fields, file } = multipart + if (!file) return v2Error('BAD_REQUEST', 'CSV file is required') + fileStream = file.stream + + const form = v2ImportIntoTableFormSchema.safeParse(fields) + if (!form.success) return v2ValidationError(form.error) + + const extension = csvExtensionSchema.safeParse(file.filename.split('.').pop()?.toLowerCase()) + if (!extension.success) return v2ValidationError(extension.error) + + const scopeError = await resolveWorkspaceScope(rateLimit, form.data.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== form.data.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performTableCsvImport({ + table: access.table, + workspaceId: form.data.workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extension.data === 'tsv' ? '\t' : ',', + mode: form.data.mode, + mapping: form.data.mapping, + createColumns: form.data.createColumns, + timezone: form.data.timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', + requestId, + }) + + if (!outcome.success || !outcome.data) { + // Naming the lock is the difference between an actionable 423 and one the + // caller has to guess at — there are four flags. + if (outcome.errorCode === 'locked') { + return v2Error('LOCKED', outcome.error ?? 'Table is locked', { + details: { lock: outcome.lock }, + }) + } + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to import CSV') + } + + return v2Data(outcome.data, { rateLimit }) + } catch (error) { + if (isMultipartError(error)) return v2MultipartError(error) + + logger.error(`[${requestId}] Error importing CSV into table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + fileStream?.destroy() + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts new file mode 100644 index 00000000000..e0db26f5381 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + * + * Public v2 job cancel — the "stop it" half of the async import/export story. + * Idempotent by design: cancelling a job that already finished reports + * `canceled: false` rather than failing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableJob, + mockMarkJobCanceled, + mockAppendTableEvent, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockMarkJobCanceled: vi.fn(), + mockAppendTableEvent: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + getTableJob: mockGetTableJob, + markJobCanceled: mockMarkJobCanceled, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/job/cancel/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/job/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableJob.mockResolvedValue({ type: 'import' }) + mockMarkJobCanceled.mockResolvedValue(true) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/job/cancel', () => { + it('cancels the job and emits the event with the job’s real type', async () => { + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ jobId: 'job-1', canceled: true }) + expect(mockMarkJobCanceled).toHaveBeenCalledWith('table-1', 'job-1') + // The table-level derivation excludes exports, so the type has to come from + // the job's own row or an export cancel would announce itself as an import. + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'import', jobId: 'job-1', status: 'canceled' }) + ) + }) + + it('reads the type from an export job rather than defaulting', async () => { + mockGetTableJob.mockResolvedValue({ type: 'export' }) + + await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(mockAppendTableEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'export' })) + }) + + it('reports canceled: false for a job that already finished, and emits nothing', async () => { + mockMarkJobCanceled.mockResolvedValue(false) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ jobId: 'job-1', canceled: false }) + expect(mockAppendTableEvent).not.toHaveBeenCalled() + }) + + it('400s a body with no jobId', async () => { + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without cancelling', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(404) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(403) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) + + expect(res.status).toBe(429) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts new file mode 100644 index 00000000000..42969c1fcb6 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelTableJobContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { appendTableEvent } from '@/lib/table/events' +import { getTableJob, markJobCanceled } from '@/lib/table/jobs/service' +import type { TableJobType } from '@/lib/table/types' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableJobCancelAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/job/cancel — Stop an in-flight import or delete. + * + * Flips the job's status so the worker's next ownership check fails and it + * stops. Work already committed (rows inserted or deleted) is left in place — + * there is no rollback. Idempotent: cancelling a job that already finished + * reports `canceled: false` rather than failing, so a client racing the + * worker's completion is not an error. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-jobs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CancelTableJobContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // Resolve the job's real type from its own row — the table-level derivation + // excludes exports — so the cancel event carries the right `type`. + const job = await getTableJob(tableId, jobId) + const type = (job?.type ?? 'import') as TableJobType + + const canceled = await markJobCanceled(tableId, jobId) + if (canceled) { + void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) + } + + logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) + + return v2Data({ jobId, canceled }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error cancelling table job`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts new file mode 100644 index 00000000000..8f8f25a834d --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts @@ -0,0 +1,189 @@ +/** + * @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, + 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..609623e4d2d 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -1,8 +1,10 @@ /** * @vitest-environment node * - * Public v2 table delete: the actor is handed to the service so the audit is - * emitted there — and only for a delete that actually archived a row. + * Public v2 table delete and update. Delete hands the actor to the service so + * the audit is emitted there — and only for a delete that actually archived a + * row. Update routes each field to its own orchestration call, and carries the + * first-party permission split: renaming needs `write`, locking needs `admin`. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +14,27 @@ const { mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteTable, + mockPerformRenameTable, + mockPerformMoveTableToFolder, + mockPerformUpdateTableLocks, mockRecordAudit, + mockGetTableById, + mockFindActiveFolder, + mockIsFeatureEnabled, + mockGateError, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), mockCheckAccess: vi.fn(), mockPerformDeleteTable: vi.fn(), + mockPerformRenameTable: vi.fn(), + mockPerformMoveTableToFolder: vi.fn(), + mockPerformUpdateTableLocks: vi.fn(), mockRecordAudit: vi.fn(), + mockGetTableById: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockGateError: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -41,21 +57,63 @@ 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: vi.fn() })) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), +})) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/table/orchestration', () => ({ + performDeleteTable: mockPerformDeleteTable, + performRenameTable: mockPerformRenameTable, + performMoveTableToFolder: mockPerformMoveTableToFolder, + performUpdateTableLocks: mockPerformUpdateTableLocks, })) -import { DELETE } from '@/app/api/v2/tables/[tableId]/route' +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route' -const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [] } } +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 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 +122,27 @@ function callDelete() { return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('DELETE /api/v2/tables/[tableId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) +function callPatch(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableById.mockResolvedValue(UPDATED_TABLE) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockIsFeatureEnabled.mockResolvedValue(true) + mockGateError.mockResolvedValue(null) +}) + +describe('DELETE /api/v2/tables/[tableId]', () => { it('delegates to the orchestration function with the resolved table and actor', async () => { mockPerformDeleteTable.mockResolvedValue({ success: true }) @@ -107,3 +170,148 @@ 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, + 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('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 lock change from a write-level caller', async () => { + mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => + level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } + ) + + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(403) + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('rejects enabling a lock while the feature is off', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(403) + expect((await res.json()).error.message).toBe('Table locks are not enabled') + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('still clears a lock while the feature is off, so a locked table is never stranded', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + mockCheckAccess.mockResolvedValue({ + ok: true, + table: { ...TABLE, locks: { ...UNLOCKED, deleteLocked: true } }, + }) + mockPerformUpdateTableLocks.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: false } }) + + expect(res.status).toBe(200) + expect(mockIsFeatureEnabled).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', partial: { deleteLocked: false } }) + ) + }) + + it('maps a duplicate-name rename to 409 CONFLICT', async () => { + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A table named "Renamed" already exists', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('rejects a body with nothing to change', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without writing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(429) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 55e2d792f8f..e08df9873e8 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,15 +1,31 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v2/tables' +import { + v2DeleteTableContract, + v2GetTableContract, + v2UpdateTableContract, +} from '@/lib/api/contracts/v2/tables' import { parseRequest } from '@/lib/api/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performDeleteTable } from '@/lib/table/orchestration' +import { findActiveFolder } from '@/lib/folders/queries' +import { getTableById } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from '@/lib/table/orchestration' +import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2Data, v2Error, v2ErrorForOrchestration, @@ -69,6 +85,151 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR } }) +/** + * PATCH /api/v2/tables/[tableId] — Rename, move, and/or change lock flags. + * + * Each field routes to its own orchestration call so the audit records the + * operation the caller actually performed. `locks` carries the first-party + * permission split: `write` is the floor for the endpoint, but enabling a lock + * additionally needs workspace `admin` and the `table-locks` feature. Clearing + * a lock stays available with the feature off, or flipping the kill switch + * would strand an already-locked table with no way to unlock it while + * enforcement of the stored locks keeps running. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.locks !== undefined) { + // Only a lock transitioning off→on needs the feature; comparing against + // the stored state is what lets a caller submitting the full flag set + // clear one lock while another stays on. + const enablesALock = TABLE_LOCK_KINDS.some((kind) => { + const flag = TABLE_LOCK_FLAGS[kind] + return validated.locks?.[flag] === true && !table.locks[flag] + }) + if (enablesALock) { + // Resolved against the workspace's host organization, not the caller's + // active one, so an org-targeted rollout can't accept the write here + // and reject it in the first-party UI. + const workspace = await getWorkspaceWithOwner(table.workspaceId) + const enabled = await isFeatureEnabled('table-locks', { + userId, + orgId: workspace?.organizationId ?? undefined, + }) + if (!enabled) return v2Error('FORBIDDEN', 'Table locks are not enabled') + } + + const adminResult = await checkAccess(tableId, userId, 'admin') + if (!adminResult.ok) { + return v2Error('FORBIDDEN', 'Admin access required to change table locks') + } + + const outcome = await performUpdateTableLocks({ + tableId, + partial: validated.locks, + userId, + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration( + outcome.errorCode, + outcome.error ?? 'Failed to update table locks' + ) + } + } + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to rename table') + } + } + + if (validated.folderId !== undefined) { + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't file the table somewhere Tables never lists. + if ( + validated.folderId !== null && + !(await findActiveFolder(validated.folderId, table.workspaceId, 'table')) + ) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + const outcome = await performMoveTableToFolder({ + table, + folderId: validated.folderId, + userId, + requestId, + request, + }) + if (!outcome.success) { + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + return v2ErrorForOrchestration( + outcome.errorCode, + outcome.errorCode === 'not_found' + ? 'Table not found' + : (outcome.error ?? 'Failed to move table') + ) + } + } + + // Live-collab: tell open viewers the definition changed so they refetch. + signalTableSchemaChanged(tableId) + + // Re-read so the response reflects every applied change at once. + const updated = await getTableById(tableId) + if (!updated) return v2Error('NOT_FOUND', 'Table not found') + + return v2Data({ table: toApiTable(updated) }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + /** DELETE /api/v2/tables/[tableId] — Archive a table. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() 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/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts new file mode 100644 index 00000000000..19f38dfb59f --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + * + * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate + * and sort translate down to storage ids on the way in, and the matched column + * id translates back to its name on the way out. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockFindRowMatches, + mockPredicateToFilter, + mockValidateSortSpec, + mockSortSpecNamesToIds, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockFindRowMatches: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockValidateSortSpec: vi.fn(), + mockSortSpecNamesToIds: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table', () => ({ + buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }), + sortSpecNamesToIds: mockSortSpecNamesToIds, +})) +vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches })) +vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' + +const COLUMNS = [ + { id: 'col-1', name: 'status', type: 'string' }, + { id: 'col-2', name: 'name', type: 'string' }, +] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/rows/find', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockFindRowMatches.mockResolvedValue({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }], + truncated: false, + }) + mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) => + spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field })) + ) + mockGateError.mockResolvedValue(null) + }) + + it('reports the matched column by NAME, not its storage id', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], + truncated: false, + }) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: undefined, sort: undefined }, + expect.any(String) + ) + }) + + it('translates the predicate and sort to storage keys before searching', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate, + sort: [{ field: 'name', direction: 'asc' }], + }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockValidateSortSpec).toHaveBeenCalledWith( + [{ field: 'name', direction: 'asc' }], + COLUMNS + ) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } }, + expect.any(String) + ) + }) + + it('surfaces truncation so a caller narrows instead of paging', async () => { + mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'a' }) + + expect((await res.json()).data).toEqual({ matches: [], truncated: true }) + }) + + it('400s an unresolvable predicate field instead of returning zero matches', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('400s an empty search string', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: '' }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(429) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts new file mode 100644 index 00000000000..68d86f3dea5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, Sort, TableSchema } from '@/lib/table' +import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { validateSortSpec } from '@/lib/table/query-builder/validate' +import { findRowMatches } from '@/lib/table/rows/service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsFindAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search + * across every cell, narrowed by the same predicate/sort grammar as + * `POST /query`. + * + * Returns matching CELLS, not rows: each match carries the row's ordinal in the + * same filtered+sorted view a `POST /query` with these arguments would return, + * so a caller can jump straight to the page holding it. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows-find') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2FindTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, q, predicate, sort } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = accessResult + const schema = table.schema as TableSchema + + // The public wire is column-NAME keyed both ways: translate the predicate + // and sort down to storage ids on the way in, and the matched column id + // back to its name on the way out. + let filter: Filter | undefined + if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) + + let sortObj: Sort | undefined + if (sort?.length) { + validateSortSpec(sort, schema.columns) + const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) + sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) + } + + const { matches, truncated } = await findRowMatches( + table, + { q, filter, sort: sortObj }, + requestId + ) + + const toColumnName = columnNameById(schema) + + return v2Data( + { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error finding rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts new file mode 100644 index 00000000000..25488f0ad26 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -0,0 +1,240 @@ +/** + * @vitest-environment node + * + * Public v2 saved-view detail: read, patch, delete. A view that is not on this + * table is a 404 rather than a silent no-op, so a caller can tell a wrong id + * from a successful write. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableView, + mockUpdateTableView, + mockDeleteTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableView: vi.fn(), + mockUpdateTableView: vi.fn(), + mockDeleteTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + getTableView: mockGetTableView, + updateTableView: mockUpdateTableView, + deleteTableView: mockDeleteTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } + +function callGet() { + return GET( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'GET', + }), + params + ) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + params + ) +} + +function callDelete() { + return DELETE( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'DELETE', + }), + params + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the view scoped to its table', async () => { + mockGetTableView.mockResolvedValue(VIEW) + + const res = await callGet() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS) + }) + + it('404s a view id that belongs to a different table', async () => { + mockGetTableView.mockResolvedValue(null) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('View not found') + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockGetTableView).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => { + it('forwards the patch fields to the service', async () => { + mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(200) + expect((await res.json()).data.view.isDefault).toBe(true) + expect(mockUpdateTableView).toHaveBeenCalledWith({ + viewId: 'view-1', + tableId: 'table-1', + name: undefined, + config: undefined, + configPatch: undefined, + isDefault: true, + columns: COLUMNS, + }) + }) + + it('400s a body that changes nothing', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('400s config and configPatch together', async () => { + const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(403) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the deleted view id', async () => { + mockDeleteTableView.mockResolvedValue(true) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ id: 'view-1' }) + expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1') + }) + + it('404s when nothing was deleted rather than reporting a phantom success', async () => { + mockDeleteTableView.mockResolvedValue(false) + + const res = await callDelete() + + expect(res.status).toBe(404) + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callDelete() + + expect(res.status).toBe(403) + expect(mockDeleteTableView).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts new file mode 100644 index 00000000000..ba29f7665c0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -0,0 +1,183 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableViewContract, + v2GetTableViewContract, + v2UpdateTableViewContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { + deleteTableView, + getTableView, + TableViewValidationError, + updateTableView, +} from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableViewRouteParams { + params: Promise<{ tableId: string; viewId: string }> +} + +/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the + * config, or promote the view to the table's default. + */ +export const PATCH = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await updateTableView({ + viewId, + tableId, + name, + config, + configPatch, + isDefault, + columns: (result.table.schema as TableSchema).columns, + }) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error updating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const deleted = await deleteTableView(viewId, tableId) + if (!deleted) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ id: viewId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts new file mode 100644 index 00000000000..8a789e0de94 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + * + * Public v2 saved views: list and create. A view is presentation state, so the + * read needs only `read` while saving one needs `write`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockListTableViews, + mockCreateTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockListTableViews: vi.fn(), + mockCreateTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + listTableViews: mockListTableViews, + createTableView: mockCreateTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } }, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views', () => { + it('returns every view as one full page with ISO timestamps', async () => { + mockListTableViews.mockResolvedValue([VIEW]) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null }) + // The columns are passed so stale references are pruned from each config. + expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS) + }) + + it('404s a table in another workspace without listing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockListTableViews).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/v2/tables/[tableId]/views', () => { + it('creates the view with the caller as author and answers 201', async () => { + mockCreateTableView.mockResolvedValue(VIEW) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(201) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockCreateTableView).toHaveBeenCalledWith({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Active', + config: {}, + userId: 'user-1', + columns: COLUMNS, + }) + }) + + it('400s a blank view name without touching the service', async () => { + const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} }) + + expect(res.status).toBe(400) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(403) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('surfaces a service-level view validation failure as 400', async () => { + mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty')) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('View name cannot be empty') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts new file mode 100644 index 00000000000..be0dcbe0fa7 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -0,0 +1,127 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/views — Every saved view on the table. + * + * A table carries a bounded set of views, so this is one full page and + * `nextCursor` is always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListTableViewsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) + + return v2CursorList(views.map(toApiView), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing table views`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, name, config } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await createTableView({ + tableId, + workspaceId, + name, + config, + userId, + columns: (result.table.schema as TableSchema).columns, + }) + + return v2Data({ view: toApiView(view) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error creating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.test.ts b/apps/sim/app/api/v2/tables/import-csv/route.test.ts new file mode 100644 index 00000000000..14821ddd520 --- /dev/null +++ b/apps/sim/app/api/v2/tables/import-csv/route.test.ts @@ -0,0 +1,227 @@ +/** + * @vitest-environment node + * + * Public v2 create-table-from-CSV. Workspace-scoped rather than table-scoped — + * there is no table to authorize against yet — and the response is re-read + * through `toApiTable` so it carries the same table shape as every other v2 + * endpoint rather than the import's partial view. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockReadMultipart, + mockPerformCreate, + mockGetTableById, + mockFindActiveFolder, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockReadMultipart: vi.fn(), + mockPerformCreate: vi.fn(), + mockGetTableById: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/core/utils/multipart', () => ({ + readMultipart: mockReadMultipart, + isMultipartError: (error: unknown) => + typeof error === 'object' && error !== null && 'code' in error, +})) + +vi.mock('@/lib/table/orchestration', () => ({ performCreateTableFromCsv: mockPerformCreate })) +vi.mock('@/lib/table', () => ({ + CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024, + getTableById: mockGetTableById, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/users/queries', () => ({ + getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/import-csv/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const CREATED_TABLE = { + id: 'table-1', + name: 'contacts', + description: 'Imported from contacts.csv', + workspaceId: 'ws-1', + schema: { columns: [] }, + rowCount: 3, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(options: { contentLength?: string } = {}) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/import-csv', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=x', + ...(options.contentLength ? { 'content-length': options.contentLength } : {}), + }, + }) + return POST(req) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1' }, + file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, + }) + mockPerformCreate.mockResolvedValue({ success: true, data: { table: { id: 'table-1' } } }) + mockGetTableById.mockResolvedValue(CREATED_TABLE) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/import-csv', () => { + it('creates the table and answers 201 with the canonical table shape', async () => { + const res = await callPost() + + expect(res.status).toBe(201) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'contacts', + description: 'Imported from contacts.csv', + schema: { columns: [] }, + rowCount: 3, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }) + expect(mockPerformCreate).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + userId: 'user-1', + fileName: 'contacts.csv', + fallbackDelimiter: ',', + folderId: null, + }) + ) + }) + + it('checks a supplied folder is a table folder in this workspace', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1', folderId: 'folder-1' }, + file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, + }) + + await callPost() + + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') + expect(mockPerformCreate).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }) + ) + }) + + it('404s a folder from outside the workspace without importing', async () => { + mockReadMultipart.mockResolvedValue({ + fields: { workspaceId: 'ws-1', folderId: 'folder-elsewhere' }, + file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, + }) + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('413s an oversize body rather than importing a silently truncated file', async () => { + const res = await callPost({ contentLength: String(11 * 1024 * 1024) }) + + expect(res.status).toBe(413) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('403s a caller without workspace write', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const res = await callPost() + + expect(res.status).toBe(403) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('400s a file with no data rows', async () => { + mockPerformCreate.mockResolvedValue({ + success: false, + errorCode: 'validation', + error: 'CSV file has no data rows', + }) + + const res = await callPost() + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('CSV file has no data rows') + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost() + + expect(res.status).toBe(404) + expect(mockReadMultipart).not.toHaveBeenCalled() + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost() + + expect(res.status).toBe(429) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.ts b/apps/sim/app/api/v2/tables/import-csv/route.ts new file mode 100644 index 00000000000..d207c4cff91 --- /dev/null +++ b/apps/sim/app/api/v2/tables/import-csv/route.ts @@ -0,0 +1,140 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { csvExtensionSchema } from '@/lib/api/contracts/tables' +import { + v2CreateTableFromCsvContract, + v2CreateTableFromCsvFormSchema, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findActiveFolder } from '@/lib/folders/queries' +import { CSV_MAX_FILE_SIZE_BYTES, getTableById } from '@/lib/table' +import { performCreateTableFromCsv } from '@/lib/table/orchestration' +import { getUserSettings } from '@/lib/users/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable, v2CsvBodyCapError, v2MultipartError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2CreateTableFromCsvAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +/** + * POST /api/v2/tables/import-csv — Create a table from a CSV/TSV. + * + * The column schema is inferred from the file's first rows and the table is + * named after the file. Workspace-scoped rather than table-scoped, so the + * permission check is the workspace one — there is no table to authorize + * against yet. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + let fileStream: Readable | undefined + + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateTableFromCsvContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const oversize = v2CsvBodyCapError(request) + if (oversize) return oversize + + let multipart: Awaited> + try { + multipart = await readMultipart(request, { + maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + requiredFieldsBeforeFile: ['workspaceId'], + signal: request.signal, + }) + } catch (err) { + if (isMultipartError(err)) return v2MultipartError(err) + throw err + } + + const { fields, file } = multipart + if (!file) return v2Error('BAD_REQUEST', 'CSV file is required') + fileStream = file.stream + + const form = v2CreateTableFromCsvFormSchema.safeParse(fields) + if (!form.success) return v2ValidationError(form.error) + + const extension = csvExtensionSchema.safeParse(file.filename.split('.').pop()?.toLowerCase()) + if (!extension.success) return v2ValidationError(extension.error) + + const accessError = await resolveWorkspaceAccess( + rateLimit, + userId, + form.data.workspaceId, + 'write' + ) + if (accessError) return v2WorkspaceAccessError(accessError) + + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't file the imported table where Tables never lists it. + if ( + form.data.folderId && + !(await findActiveFolder(form.data.folderId, form.data.workspaceId, 'table')) + ) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + + const outcome = await performCreateTableFromCsv({ + workspaceId: form.data.workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extension.data === 'tsv' ? '\t' : ',', + folderId: form.data.folderId ?? null, + timezone: form.data.timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', + requestId, + }) + + if (!outcome.success || !outcome.data) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to import CSV') + } + + // Re-read so the response carries the canonical v2 table shape (row count, + // plan row cap, timestamps) rather than the import's partial view. + const table = await getTableById(outcome.data.table.id) + if (!table) return v2Error('INTERNAL_ERROR', 'Internal server error') + + return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) + } catch (error) { + if (isMultipartError(error)) return v2MultipartError(error) + + logger.error(`[${requestId}] Error creating table from CSV`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + fileStream?.destroy() + } +}) diff --git a/apps/sim/app/api/v2/tables/jobs/route.test.ts b/apps/sim/app/api/v2/tables/jobs/route.test.ts new file mode 100644 index 00000000000..749c29fd70f --- /dev/null +++ b/apps/sim/app/api/v2/tables/jobs/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + * + * Public v2 export-job listing — the observability half of the async + * import/export story. Workspace-scoped, so the permission check is the + * workspace one rather than a table's. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListJobs, mockGateError } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListJobs: vi.fn(), + mockGateError: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/table/jobs/service', () => ({ listWorkspaceExportJobs: mockListJobs })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET } from '@/app/api/v2/tables/jobs/route' + +const JOB = { + jobId: 'job-1', + tableId: 'table-1', + tableName: 'customers', + status: 'ready', + rowsProcessed: 12, + format: 'csv', + hasResult: true, + error: null, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet(query = 'workspaceId=ws-1&type=export') { + return GET( + new NextRequest(`http://localhost:3000/api/v2/tables/jobs?${query}`, { method: 'GET' }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListJobs.mockResolvedValue([JOB]) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/jobs', () => { + it('returns the workspace export jobs as one full page', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [JOB], nextCursor: null }) + expect(mockListJobs).toHaveBeenCalledWith('ws-1') + }) + + it('400s a request with no type, so widening the parameter can never surprise a caller', async () => { + const res = await callGet('workspaceId=ws-1') + + expect(res.status).toBe(400) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('400s an unsupported job type', async () => { + const res = await callGet('workspaceId=ws-1&type=import') + + expect(res.status).toBe(400) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('403s a caller without workspace access', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const res = await callGet() + + expect(res.status).toBe(403) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListJobs).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockListJobs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/jobs/route.ts b/apps/sim/app/api/v2/tables/jobs/route.ts new file mode 100644 index 00000000000..77d1067920f --- /dev/null +++ b/apps/sim/app/api/v2/tables/jobs/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListTableJobsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableJobsAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * GET /api/v2/tables/jobs — Export jobs across a workspace. + * + * Export-only today, and `type` is a required literal rather than a default so + * the parameter can widen to other job kinds later without silently changing + * what an existing caller receives. Running jobs plus recently finished ones, + * so a completed export stays re-downloadable. Workspace-scoped, so the + * permission check is the workspace one. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-jobs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListTableJobsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const accessError = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (accessError) return v2WorkspaceAccessError(accessError) + + const jobs = await listWorkspaceExportJobs(workspaceId) + + return v2CursorList(jobs, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing table jobs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 8d662be3d4a..d4beaf7b789 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,7 @@ import type { NextResponse } from 'next/server' +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 { @@ -8,7 +10,13 @@ import { } 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 type { TableView } from '@/lib/table/views/service' +import { + CSV_IMPORT_PROXY_BODY_CAP_BYTES, + normalizeColumn, + rootErrorMessage, + rowWriteErrorResponse, +} from '@/app/api/table/utils' import { v2Error } from '@/app/api/v2/lib/response' /** @@ -54,11 +62,40 @@ export function toApiTable(table: TableDefinition) { }, rowCount: table.rowCount, maxRows: table.maxRows, + folderId: table.folderId ?? null, + locks: table.locks, 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 +122,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 diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 89e68b9715f..6b4ccb84396 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1534,44 +1534,60 @@ export const deleteWorkflowGroupContract = defineRouteContract({ * cells on rows matching it (filtered "select all" Stop) * - `row` — every running/pending cell for a specific row (`rowId` required) */ -export const cancelTableRunsBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - scope: z.enum(['all', 'row']), - rowId: z.string().min(1).optional(), - filter: z.union([predicateSchema, domainObjectSchema()]).optional(), - /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - }) - .superRefine((value, ctx) => { - if (value.scope === 'row' && !value.rowId) { - ctx.addIssue({ - code: 'custom', - path: ['rowId'], - message: 'rowId is required when scope is "row"', - }) - } - if (value.scope === 'row' && value.filter) { - ctx.addIssue({ - code: 'custom', - path: ['filter'], - message: 'filter only applies to scope "all"', - }) - } - if (value.scope === 'row' && value.excludeRowIds) { - ctx.addIssue({ - code: 'custom', - path: ['excludeRowIds'], - message: 'excludeRowIds only applies to scope "all"', - }) - } - }) +/** + * Plain-object base for the cancel-runs body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying {@link refineCancelTableRunsScope} — Zod forbids + * `.extend()` on a refined schema. + */ +export const cancelTableRunsBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + scope: z.enum(['all', 'row']), + rowId: z.string().min(1).optional(), + filter: z.union([predicateSchema, domainObjectSchema()]).optional(), + /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), +}) + +/** + * `row` scope names exactly one row, so it requires `rowId` and rejects the + * two select-all-only narrowing fields rather than ignoring them — a caller + * that sends both has misunderstood the scope. + */ +export function refineCancelTableRunsScope(value: { + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: string[] +}): { path: string[]; message: string }[] { + if (value.scope !== 'row') return [] + const issues: { path: string[]; message: string }[] = [] + if (!value.rowId) { + issues.push({ path: ['rowId'], message: 'rowId is required when scope is "row"' }) + } + if (value.filter) { + issues.push({ path: ['filter'], message: 'filter only applies to scope "all"' }) + } + if (value.excludeRowIds) { + issues.push({ + path: ['excludeRowIds'], + message: 'excludeRowIds only applies to scope "all"', + }) + } + return issues +} + +export const cancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema.superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } +}) export const cancelTableRunsContract = defineRouteContract({ method: 'POST', @@ -1635,32 +1651,47 @@ export const runLimitSchema = z.object({ .max(1_000_000, 'max cannot exceed 1,000,000'), }) -export const runColumnBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - groupIds: z.array(z.string().min(1)).min(1), - runMode: z.enum(['all', 'incomplete']).default('all'), - rowIds: z.array(z.string().min(1)).min(1).optional(), - /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The - * dispatcher walks only matching rows (paginated), so no id list is materialized. */ - filter: bulkFilterSchema.optional(), - /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ - limit: runLimitSchema.optional(), - }) - .refine((data) => !(data.rowIds && data.filter), { - message: 'Provide either filter or rowIds, but not both', - }) - .refine((data) => !(data.rowIds && data.excludeRowIds), { - message: 'excludeRowIds only applies to select-all scope (no rowIds)', - }) +/** + * Plain-object base for the run-column body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying the mutex refines — Zod forbids `.extend()` on a + * refined schema. + */ +export const runColumnBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + groupIds: z.array(z.string().min(1)).min(1), + runMode: z.enum(['all', 'incomplete']).default('all'), + rowIds: z.array(z.string().min(1)).min(1).optional(), + /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The + * dispatcher walks only matching rows (paginated), so no id list is materialized. */ + filter: bulkFilterSchema.optional(), + /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), + /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ + limit: runLimitSchema.optional(), +}) + +/** An explicit row set and a select-all filter are mutually exclusive scopes. */ +export const runColumnScopeMutexRefine = [ + (data: { rowIds?: string[]; filter?: unknown }) => !(data.rowIds && data.filter), + { message: 'Provide either filter or rowIds, but not both' }, +] as const + +/** Deselections only mean something under select-all scope. */ +export const runColumnExcludeMutexRefine = [ + (data: { rowIds?: string[]; excludeRowIds?: string[] }) => !(data.rowIds && data.excludeRowIds), + { message: 'excludeRowIds only applies to select-all scope (no rowIds)' }, +] as const + +export const runColumnBodySchema = runColumnBodyBaseSchema + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) export const runColumnContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 4c6f886ffe7..5b30cbc67b5 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,20 +1,42 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { + cancelTableJobBodySchema, + cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, + createTableViewBodySchema, + csvImportCreateColumnsSchema, + csvImportMappingSchema, + csvImportModeSchema, deleteTableColumnBodySchema, + exportDownloadQuerySchema, + exportTableAsyncBodySchema, + importIntoTableAsyncBodySchema, + listTableJobsQuerySchema, predicateSchema, + refineCancelTableRunsScope, + runColumnBodyBaseSchema, + runColumnExcludeMutexRefine, + runColumnScopeMutexRefine, sortSpecSchema, tableColumnSchema, + tableExportFormatSchema, tableIdParamsSchema, + tableJobSummarySchema, + tableLocksSchema, tableRowParamsSchema, tableRowsQueryBaseSchema, + tableViewConfigSchema, + tableViewParamsSchema, updateRowsByFilterBodySchema, + updateTableBodySchema, updateTableColumnBodySchema, updateTableRowBodySchema, + updateTableViewBodySchema, upsertTableRowBodySchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { v1CreateTableBodySchema, v1CreateTableRowsBodySchema, @@ -61,6 +83,10 @@ export const v2ApiTableSchema = z.object({ schema: z.object({ columns: z.array(tableColumnSchema) }), rowCount: z.number(), maxRows: z.number(), + /** Owning folder, or `null` when the table sits at the workspace root. */ + folderId: z.string().nullable(), + /** Governance flags. Writable only by a workspace admin via `PATCH`. */ + locks: tableLocksSchema, createdAt: z.string(), updatedAt: z.string(), }) @@ -179,6 +205,26 @@ export const v2GetTableContract = defineRouteContract({ }, }) +/** + * Table update. Every field is optional but at least one must be present: + * `name` renames, `folderId` moves the table (explicit `null` moves it to the + * workspace root; omission leaves the placement untouched), and `locks` flips + * the governance flags. The lock branch additionally requires workspace `admin` + * and the `table-locks` feature, matching the first-party surface — a `write` + * caller can rename and move but not lock. + */ +export const v2UpdateTableContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + body: updateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) +export type V2UpdateTableBody = z.input + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -407,3 +453,501 @@ export const v2UpsertTableRowContract = defineRouteContract({ schema: v2DataResponse(v2UpsertRowDataSchema), }, }) + +/** + * Body for the endpoints whose only input is the workspace the table must + * belong to. Present so every v2 mutation carries the same scope check the rest + * of the surface applies through `resolveWorkspaceScope`. + */ +export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2WorkspaceScopedBody = z.input + +/** + * Un-archives a table archived by `DELETE /api/v2/tables/[tableId]`. Resolves + * the table with archived rows included, so it is the one table endpoint whose + * target is expected NOT to be active. + */ +export const v2RestoreTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + params: tableIdParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +/** + * A saved view: a named preset of `{ filter, sort, column layout }` over a + * table. Presentation state only — a view narrows what a reader sees by + * default, it is never an access boundary, and every row it hides stays + * reachable by reading the table without it. Timestamps ISO-serialized. + */ +export const v2ApiViewSchema = z.object({ + id: z.string(), + tableId: z.string(), + name: z.string(), + config: tableViewConfigSchema, + isDefault: z.boolean(), + /** User who saved the view; `null` for views whose author is gone. */ + createdBy: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiView = z.output + +/** A single view payload. */ +export const v2TableViewDataSchema = z.object({ view: v2ApiViewSchema }) +export type V2TableViewData = z.output + +/** Delete confirmation — the id of the view that was removed. */ +export const v2DeleteTableViewDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableViewData = z.output + +/** + * Every saved view on a table, oldest first. A table carries a small bounded + * set of views, so this is a single full page (`nextCursor` is always `null`); + * the cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListTableViewsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiViewSchema), + }, +}) + +export const v2CreateTableViewContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + body: createTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2GetTableViewContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2UpdateTableViewContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + body: updateTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +/** Deleting the default view simply leaves the table unfiltered. */ +export const v2DeleteTableViewContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableViewDataSchema), + }, +}) + +/** + * One workflow/enrichment column group: a backing workflow (or registry + * enrichment) plus the output columns its runs populate. Read-only on v2 — + * groups are authored in the workflow builder, and the public surface exposes + * them so a caller can discover the `groupIds` the run endpoints take. + */ +export const v2WorkflowGroupSchema = z.object({ + id: z.string(), + /** Backing workflow id for `manual` groups; `''` for enrichment groups. */ + workflowId: z.string(), + /** Registry enrichment id for `enrichment` groups. */ + enrichmentId: z.string().optional(), + name: z.string().optional(), + type: z.enum(['manual', 'enrichment']).optional(), + dependencies: z.object({ columns: z.array(z.string()).optional() }).optional(), + outputs: z.array( + z.object({ + blockId: z.string(), + path: z.string(), + outputId: z.string().optional(), + columnName: z.string(), + }) + ), + inputMappings: z.array(z.object({ inputName: z.string(), columnName: z.string() })).optional(), + deploymentMode: z.enum(['live', 'deployed']).optional(), + /** When `false` the group never auto-fires; it runs only on an explicit request. */ + autoRun: z.boolean().optional(), +}) +export type V2WorkflowGroup = z.output + +/** + * The table's workflow/enrichment groups. Bounded per table, so a single full + * page (`nextCursor` is always `null`). + */ +export const v2ListWorkflowGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowGroupSchema), + }, +}) + +/** + * Run-column body. Identical to the first-party shape except `filter`, which v2 + * narrows to the typed predicate tree — the legacy `$`-operator dialect stays + * v1-only across the whole v2 surface. + */ +export const v2RunColumnBodySchema = runColumnBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) +export type V2RunColumnBody = z.input + +/** + * A started run. `dispatchId` identifies the `table_run_dispatches` row the + * dispatcher walks; it is `null` in deployments without a background runner, + * where cells execute inline and no dispatch row is created. + */ +export const v2RunColumnDataSchema = z.object({ dispatchId: z.string().nullable() }) +export type V2RunColumnData = z.output + +/** + * Runs one or more workflow/enrichment groups across the table or a row subset. + * Asynchronous: the response acknowledges the dispatch, and cell values land as + * the runs complete. Poll the rows endpoints for results. + */ +export const v2RunTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + params: tableIdParamsSchema, + body: v2RunColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +export const v2RowEnrichmentParamsSchema = tableRowParamsSchema.extend({ + groupId: z.string().min(1), +}) +export type V2RowEnrichmentParams = z.output + +/** + * The single-cell case of {@link v2RunTableColumnContract}: runs one group for + * one row. The scope lives entirely in the path, so the body carries only the + * workspace. + */ +export const v2RunRowEnrichmentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + params: v2RowEnrichmentParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +/** + * Lookup body: a case-insensitive substring search across every cell, narrowed + * by the same predicate/sort grammar as `POST /query`. POST because the + * predicate tree is a structured body, not a querystring dialect. + */ +export const v2FindRowsBodySchema = z.object({ + workspaceId: workspaceIdSchema, + q: z.string().min(1, 'q must be a non-empty search string'), + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional(), +}) +export type V2FindRowsBody = z.input + +/** + * One matching cell. `ordinal` is the row's 0-based index in the + * predicate-filtered, sorted view, so it lines up with the same page a + * `POST /query` with the same predicate and sort would return. `column` is the + * column NAME, matching how row `data` is keyed everywhere on the public wire. + */ +export const v2RowMatchSchema = z.object({ + ordinal: z.number(), + rowId: z.string(), + column: z.string(), +}) +export type V2RowMatch = z.output + +/** + * Match set. `truncated` is `true` when the search hit the server-side cap and + * more cells match than were returned — narrow the predicate rather than + * paging, since matches have no cursor. + */ +export const v2FindRowsDataSchema = z.object({ + matches: z.array(v2RowMatchSchema), + truncated: z.boolean(), +}) +export type V2FindRowsData = z.output + +export const v2FindTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + params: tableIdParamsSchema, + body: v2FindRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FindRowsDataSchema), + }, +}) + +/** + * Multipart form fields for `POST /api/v2/tables/[tableId]/import`. + * + * Not declared as the contract's `body`: the request is `multipart/form-data`, + * so the route reads the parts with the streaming multipart reader and parses + * the collected text fields through this schema in one pass. Every value + * arrives as a string — `mapping` and `createColumns` are JSON-encoded and + * decoded by their shared field schemas. + */ +export const v2ImportIntoTableFormSchema = z.object({ + workspaceId: workspaceIdSchema, + mode: csvImportModeSchema.default('append'), + mapping: csvImportMappingSchema.optional(), + createColumns: csvImportCreateColumnsSchema.optional(), + timezone: ianaTimezoneSchema.optional(), +}) +export type V2ImportIntoTableForm = z.input + +/** + * Synchronous-import summary. `deletedCount` is present only for + * `mode: "replace"`; `skippedHeaders` and `unmappedColumns` report what the + * import chose NOT to write, so a caller can tell a partial mapping from a + * complete one without diffing the schema. + */ +export const v2ImportTableDataSchema = z.object({ + tableId: z.string(), + mode: csvImportModeSchema, + insertedCount: z.number(), + deletedCount: z.number().optional(), + mappedColumns: z.array(z.string()), + skippedHeaders: z.array(z.string()), + unmappedColumns: z.array(z.string()), + sourceFile: z.string(), +}) +export type V2ImportTableData = z.output + +/** + * Synchronous CSV/TSV import into an existing table. Bounded by the request + * body cap — larger files go through `POST /import-async`, which reads the file + * from storage instead of the request. + */ +export const v2ImportTableCsvContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/import', + params: tableIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ImportTableDataSchema), + }, +}) + +/** Multipart form fields for `POST /api/v2/tables/import-csv`. */ +export const v2CreateTableFromCsvFormSchema = z.object({ + workspaceId: workspaceIdSchema, + folderId: folderIdSchema.optional(), + timezone: ianaTimezoneSchema.optional(), +}) +export type V2CreateTableFromCsvForm = z.input + +/** + * Creates a NEW table from a CSV/TSV: the column schema is inferred from the + * file's first rows and the table is named after the file. Returns the created + * table in the same shape as every other v2 table endpoint. + */ +export const v2CreateTableFromCsvContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/import-csv', + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +/** Kickoff acknowledgement for a background import. */ +export const v2ImportAsyncDataSchema = z.object({ + tableId: z.string(), + importId: z.string(), +}) +export type V2ImportAsyncData = z.output + +/** + * Starts a background import of a file already uploaded to workspace storage. + * Returns immediately; track the job through `GET /api/v2/tables/jobs` and stop + * it with `POST /api/v2/tables/[tableId]/job/cancel`. + */ +export const v2ImportTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/import-async', + params: tableIdParamsSchema, + body: importIntoTableAsyncBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ImportAsyncDataSchema), + }, +}) + +/** Export query: the workspace scope plus the serialization format. */ +export const v2ExportTableQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + format: tableExportFormatSchema, +}) +export type V2ExportTableQuery = z.input + +/** + * Streams the whole table as a CSV or JSON attachment. `mode: 'stream'` because + * the body is the file itself, not the v2 JSON envelope — rows are written as + * they are read, so nothing is buffered. Large tables should use + * `POST /export-async` instead, which survives a dropped connection. + */ +export const v2ExportTableContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/export', + params: tableIdParamsSchema, + query: v2ExportTableQuerySchema, + response: { + mode: 'stream', + }, +}) + +/** Kickoff acknowledgement for a background export. */ +export const v2ExportAsyncDataSchema = z.object({ + tableId: z.string(), + jobId: z.string(), +}) +export type V2ExportAsyncData = z.output + +/** + * Starts a background export. Export jobs are read-only, so they bypass the + * one-write-job-per-table gate and can run alongside an import or delete. + */ +export const v2ExportTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/export-async', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExportAsyncDataSchema), + }, +}) + +/** A short-lived presigned URL for a finished export. */ +export const v2ExportDownloadDataSchema = z.object({ + url: z.string(), + fileName: z.string(), +}) +export type V2ExportDownloadData = z.output + +/** + * Resolves a `ready` export job to a presigned download URL. Returns 409 while + * the job is still running and 410 once the generated file has aged out. + */ +export const v2ExportDownloadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/export/download', + params: tableIdParamsSchema, + query: exportDownloadQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExportDownloadDataSchema), + }, +}) + +/** + * Workspace-scoped export-job listing: running jobs plus recently finished ones + * (kept so a completed export stays re-downloadable). Bounded server-side, so a + * single full page — `nextCursor` is always `null`. + */ +export const v2ListTableJobsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/jobs', + query: listTableJobsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(tableJobSummarySchema), + }, +}) + +/** + * Cancel outcome. `canceled` is `false` when the job had already finished — + * cancelling is idempotent and a late request is not an error. + */ +export const v2CancelTableJobDataSchema = z.object({ + jobId: z.string(), + canceled: z.boolean(), +}) +export type V2CancelTableJobData = z.output + +/** + * Stops an in-flight import or delete. The worker halts at its next ownership + * check; work already committed (rows inserted or deleted) stays — there is no + * rollback. + */ +export const v2CancelTableJobContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/job/cancel', + params: tableIdParamsSchema, + body: cancelTableJobBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelTableJobDataSchema), + }, +}) + +/** + * Cancel-runs body. Identical to the first-party shape except `filter`, which + * v2 narrows to the typed predicate tree. + */ +export const v2CancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } + }) +export type V2CancelTableRunsBody = z.input + +/** How many in-flight cell runs the cancel actually stopped. */ +export const v2CancelTableRunsDataSchema = z.object({ cancelled: z.number() }) +export type V2CancelTableRunsData = z.output + +/** + * Stops in-flight and pending workflow/enrichment cell runs — the counterpart + * to `POST /columns/run`. Distinct from `POST /job/cancel`, which stops an + * import or delete job. + */ +export const v2CancelTableRunsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + params: tableIdParamsSchema, + body: v2CancelTableRunsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelTableRunsDataSchema), + }, +}) diff --git a/apps/sim/lib/table/export-stream.ts b/apps/sim/lib/table/export-stream.ts new file mode 100644 index 00000000000..66fbd497090 --- /dev/null +++ b/apps/sim/lib/table/export-stream.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { neutralizeCsvFormula } from '@/lib/core/utils/csv' +import { namedRowMapper } from '@/lib/table/cell-format' +import { getColumnId } from '@/lib/table/column-keys' +import { formatCsvCell } from '@/lib/table/export-format' +import { queryRows } from '@/lib/table/rows/service' +import type { TableDefinition, TableExportFormat } from '@/lib/table/types' + +const logger = createLogger('TableExportStream') + +const EXPORT_BATCH_SIZE = 1000 + +/** + * Synchronous table export as a byte stream, shared by the first-party and + * public surfaces so both emit byte-identical files. + * + * Rows are paged out as they are read rather than buffered, so a table larger + * than memory still exports — at the cost of a mid-stream failure being + * unrecoverable (the response has already started). Large tables should use the + * background export instead. + */ + +/** Filename-safe stem for the downloaded file. */ +export function sanitizeExportFilename(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return cleaned || 'table' +} + +function escapeCsvField(field: string): string { + return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field +} + +function toCsvRow(values: string[]): string { + return values.map(escapeCsvField).join(',') +} + +/** `Content-Type` for an export in `format`. */ +export function exportContentType(format: TableExportFormat): string { + return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' +} + +export function createTableExportStream( + table: TableDefinition, + format: TableExportFormat, + requestId: string +): ReadableStream { + const columns = table.schema.columns + // Stored row data is id-keyed; CSV headers and JSON keys are display names, so + // translate id → name on the way out (export is a name-friendly boundary). + const toNamedRow = namedRowMapper(columns) + + return new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + try { + if (format === 'csv') { + controller.enqueue( + encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) + ) + } else { + controller.enqueue(encoder.encode('[')) + } + + let offset = 0 + let firstJsonRow = true + while (true) { + const result = await queryRows( + table, + { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, + requestId + ) + + for (const row of result.rows) { + if (format === 'csv') { + const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) + controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) + } else { + const prefix = firstJsonRow ? '' : ',' + firstJsonRow = false + controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) + } + } + + // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, + // so a short page does NOT mean the export is done — only a null cursor does. + if (!result.nextCursor) break + offset += result.rows.length + } + + if (format === 'json') controller.enqueue(encoder.encode(']')) + controller.close() + + logger.info(`[${requestId}] Exported table ${table.id}`, { + format, + rowCount: table.rowCount, + }) + } catch (err) { + logger.error(`[${requestId}] Export failed for table ${table.id}`, err) + controller.error(err) + } + }, + }) +} diff --git a/apps/sim/lib/table/orchestration/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/types.ts b/apps/sim/lib/table/types.ts index 40231c77900..a0e53c0a922 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -353,8 +353,11 @@ export interface TableUpdateJobPayload { * on completion — the storage key of the generated file, served to the client via a presigned URL * and deleted by the janitor when the terminal job is pruned. */ +/** Serialization a table export produces. */ +export type TableExportFormat = 'csv' | 'json' + export interface TableExportJobPayload { - format: 'csv' | 'json' + format: TableExportFormat resultKey?: string } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 0033d7b2ce2..24dde87c3fa 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/table/events', () => ({ import { createTableView, deleteTableView, + getTableView, normalizeStoredViewConfig, pruneViewConfig, updateTableView, @@ -184,3 +185,37 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) }) + +describe('getTableView', () => { + const columns: ColumnDefinition[] = [{ id: 'col_a', name: 'Name', type: 'text' }] + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('prunes stale column references the same way the list read does', async () => { + queueTableRows(tableViews, [ + { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: { columnOrder: ['col_a', 'col_gone'], hiddenColumns: ['col_gone'] }, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + const view = await getTableView('view-1', 'table-1', columns) + + expect(view?.config.columnOrder).toEqual(['col_a']) + expect(view?.config.hiddenColumns).toEqual([]) + }) + + it('returns null for a view id that is not on this table', async () => { + expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 1487be7dd35..bd3fb64963e 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -151,6 +151,21 @@ export async function listTableViews( return rows.map((row) => toTableView(row, columns)) } +/** One view by id, scoped to its table, or `null` when it doesn't exist there. */ +export async function getTableView( + viewId: string, + tableId: string, + columns: ColumnDefinition[] +): Promise { + const [row] = await db + .select() + .from(tableViews) + .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .limit(1) + + return row ? toTableView(row, columns) : null +} + function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c52c7f59a55..c5162f82057 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1046, - zodRoutes: 1046, + totalRoutes: 1062, + zodRoutes: 1062, nonZodRoutes: 0, } as const From cfbaee772eb2f5149a06423f1729e181ca03a7d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 21:03:51 -0700 Subject: [PATCH 02/13] fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: PATCH applied locks, rename and move as three sequential transactions, so a folder rejected mid-request left the earlier writes persisted while the response reported failure — and the schema-changed signal was skipped, leaving open clients on stale state. Every rejectable condition now runs before the first write, and the signal fires whenever anything did land. Cursor: v2TableLockError dropped the lock kind, so async import, column run, enrichment and table mutations returned a bare LOCKED. A table has four independent locks, so the caller could not tell which to clear. --- .../[tableId]/import-async/route.test.ts | 29 ++++----- .../app/api/v2/tables/[tableId]/route.test.ts | 55 +++++++++++++++- apps/sim/app/api/v2/tables/[tableId]/route.ts | 64 +++++++++++++------ apps/sim/app/api/v2/tables/utils.ts | 8 ++- 4 files changed, 117 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts index 4cca287b800..91672b9802e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts @@ -19,7 +19,6 @@ const { mockAssertRowInsert, mockAssertRowDelete, mockGateError, - TableLockedError, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), @@ -30,7 +29,6 @@ const { mockAssertRowInsert: vi.fn(), mockAssertRowDelete: vi.fn(), mockGateError: vi.fn(), - TableLockedError: class TableLockedError extends Error {}, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -43,25 +41,18 @@ vi.mock('@/app/api/table/utils', async (importOriginal) => ({ checkAccess: mockCheckAccess, })) -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - v2TableLockError: (error: unknown) => - error instanceof TableLockedError - ? new Response(JSON.stringify({ error: { code: 'LOCKED', message: error.message } }), { - status: 423, - }) - : null, -})) - vi.mock('@/lib/table/jobs/service', () => ({ markTableJobRunning: mockMarkTableJobRunning, releaseJobClaim: mockReleaseJobClaim, })) -vi.mock('@/lib/table/mutation-locks', () => ({ +// Only the assert helpers are stubbed — `TableLockedError` stays real so the +// route's `v2TableLockError` recognizes it by `instanceof` and reports the lock +// kind, exactly as it would in production. +vi.mock('@/lib/table/mutation-locks', async (importOriginal) => ({ + ...(await importOriginal>()), assertRowInsert: mockAssertRowInsert, assertRowDelete: mockAssertRowDelete, assertSchemaMutable: vi.fn(), - TableLockedError, })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) @@ -71,6 +62,7 @@ vi.mock('@/lib/users/queries', () => ({ })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) +import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/v2/tables/[tableId]/import-async/route' const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] }, archivedAt: null } @@ -134,15 +126,20 @@ describe('POST /api/v2/tables/[tableId]/import-async', () => { expect(mockMarkTableJobRunning).not.toHaveBeenCalled() }) - it('asserts the insert lock BEFORE claiming the slot, so a locked table never holds it', async () => { + it('asserts the insert lock BEFORE claiming the slot, and names the lock in the 423', async () => { mockAssertRowInsert.mockImplementation(() => { - throw new TableLockedError('Inserts are locked for this table') + throw new TableLockedError('insert') }) const res = await callPost(BODY) expect(res.status).toBe(423) expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + // A table has four independent locks, so "LOCKED" alone doesn't tell the + // caller which one to clear. + const body = await res.json() + expect(body.error.code).toBe('LOCKED') + expect(body.error.details).toEqual({ lock: 'insert' }) }) it('asserts the delete lock too when the mode replaces rows', async () => { 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 609623e4d2d..f14618b5816 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -22,6 +22,7 @@ const { mockFindActiveFolder, mockIsFeatureEnabled, mockGateError, + mockSignalSchemaChanged, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), @@ -35,6 +36,7 @@ const { mockFindActiveFolder: vi.fn(), mockIsFeatureEnabled: vi.fn(), mockGateError: vi.fn(), + mockSignalSchemaChanged: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -63,7 +65,9 @@ vi.mock('@/lib/table', () => ({ buildIdByName: vi.fn(), })) -vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() })) +vi.mock('@/lib/table/events', () => ({ + signalTableSchemaChanged: mockSignalSchemaChanged, +})) vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -220,6 +224,55 @@ describe('PATCH /api/v2/tables/[tableId]', () => { expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() }) + it('rejects a bad folder without applying the rename that came with it', async () => { + // The three operations are separate transactions, so validation has to run + // before the first write — otherwise a rejected PATCH still renames. + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + folderId: 'folder-elsewhere', + }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + expect(mockSignalSchemaChanged).not.toHaveBeenCalled() + }) + + it('rejects a lock change from a non-admin without applying the rename beside it', async () => { + mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => + level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } + ) + + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + locks: { deleteLocked: true }, + }) + + expect(res.status).toBe(403) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('still signals collaborators when a later operation fails after an earlier one landed', async () => { + // A mid-write fault can't be rolled back across three transactions, so the + // clients must at least be told to refetch what did apply. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + it('rejects a lock change from a write-level caller', async () => { mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index e08df9873e8..abd22af2b6c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' +import type { NextRequest, NextResponse } from 'next/server' import { v2DeleteTableContract, v2GetTableContract, @@ -127,6 +127,11 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl return v2Error('NOT_FOUND', 'Table not found') } + // ── Validate every field BEFORE the first write ── + // The three operations are separate transactions, so a rejection + // discovered partway through would leave the earlier ones persisted while + // the response reports failure. Everything a request can be rejected for + // is therefore checked up front: a rejected PATCH changes nothing. if (validated.locks !== undefined) { // Only a lock transitioning off→on needs the feature; comparing against // the stored state is what lets a caller submitting the full flag set @@ -151,7 +156,24 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl if (!adminResult.ok) { return v2Error('FORBIDDEN', 'Admin access required to change table locks') } + } + + if (validated.folderId != null) { + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't file the table somewhere Tables never lists. + if (!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + } + + // ── Apply ── + // `applied` tracks whether anything reached the database, so a failure + // partway through still signals open clients. Skipping the signal there + // would leave every viewer rendering state that has already changed. + let applied = false + let failure: NextResponse | null = null + if (validated.locks !== undefined) { const outcome = await performUpdateTableLocks({ tableId, partial: validated.locks, @@ -159,15 +181,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl requestId, request, }) - if (!outcome.success) { - return v2ErrorForOrchestration( + if (outcome.success) { + applied = true + } else { + failure = v2ErrorForOrchestration( outcome.errorCode, outcome.error ?? 'Failed to update table locks' ) } } - if (validated.name !== undefined) { + if (!failure && validated.name !== undefined) { const outcome = await performRenameTable({ table, newName: validated.name, @@ -175,20 +199,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl requestId, request, }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to rename table') + if (outcome.success) { + applied = true + } else { + failure = v2ErrorForOrchestration( + outcome.errorCode, + outcome.error ?? 'Failed to rename table' + ) } } - if (validated.folderId !== undefined) { - // Scoped to `resourceType: 'table'` so a folder id from another resource's - // tree can't file the table somewhere Tables never lists. - if ( - validated.folderId !== null && - !(await findActiveFolder(validated.folderId, table.workspaceId, 'table')) - ) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } + if (!failure && validated.folderId !== undefined) { const outcome = await performMoveTableToFolder({ table, folderId: validated.folderId, @@ -196,20 +217,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl requestId, request, }) - if (!outcome.success) { - // The move re-asserts workspace and active state, so a miss means the - // table was archived between `checkAccess` and the write. - return v2ErrorForOrchestration( + if (outcome.success) applied = true + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + else + failure = v2ErrorForOrchestration( outcome.errorCode, outcome.errorCode === 'not_found' ? 'Table not found' : (outcome.error ?? 'Failed to move table') ) - } } // Live-collab: tell open viewers the definition changed so they refetch. - signalTableSchemaChanged(tableId) + if (applied) signalTableSchemaChanged(tableId) + if (failure) return failure // Re-read so the response reflects every applied change at once. const updated = await getTableById(tableId) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index d4beaf7b789..0754baf6652 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -166,9 +166,15 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything * else so the caller falls through to its own classification. + * + * `details.lock` names the flag that rejected the write. A table carries four + * independent locks, so "locked" on its own does not tell a caller which one to + * clear — every 423 on the surface reports it. */ export function v2TableLockError(error: unknown): NextResponse | null { - if (error instanceof TableLockedError) return v2Error('LOCKED', error.message) + if (error instanceof TableLockedError) { + return v2Error('LOCKED', error.message, { details: { lock: error.lock } }) + } return null } From 8ccf529bc38e350d5aaab3096c7063893f9b2db0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 21:30:25 -0700 Subject: [PATCH 03/13] fix(api): report the lock kind on classified 423s too, not just thrown ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit named the lock only where the rejection was thrown and caught at the route boundary. Where it instead arrives as a classified `errorCode: 'locked'` outcome — delete table, delete row, update column, and the table mutations — the kind was dropped, so those 423s stayed unactionable while their neighbours improved. The orchestration results now carry `lock`, and a shared `v2TableOrchestrationError` renders both arrival paths into the same `{ code, message, details: { lock } }` body. `details` is omitted rather than sent null when the kind is unknown, so a caller branching on it sees absence instead of a phantom value. --- .../api/v2/tables/[tableId]/columns/route.ts | 5 +-- .../api/v2/tables/[tableId]/import/route.ts | 17 ++++----- apps/sim/app/api/v2/tables/[tableId]/route.ts | 38 +++++++++---------- .../[tableId]/rows/[rowId]/route.test.ts | 23 +++++++++++ .../v2/tables/[tableId]/rows/[rowId]/route.ts | 10 +++-- apps/sim/app/api/v2/tables/utils.ts | 30 ++++++++++++++- apps/sim/lib/table/orchestration/columns.ts | 6 ++- .../lib/table/orchestration/tables.test.ts | 7 +++- apps/sim/lib/table/orchestration/tables.ts | 18 +++++++-- 9 files changed, 109 insertions(+), 45 deletions(-) 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]/import/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts index bd36334f90d..67c11575988 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/import/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts @@ -20,12 +20,16 @@ import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { v2CsvBodyCapError, v2MultipartError, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { + v2CsvBodyCapError, + v2MultipartError, + v2TableAccessError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableImportAPI') @@ -116,14 +120,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table }) if (!outcome.success || !outcome.data) { - // Naming the lock is the difference between an actionable 423 and one the - // caller has to guess at — there are four flags. - if (outcome.errorCode === 'locked') { - return v2Error('LOCKED', outcome.error ?? 'Table is locked', { - details: { lock: outcome.lock }, - }) - } - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to import CSV') + return v2TableOrchestrationError(outcome, 'Failed to import CSV') } return v2Data(outcome.data, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index abd22af2b6c..b81987df203 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -28,12 +28,16 @@ import { v2CaughtOrchestrationError, v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import { + toApiTable, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') @@ -184,10 +188,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl if (outcome.success) { applied = true } else { - failure = v2ErrorForOrchestration( - outcome.errorCode, - outcome.error ?? 'Failed to update table locks' - ) + failure = v2TableOrchestrationError(outcome, 'Failed to update table locks') } } @@ -202,10 +203,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl if (outcome.success) { applied = true } else { - failure = v2ErrorForOrchestration( - outcome.errorCode, - outcome.error ?? 'Failed to rename table' - ) + failure = v2TableOrchestrationError(outcome, 'Failed to rename table') } } @@ -217,16 +215,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl requestId, request, }) - if (outcome.success) applied = true - // The move re-asserts workspace and active state, so a miss means the - // table was archived between `checkAccess` and the write. - else - failure = v2ErrorForOrchestration( - outcome.errorCode, - outcome.errorCode === 'not_found' - ? 'Table not found' - : (outcome.error ?? 'Failed to move table') + if (outcome.success) { + applied = true + } else { + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + failure = v2TableOrchestrationError( + outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, + 'Failed to move table' ) + } } // Live-collab: tell open viewers the definition changed so they refetch. @@ -285,7 +283,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]/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/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 0754baf6652..e885d81a0a6 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,4 +1,5 @@ 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' @@ -9,7 +10,7 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { predicateToStorage } from '@/lib/table/select-values' -import type { Filter } from '@/lib/table/types' +import type { Filter, TableLockKind } from '@/lib/table/types' import type { TableView } from '@/lib/table/views/service' import { CSV_IMPORT_PROXY_BODY_CAP_BYTES, @@ -17,7 +18,7 @@ import { rootErrorMessage, rowWriteErrorResponse, } from '@/app/api/table/utils' -import { v2Error } from '@/app/api/v2/lib/response' +import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' /** * Shared serialization + error helpers for the v2 tables surface. Every v2 @@ -178,6 +179,31 @@ export function v2TableLockError(error: unknown): NextResponse | null { return null } +/** + * 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: { errorCode?: OrchestrationErrorCode; error?: string; lock?: TableLockKind }, + fallback: string +): NextResponse { + if (outcome.errorCode === 'locked') { + return v2Error('LOCKED', outcome.error ?? fallback, { + // Omitted rather than sent as null when the kind is unknown — a caller + // branching on `details.lock` should see absence, not a phantom value. + ...(outcome.lock ? { details: { lock: outcome.lock } } : {}), + }) + } + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? fallback) +} + /** * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the diff --git a/apps/sim/lib/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/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 From 83e04cbb28b8d7bcebc9d70e6143745dda726a5f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:10:23 -0700 Subject: [PATCH 04/13] fix(api): make async table imports observable, not just startable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track progress, but that endpoint filters to `type = 'export'` — imports are derived onto the table itself, one write job at a time, and exports get a separate list precisely because they are excluded from that derivation. The public Table shape omitted those derived fields, so an async import could be started and cancelled but never observed to completion, failure, or progress. That is the gap the import/export/job-control set was meant to close. Table now carries `job` — id, type, status, rowsProcessed, error, or null when idle — and the import-async docs point at the table rather than the export list. --- apps/docs/openapi-v2-tables.json | 55 +++++++++++++++++-- .../v2/tables/[tableId]/restore/route.test.ts | 1 + .../app/api/v2/tables/[tableId]/route.test.ts | 26 +++++++++ .../api/v2/tables/import-csv/route.test.ts | 1 + apps/sim/app/api/v2/tables/utils.ts | 12 ++++ apps/sim/lib/api/contracts/v2/tables.ts | 20 +++++++ 6 files changed, 109 insertions(+), 6 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index dca3fc1786f..955c70294a5 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -103,7 +103,8 @@ "insertLocked": false, "updateLocked": false, "deleteLocked": false - } + }, + "job": null } ], "nextCursor": null @@ -211,7 +212,8 @@ "insertLocked": false, "updateLocked": false, "deleteLocked": false - } + }, + "job": null } } } @@ -467,7 +469,8 @@ "deleteLocked": true }, "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-16T09:12:00.000Z" + "updatedAt": "2026-01-16T09:12:00.000Z", + "job": null } } } @@ -1720,7 +1723,8 @@ "deleteLocked": true }, "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-16T09:12:00.000Z" + "updatedAt": "2026-01-16T09:12:00.000Z", + "job": null } } } @@ -2916,7 +2920,7 @@ "post": { "operationId": "importTableCsvAsync", "summary": "Import CSV (Background)", - "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it with `GET /api/v2/tables/jobs` and stop it with `POST /job/cancel`. `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3711,7 +3715,8 @@ "folderId", "locks", "createdAt", - "updatedAt" + "updatedAt", + "job" ], "properties": { "id": { @@ -3767,6 +3772,17 @@ }, "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." } } }, @@ -5422,6 +5438,33 @@ } } } + }, + "TableJobState": { + "type": "object", + "description": "The table's in-flight background job. Import and delete jobs are derived onto the table itself (one write job per table), so the table is their status endpoint \u2014 poll `GET /api/v2/tables/{tableId}` after starting one. Exports are read-only and run concurrently, so they are listed separately by `GET /api/v2/tables/jobs` instead.", + "required": ["id", "type", "status", "rowsProcessed", "error"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "Job id \u2014 pass to `POST /job/cancel` to stop it." + }, + "type": { + "enum": ["import", "delete", "export", "backfill", "update", null], + "description": "Which kind of job is running." + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "`running` is in-flight; the rest are terminal." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows handled so far \u2014 progress for a running job." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." + } + } } }, "responses": { 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 index 8f8f25a834d..804db7fd7f0 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts @@ -109,6 +109,7 @@ describe('POST /api/v2/tables/[tableId]/restore', () => { maxRows: 1000, folderId: null, locks: UNLOCKED, + job: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', }, 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 f14618b5816..d78dc2f70f7 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -192,6 +192,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { maxRows: 1000, folderId: null, locks: UNLOCKED, + job: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', }, @@ -203,6 +204,31 @@ describe('PATCH /api/v2/tables/[tableId]', () => { 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 }) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.test.ts b/apps/sim/app/api/v2/tables/import-csv/route.test.ts index 14821ddd520..a13d5c901e2 100644 --- a/apps/sim/app/api/v2/tables/import-csv/route.test.ts +++ b/apps/sim/app/api/v2/tables/import-csv/route.test.ts @@ -121,6 +121,7 @@ describe('POST /api/v2/tables/import-csv', () => { maxRows: 1000, folderId: null, locks: UNLOCKED, + job: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index e885d81a0a6..7df3e9a39c1 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -65,6 +65,18 @@ export function toApiTable(table: TableDefinition) { 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), } diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 5b30cbc67b5..d94a2c20392 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -76,6 +76,24 @@ export const V2_MAX_ROW_LIMIT = 1000 * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). * Concrete so the v2 contract describes exactly what the wire carries. */ +/** + * The table's current background job, or `null` when idle. + * + * This is how an async import or delete is observed. Those jobs are derived + * onto the table itself (one write job per table at a time), so the table is + * their status endpoint — unlike exports, which are read-only, run concurrently, + * and therefore have the dedicated `GET /api/v2/tables/jobs` list instead. + */ +export const v2TableJobStateSchema = z.object({ + id: z.string().nullable(), + type: z.enum(['import', 'delete', 'export', 'backfill', 'update']).nullable(), + status: z.enum(['running', 'ready', 'failed', 'canceled']), + rowsProcessed: z.number(), + /** Failure reason for a `failed` job; `null` otherwise. */ + error: z.string().nullable(), +}) +export type V2TableJobState = z.output + export const v2ApiTableSchema = z.object({ id: z.string(), name: z.string(), @@ -87,6 +105,8 @@ export const v2ApiTableSchema = z.object({ folderId: z.string().nullable(), /** Governance flags. Writable only by a workspace admin via `PATCH`. */ locks: tableLocksSchema, + /** In-flight background job, or `null` when the table is idle. */ + job: v2TableJobStateSchema.nullable(), createdAt: z.string(), updatedAt: z.string(), }) From b25c426974d7d386c6b177a2ceadb025264b497a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:19:44 -0700 Subject: [PATCH 05/13] feat(api): make v2 table PATCH state which operations landed on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile held the PR at 4/5 on the residual non-atomicity and named two acceptable resolutions: make PATCH atomic, or have the contract adopt and expose partial-success explicitly. Atomicity would mean threading one transaction through renameTable, moveTableToFolder and updateTableLocks — three shared service functions with four non-test callers including the first-party route and two copilot tools — and deferring their per-operation audits to commit time. That is a refactor of shared write paths well outside this PR. So the contract states it instead. Every rejectable condition is already pre-validated, so a failure here is a genuine fault; when one follows a successful operation the error now carries `details.applied` listing what is live. Absent when nothing applied, so its presence always means "these changes took effect despite the error". Documented on the operation. `v2ErrorForOrchestration` gained the optional `details` this needs. --- apps/docs/openapi-v2-tables.json | 2 +- apps/sim/app/api/v2/lib/response.ts | 8 ++- .../app/api/v2/tables/[tableId]/route.test.ts | 32 +++++++++++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 55 +++++++++++-------- apps/sim/app/api/v2/tables/utils.ts | 30 +++++++--- 5 files changed, 92 insertions(+), 35 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 955c70294a5..51701951d47 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -373,7 +373,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.", + "description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.\n\n**Partial-success semantics.** The three operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the lock feature gate, the admin check, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails a later operation after an earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"locks\"`, `\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.", "tags": ["Tables"], "x-codeSamples": [ { diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 3bdc2b90b91..6be67ffcd3b 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -175,10 +175,14 @@ const V2_CODE_BY_ORCHESTRATION_ERROR: Record { expect(mockPerformRenameTable).not.toHaveBeenCalled() }) + it('reports which operations landed when a later one fails', async () => { + // The three writes commit independently, so rather than pretending + // atomicity the error states what is already live — a caller can reconcile + // instead of re-reading and diffing. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits the applied list when the very first operation fails', async () => { + // `details.applied` present must always mean "these changes are live". + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'taken', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.details).toBeUndefined() + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + it('still signals collaborators when a later operation fails after an earlier one landed', async () => { // A mid-write fault can't be rolled back across three transactions, so the // clients must at least be told to refetch what did apply. diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index b81987df203..5c68a30d8c1 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest, NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' import { v2DeleteTableContract, v2GetTableContract, @@ -32,6 +32,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' +import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' import { toApiTable, v2TableAccessError, @@ -171,11 +172,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl } // ── Apply ── - // `applied` tracks whether anything reached the database, so a failure - // partway through still signals open clients. Skipping the signal there - // would leave every viewer rendering state that has already changed. - let applied = false - let failure: NextResponse | null = null + // Every deterministic rejection is already behind us, so a failure here is + // a genuine fault (lost race, archived mid-request, database error) rather + // than a bad request. The three operations commit independently — a single + // transaction would have to span three shared service functions that also + // back the first-party route and two copilot tools, and would break their + // per-operation audits — so instead of pretending atomicity the response + // states exactly which operations landed. A caller that gets an error can + // then reconcile rather than having to re-read and diff. + const applied: ('locks' | 'name' | 'folderId')[] = [] + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null if (validated.locks !== undefined) { const outcome = await performUpdateTableLocks({ @@ -185,11 +191,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl requestId, request, }) - if (outcome.success) { - applied = true - } else { - failure = v2TableOrchestrationError(outcome, 'Failed to update table locks') - } + if (outcome.success) applied.push('locks') + else failure = { outcome, fallback: 'Failed to update table locks' } } if (!failure && validated.name !== undefined) { @@ -200,11 +203,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl requestId, request, }) - if (outcome.success) { - applied = true - } else { - failure = v2TableOrchestrationError(outcome, 'Failed to rename table') - } + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } } if (!failure && validated.folderId !== undefined) { @@ -216,20 +216,29 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl request, }) if (outcome.success) { - applied = true + 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 = v2TableOrchestrationError( - outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, - 'Failed to move table' - ) + 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) signalTableSchemaChanged(tableId) - if (failure) return failure + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError( + failure.outcome, + failure.fallback, + // Omitted when nothing landed, so `details.applied` present always + // means "these changes are live despite the error". + applied.length > 0 ? { applied } : undefined + ) + } // Re-read so the response reflects every applied change at once. const updated = await getTableById(tableId) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 7df3e9a39c1..58f0c97196b 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -191,6 +191,13 @@ export function v2TableLockError(error: unknown): NextResponse | null { 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. @@ -203,17 +210,22 @@ export function v2TableLockError(error: unknown): NextResponse | null { * orchestration result goes through this instead. */ export function v2TableOrchestrationError( - outcome: { errorCode?: OrchestrationErrorCode; error?: string; lock?: TableLockKind }, - fallback: string + outcome: OrchestrationOutcome, + fallback: string, + /** Merged into `details` — e.g. which operations of a composite write landed. */ + extraDetails?: Record ): NextResponse { - if (outcome.errorCode === 'locked') { - return v2Error('LOCKED', outcome.error ?? fallback, { - // Omitted rather than sent as null when the kind is unknown — a caller - // branching on `details.lock` should see absence, not a phantom value. - ...(outcome.lock ? { details: { lock: outcome.lock } } : {}), - }) + // `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) + return v2ErrorForOrchestration( + outcome.errorCode, + outcome.error ?? fallback, + Object.keys(details).length > 0 ? details : undefined + ) } /** From 178a20d07551ea7b67f567652e9be9a339e7e325 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 10:20:06 -0700 Subject: [PATCH 06/13] fix(api): make table lock flags read-only on the public v2 surface The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated on workspace admin plus the table-locks feature. That still lets an API key clear the guard placed there to stop it: `write` is the floor for the endpoint, and admin keys are ordinary API keys, so a lock is no longer a boundary the key cannot cross. Locks stay readable on the table resource and enforcement is unchanged (a locked verb still returns 423). Changing one is now a first-party admin action only. The v2 body is declared here rather than reusing the first-party updateTableBodySchema, which keeps its `locks` field so the UI can still toggle them. It is .strict(), so a request carrying `locks` is rejected with a 400 naming the field instead of silently succeeding without applying it. --- apps/docs/openapi-v2-tables.json | 176 ++++++++---------- .../app/api/v2/tables/[tableId]/route.test.ts | 75 +++----- apps/sim/app/api/v2/tables/[tableId]/route.ts | 73 ++------ apps/sim/lib/api/contracts/tables.ts | 2 +- apps/sim/lib/api/contracts/v2/tables.ts | 44 ++++- 5 files changed, 159 insertions(+), 211 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3a937b5c995..97b02195026 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": { @@ -405,7 +416,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.\n\n**Partial-success semantics.** The three operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the lock feature gate, the admin check, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails a later operation after an earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"locks\"`, `\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.", + "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": [ { @@ -441,15 +452,6 @@ "workspaceId": "ws_123", "folderId": null } - }, - "lock": { - "summary": "Lock deletes (workspace admin)", - "value": { - "workspaceId": "ws_123", - "locks": { - "deleteLocked": true - } - } } } } @@ -638,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": [ { @@ -782,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": [ { @@ -1518,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": [ { @@ -1563,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", @@ -1681,7 +1683,7 @@ "post": { "operationId": "restoreTable", "summary": "Restore Table", - "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table\u2019s name \u2014 rename that table first, then retry.", + "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": [ { @@ -1811,7 +1813,7 @@ ], "responses": { "200": { - "description": "The table\u2019s saved views.", + "description": "The table’s saved views.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1886,7 +1888,7 @@ "post": { "operationId": "createTableView", "summary": "Create View", - "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary \u2014 rows it hides stay readable through the row and query endpoints.", + "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": [ { @@ -2107,7 +2109,7 @@ "patch": { "operationId": "updateTableView", "summary": "Update View", - "description": "Rename a view, replace or merge its config, or promote it to the table\u2019s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table\u2019s existing default in the same transaction.", + "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": [ { @@ -2134,7 +2136,7 @@ }, "examples": { "promote": { - "summary": "Make this the table\u2019s default view", + "summary": "Make this the table’s default view", "value": { "workspaceId": "ws_123", "isDefault": true @@ -2236,7 +2238,7 @@ "delete": { "operationId": "deleteTableView", "summary": "Delete View", - "description": "Remove a saved view. Deleting the table\u2019s default simply leaves the table unfiltered; no rows are affected.", + "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2309,7 +2311,7 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "The table\u2019s workflow and enrichment groups \u2014 the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "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": [ { @@ -2329,7 +2331,7 @@ ], "responses": { "200": { - "description": "The table\u2019s workflow groups.", + "description": "The table’s workflow groups.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -2397,7 +2399,7 @@ "post": { "operationId": "runTableColumns", "summary": "Run Column Groups", - "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) \u2014 never both. Omit both to run every row. Starting a run clears the target groups\u2019 cells to pending, so a read taken immediately after will show them empty.", + "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": [ { @@ -2507,7 +2509,7 @@ "post": { "operationId": "runRowEnrichment", "summary": "Run Enrichment For One Row", - "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** \u2014 the response acknowledges the dispatch; read the row back for the result.", + "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": [ { @@ -2596,7 +2598,7 @@ "post": { "operationId": "findTableRows", "summary": "Find Rows", - "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row\u2019s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor \u2014 when `truncated` is true, narrow the predicate rather than paging.", + "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": [ { @@ -2711,7 +2713,7 @@ "post": { "operationId": "createTableFromCsv", "summary": "Create Table From CSV", - "description": "Create a table from a CSV or TSV file. The column schema is inferred from the file\u2019s first rows and the table is named after the file.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part, so an unauthorized upload is rejected before its bytes are read. Rows stream in as they are parsed, so a file larger than memory still imports; a failure part way through drops the half-populated table rather than leaving it behind.", + "description": "Create a table from a CSV or TSV file. The column schema is inferred from the file’s first rows and the table is named after the file.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part, so an unauthorized upload is rejected before its bytes are read. Rows stream in as they are parsed, so a file larger than memory still imports; a failure part way through drops the half-populated table rather than leaving it behind.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2723,7 +2725,7 @@ ], "requestBody": { "required": true, - "description": "Bodies over 10 MB are rejected with 413 \u2014 use the async import instead.", + "description": "Bodies over 10 MB are rejected with 413 — use the async import instead.", "content": { "multipart/form-data": { "schema": { @@ -2782,7 +2784,7 @@ "get": { "operationId": "listTableJobs", "summary": "List Export Jobs", - "description": "Export jobs across a workspace \u2014 running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "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": [ { @@ -2802,7 +2804,7 @@ ], "responses": { "200": { - "description": "The workspace\u2019s export jobs.", + "description": "The workspace’s export jobs.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -2859,7 +2861,7 @@ "post": { "operationId": "importTableCsv", "summary": "Import CSV", - "description": "Import a CSV or TSV into an existing table, appending or replacing its rows.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part. Omit `mapping` to auto-map CSV headers to same-named columns; pass `createColumns` to have unmatched headers created as new columns, with types inferred from the file. The response reports what was written AND what was not (`skippedHeaders`, `unmappedColumns`), so a partial mapping is visible without diffing the schema.\n\nThe table\u2019s single write-job slot is held for the whole import, so a concurrent import or delete gets 409. Files over 10 MB must use `POST /import-async`.", + "description": "Import a CSV or TSV into an existing table, appending or replacing its rows.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part. Omit `mapping` to auto-map CSV headers to same-named columns; pass `createColumns` to have unmatched headers created as new columns, with types inferred from the file. The response reports what was written AND what was not (`skippedHeaders`, `unmappedColumns`), so a partial mapping is visible without diffing the schema.\n\nThe table’s single write-job slot is held for the whole import, so a concurrent import or delete gets 409. Files over 10 MB must use `POST /import-async`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2876,7 +2878,7 @@ ], "requestBody": { "required": true, - "description": "Bodies over 10 MB are rejected with 413 \u2014 use the async import instead.", + "description": "Bodies over 10 MB are rejected with 413 — use the async import instead.", "content": { "multipart/form-data": { "schema": { @@ -2952,7 +2954,7 @@ "post": { "operationId": "importTableCsvAsync", "summary": "Import CSV (Background)", - "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "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": [ { @@ -3042,7 +3044,7 @@ "get": { "operationId": "exportTable", "summary": "Export Table", - "description": "Stream the whole table as a CSV or JSON file attachment.\n\nThe only endpoint whose success body is the file itself rather than the `{ data }` envelope. Rows are written as they are read, so nothing is buffered \u2014 but once the stream has started a failure can only tear the connection down. Large tables should use `POST /export-async`, which survives a dropped connection and leaves a re-downloadable result.", + "description": "Stream the whole table as a CSV or JSON file attachment.\n\nThe only endpoint whose success body is the file itself rather than the `{ data }` envelope. Rows are written as they are read, so nothing is buffered — but once the stream has started a failure can only tear the connection down. Large tables should use `POST /export-async`, which survives a dropped connection and leaves a re-downloadable result.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3290,7 +3292,7 @@ "post": { "operationId": "cancelTableJob", "summary": "Cancel Job", - "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place \u2014 there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "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": [ { @@ -3372,7 +3374,7 @@ "post": { "operationId": "cancelTableRuns", "summary": "Cancel Column Runs", - "description": "Stop in-flight and pending workflow or enrichment cell runs \u2014 the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row\u2019s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "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": [ { @@ -3403,7 +3405,7 @@ } }, "oneRow": { - "summary": "Stop one row\u2019s runs", + "summary": "Stop one row’s runs", "value": { "workspaceId": "ws_123", "scope": "row", @@ -3713,7 +3715,7 @@ }, "id": { "type": "string", - "description": "Stable column id. Server-assigned \u2014 normally omit." + "description": "Stable column id. Server-assigned — normally omit." }, "options": { "type": "array", @@ -4434,7 +4436,7 @@ } }, "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.", + "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", @@ -4486,7 +4488,7 @@ "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)." + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." }, "op": { "enum": [ @@ -4511,7 +4513,7 @@ "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)." + "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." @@ -4524,7 +4526,7 @@ "properties": { "id": { "type": "string", - "description": "Stable option id \u2014 the value stored in cells." + "description": "Stable option id — the value stored in cells." }, "name": { "type": "string", @@ -4556,31 +4558,9 @@ } } }, - "TableLocksPatch": { - "type": "object", - "description": "Lock flags to change. Omitted flags are left as they are.", - "properties": { - "schemaLocked": { - "type": "boolean", - "description": "Blocks column adds, edits, and deletes." - }, - "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." - } - } - }, "UpdateTableBody": { "type": "object", - "description": "Rename, move, and/or re-lock a table. Every field beyond `workspaceId` is optional, but at least one must be present.", + "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": { @@ -4596,11 +4576,9 @@ "folderId": { "type": ["string", "null"], "description": "Folder to move the table into. Pass null to move it to the workspace root; omit to leave the placement untouched." - }, - "locks": { - "$ref": "#/components/schemas/TableLocksPatch" } - } + }, + "additionalProperties": false }, "WorkspaceScopedBody": { "type": "object", @@ -4633,7 +4611,7 @@ }, "ViewConfig": { "type": "object", - "description": "A view\u2019s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "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", @@ -4662,7 +4640,7 @@ "items": { "type": "string" }, - "description": "Column ids hidden by the view. A deny-list \u2014 a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + "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" @@ -4674,7 +4652,7 @@ }, "View": { "type": "object", - "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only \u2014 a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "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", @@ -4704,7 +4682,7 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this view is the table\u2019s default. At most one view per table is." + "description": "Whether this view is the table’s default. At most one view per table is." }, "createdBy": { "type": ["string", "null"], @@ -4773,7 +4751,7 @@ }, "isDefault": { "type": "boolean", - "description": "Promote this view to the table\u2019s default. Setting it demotes the table\u2019s existing default in the same transaction." + "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction." } } }, @@ -4806,7 +4784,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 a table carries a bounded set of views, so the list is a single full page." + "description": "Always null — a table carries a bounded set of views, so the list is a single full page." } } }, @@ -4834,7 +4812,7 @@ "properties": { "id": { "type": "string", - "description": "Group id \u2014 pass to the run endpoints." + "description": "Group id — pass to the run endpoints." }, "workflowId": { "type": "string", @@ -4929,13 +4907,13 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 groups are bounded per table, so the list is a single full page." + "description": "Always null — groups are bounded per table, so the list is a single full page." } } }, "RunColumnBody": { "type": "object", - "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) \u2014 never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "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": { @@ -5042,7 +5020,7 @@ "properties": { "ordinal": { "type": "integer", - "description": "The row\u2019s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments \u2014 use it to page straight to the match." + "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." }, "rowId": { "type": "string", @@ -5071,7 +5049,7 @@ }, "truncated": { "type": "boolean", - "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor \u2014 narrow the predicate instead of paging." + "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." } } } @@ -5085,7 +5063,7 @@ "workspaceId": { "type": "string", "minLength": 1, - "description": "The workspace that owns the table. Must appear BEFORE the file part \u2014 the server rejects an unauthorized upload before reading its bytes." + "description": "The workspace that owns the table. Must appear BEFORE the file part — the server rejects an unauthorized upload before reading its bytes." }, "file": { "type": "string", @@ -5109,7 +5087,7 @@ }, "timezone": { "type": "string", - "description": "IANA zone used to read naive datetimes (Excel and Sheets exports carry no offset). Defaults to the API key owner\u2019s saved timezone, else UTC.", + "description": "IANA zone used to read naive datetimes (Excel and Sheets exports carry no offset). Defaults to the API key owner’s saved timezone, else UTC.", "example": "America/New_York" } } @@ -5122,7 +5100,7 @@ "workspaceId": { "type": "string", "minLength": 1, - "description": "The workspace to create the table in. Must appear BEFORE the file part \u2014 the server rejects an unauthorized upload before reading its bytes." + "description": "The workspace to create the table in. Must appear BEFORE the file part — the server rejects an unauthorized upload before reading its bytes." }, "file": { "type": "string", @@ -5135,7 +5113,7 @@ }, "timezone": { "type": "string", - "description": "IANA zone used to read naive datetimes. Defaults to the API key owner\u2019s saved timezone, else UTC.", + "description": "IANA zone used to read naive datetimes. Defaults to the API key owner’s saved timezone, else UTC.", "example": "America/New_York" } } @@ -5190,7 +5168,7 @@ "items": { "type": "string" }, - "description": "Table columns no CSV header supplied \u2014 left at their existing values." + "description": "Table columns no CSV header supplied — left at their existing values." }, "sourceFile": { "type": "string", @@ -5214,7 +5192,7 @@ }, "importId": { "type": "string", - "description": "Job id \u2014 pass to `POST /job/cancel` to stop the import." + "description": "Job id — pass to `POST /job/cancel` to stop the import." } } } @@ -5233,7 +5211,7 @@ "fileKey": { "type": "string", "minLength": 1, - "description": "Storage key of the uploaded file. Must sit under this workspace\u2019s `workspace/{workspaceId}/` prefix.", + "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.", "example": "workspace/ws_123/imports/contacts.csv" }, "fileName": { @@ -5247,7 +5225,7 @@ }, "mapping": { "type": "object", - "description": "CSV header \u2192 column name, or null to skip the header.", + "description": "CSV header → column name, or null to skip the header.", "additionalProperties": { "type": ["string", "null"] } @@ -5297,7 +5275,7 @@ }, "jobId": { "type": "string", - "description": "Job id \u2014 poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." + "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } @@ -5314,7 +5292,7 @@ "properties": { "url": { "type": "string", - "description": "Presigned URL. Expires shortly after issue \u2014 fetch it promptly." + "description": "Presigned URL. Expires shortly after issue — fetch it promptly." }, "fileName": { "type": "string", @@ -5381,7 +5359,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 the listing is bounded server-side to a single page." + "description": "Always null — the listing is bounded server-side to a single page." } } }, @@ -5416,7 +5394,7 @@ }, "canceled": { "type": "boolean", - "description": "False when the job had already finished. Cancelling is idempotent \u2014 a late request is not an error." + "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." } } } @@ -5434,7 +5412,7 @@ }, "scope": { "enum": ["all", "row"], - "description": "`all` cancels every running and pending cell; `row` cancels one row\u2019s cells." + "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." }, "rowId": { "type": "string", @@ -5473,12 +5451,12 @@ }, "TableJobState": { "type": "object", - "description": "The table's in-flight background job. Import and delete jobs are derived onto the table itself (one write job per table), so the table is their status endpoint \u2014 poll `GET /api/v2/tables/{tableId}` after starting one. Exports are read-only and run concurrently, so they are listed separately by `GET /api/v2/tables/jobs` instead.", + "description": "The table's in-flight background job. Import and delete jobs are derived onto the table itself (one write job per table), so the table is their status endpoint — poll `GET /api/v2/tables/{tableId}` after starting one. Exports are read-only and run concurrently, so they are listed separately by `GET /api/v2/tables/jobs` instead.", "required": ["id", "type", "status", "rowsProcessed", "error"], "properties": { "id": { "type": ["string", "null"], - "description": "Job id \u2014 pass to `POST /job/cancel` to stop it." + "description": "Job id — pass to `POST /job/cancel` to stop it." }, "type": { "enum": ["import", "delete", "export", "backfill", "update", null], @@ -5490,7 +5468,7 @@ }, "rowsProcessed": { "type": "integer", - "description": "Rows handled so far \u2014 progress for a running job." + "description": "Rows handled so far — progress for a running job." }, "error": { "type": ["string", "null"], @@ -5620,7 +5598,7 @@ } }, "Conflict": { - "description": "The request conflicts with the current state of the resource \u2014 for example a rename to a name another table in the workspace already uses.", + "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": { 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 e89ce0fae12..bca0c8e3190 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -3,8 +3,8 @@ * * Public v2 table delete and update. Delete hands the actor to the service so * the audit is emitted there — and only for a delete that actually archived a - * row. Update routes each field to its own orchestration call, and carries the - * first-party permission split: renaming needs `write`, locking needs `admin`. + * 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' @@ -20,7 +20,6 @@ const { mockRecordAudit, mockGetTableById, mockFindActiveFolder, - mockIsFeatureEnabled, mockGateError, mockSignalSchemaChanged, } = vi.hoisted(() => ({ @@ -34,7 +33,6 @@ const { mockRecordAudit: vi.fn(), mockGetTableById: vi.fn(), mockFindActiveFolder: vi.fn(), - mockIsFeatureEnabled: vi.fn(), mockGateError: vi.fn(), mockSignalSchemaChanged: vi.fn(), })) @@ -69,7 +67,6 @@ vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged, })) vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), })) @@ -142,7 +139,6 @@ beforeEach(() => { mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) mockGetTableById.mockResolvedValue(UPDATED_TABLE) mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) - mockIsFeatureEnabled.mockResolvedValue(true) mockGateError.mockResolvedValue(null) }) @@ -267,21 +263,6 @@ describe('PATCH /api/v2/tables/[tableId]', () => { expect(mockSignalSchemaChanged).not.toHaveBeenCalled() }) - it('rejects a lock change from a non-admin without applying the rename beside it', async () => { - mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => - level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } - ) - - const res = await callPatch({ - workspaceId: 'ws-1', - name: 'Renamed', - locks: { deleteLocked: true }, - }) - - expect(res.status).toBe(403) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - it('reports which operations landed when a later one fails', async () => { // The three writes commit independently, so rather than pretending // atomicity the error states what is already live — a caller can reconcile @@ -331,42 +312,46 @@ describe('PATCH /api/v2/tables/[tableId]', () => { expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') }) - it('rejects a lock change from a write-level caller', async () => { - mockCheckAccess.mockImplementation(async (_tableId, _userId, level) => - level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE } - ) - + /** + * 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(403) + 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 enabling a lock while the feature is off', async () => { - mockIsFeatureEnabled.mockResolvedValue(false) - - const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + 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(403) - expect((await res.json()).error.message).toBe('Table locks are not enabled') - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + expect(res.status).toBe(400) + // The whole request is refused — the rename must not land either. + expect(mockPerformRenameTable).not.toHaveBeenCalled() }) - it('still clears a lock while the feature is off, so a locked table is never stranded', async () => { - mockIsFeatureEnabled.mockResolvedValue(false) - mockCheckAccess.mockResolvedValue({ - ok: true, - table: { ...TABLE, locks: { ...UNLOCKED, deleteLocked: true } }, + 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 }, }) - mockPerformUpdateTableLocks.mockResolvedValue({ success: true }) - const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: false } }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) expect(res.status).toBe(200) - expect(mockIsFeatureEnabled).not.toHaveBeenCalled() - expect(mockPerformUpdateTableLocks).toHaveBeenCalledWith( - expect.objectContaining({ tableId: 'table-1', partial: { deleteLocked: false } }) - ) + expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true }) }) it('maps a duplicate-name rename to 409 CONFLICT', async () => { diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 5c68a30d8c1..e58af81976c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -7,7 +7,6 @@ import { v2UpdateTableContract, } from '@/lib/api/contracts/v2/tables' import { parseRequest } from '@/lib/api/server' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' @@ -17,10 +16,7 @@ import { performDeleteTable, performMoveTableToFolder, performRenameTable, - performUpdateTableLocks, } from '@/lib/table/orchestration' -import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -91,15 +87,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR }) /** - * PATCH /api/v2/tables/[tableId] — Rename, move, and/or change lock flags. + * 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. `locks` carries the first-party - * permission split: `write` is the floor for the endpoint, but enabling a lock - * additionally needs workspace `admin` and the `table-locks` feature. Clearing - * a lock stays available with the feature off, or flipping the kill switch - * would strand an already-locked table with no way to unlock it while - * enforcement of the stored locks keeps running. + * 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() @@ -133,36 +130,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl } // ── Validate every field BEFORE the first write ── - // The three operations are separate transactions, so a rejection - // discovered partway through would leave the earlier ones persisted while - // the response reports failure. Everything a request can be rejected for - // is therefore checked up front: a rejected PATCH changes nothing. - if (validated.locks !== undefined) { - // Only a lock transitioning off→on needs the feature; comparing against - // the stored state is what lets a caller submitting the full flag set - // clear one lock while another stays on. - const enablesALock = TABLE_LOCK_KINDS.some((kind) => { - const flag = TABLE_LOCK_FLAGS[kind] - return validated.locks?.[flag] === true && !table.locks[flag] - }) - if (enablesALock) { - // Resolved against the workspace's host organization, not the caller's - // active one, so an org-targeted rollout can't accept the write here - // and reject it in the first-party UI. - const workspace = await getWorkspaceWithOwner(table.workspaceId) - const enabled = await isFeatureEnabled('table-locks', { - userId, - orgId: workspace?.organizationId ?? undefined, - }) - if (!enabled) return v2Error('FORBIDDEN', 'Table locks are not enabled') - } - - const adminResult = await checkAccess(tableId, userId, 'admin') - if (!adminResult.ok) { - return v2Error('FORBIDDEN', 'Admin access required to change table locks') - } - } - + // 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. @@ -174,28 +145,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl // ── Apply ── // Every deterministic rejection is already behind us, so a failure here is // a genuine fault (lost race, archived mid-request, database error) rather - // than a bad request. The three operations commit independently — a single - // transaction would have to span three shared service functions that also + // 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. - const applied: ('locks' | 'name' | 'folderId')[] = [] + const applied: ('name' | 'folderId')[] = [] let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null - if (validated.locks !== undefined) { - const outcome = await performUpdateTableLocks({ - tableId, - partial: validated.locks, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('locks') - else failure = { outcome, fallback: 'Failed to update table locks' } - } - - if (!failure && validated.name !== undefined) { + if (validated.name !== undefined) { const outcome = await performRenameTable({ table, newName: validated.name, diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 6b4ccb84396..e7db3c5bd3c 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( diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 51954e7d564..1e18a20d8ad 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -24,12 +24,12 @@ import { tableIdParamsSchema, tableJobSummarySchema, tableLocksSchema, + tableNameSchema, tableRowParamsSchema, tableRowsQueryBaseSchema, tableViewConfigSchema, tableViewParamsSchema, updateRowsByFilterBodySchema, - updateTableBodySchema, updateTableColumnBodySchema, updateTableRowBodySchema, updateTableViewBodySchema, @@ -108,7 +108,11 @@ export const v2ApiTableSchema = z.object({ maxRows: z.number(), /** Owning folder, or `null` when the table sits at the workspace root. */ folderId: z.string().nullable(), - /** Governance flags. Writable only by a workspace admin via `PATCH`. */ + /** + * 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(), @@ -252,23 +256,45 @@ export const v2GetTableContract = defineRouteContract({ /** * Table update. Every field is optional but at least one must be present: - * `name` renames, `folderId` moves the table (explicit `null` moves it to the - * workspace root; omission leaves the placement untouched), and `locks` flips - * the governance flags. The lock branch additionally requires workspace `admin` - * and the `table-locks` feature, matching the first-party surface — a `write` - * caller can rename and move but not lock. + * `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: updateTableBodySchema, + body: v2UpdateTableBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableDataSchema), }, }) -export type V2UpdateTableBody = z.input +export type V2UpdateTableBody = z.input export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', From 2366a4e032aeb1b66a393ef5db9fc1c964140321 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 10:28:44 -0700 Subject: [PATCH 07/13] fix(api): keep reporting applied operations when the PATCH re-read fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composite table PATCH promises that `error.details.applied` names the operations that are live despite an error, but `applied` was scoped inside the try. A rename or move that committed and was then followed by a throw in the final re-read — or a re-read finding the table archived — returned a bare 500/404 with no details, telling the caller nothing had landed. It would then retry into a duplicate-name conflict or repeat the move. `applied` is now function-scoped so every post-write exit carries it: the 404 on a missing re-read, a thrown lock error, a classified orchestration error, and the generic 500. `v2TableLockError` gains the same `extraDetails` parameter `v2TableOrchestrationError` already had. --- .../app/api/v2/tables/[tableId]/route.test.ts | 34 +++++++++++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 56 ++++++++++++++----- apps/sim/app/api/v2/tables/utils.ts | 8 ++- 3 files changed, 81 insertions(+), 17 deletions(-) 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 bca0c8e3190..735529f80a0 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -341,6 +341,40 @@ describe('PATCH /api/v2/tables/[tableId]', () => { 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({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index e58af81976c..66e0c92bac4 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -7,6 +7,7 @@ import { 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 { findActiveFolder } from '@/lib/folders/queries' @@ -21,7 +22,6 @@ 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, @@ -38,6 +38,17 @@ import { 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 @@ -101,6 +112,15 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR 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) @@ -151,7 +171,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl // per-operation audits — so instead of pretending atomicity the response // states exactly which operations landed. A caller that gets an error can // then reconcile rather than having to re-read and diff. - const applied: ('name' | 'folderId')[] = [] let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null if (validated.name !== undefined) { @@ -190,31 +209,38 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl // Live-collab: tell open viewers the definition changed so they refetch. if (applied.length > 0) signalTableSchemaChanged(tableId) if (failure) { - return v2TableOrchestrationError( - failure.outcome, - failure.fallback, - // Omitted when nothing landed, so `details.applied` present always - // means "these changes are live despite the error". - applied.length > 0 ? { applied } : undefined - ) + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) } - // Re-read so the response reflects every applied change at once. + // 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') + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } return v2Data({ table: toApiTable(updated) }, { rateLimit }) } catch (error) { - const lockError = v2TableLockError(error) + const details = appliedDetails(applied) + + const lockError = v2TableLockError(error, details) if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified + 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') + return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) } }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 58f0c97196b..bcc780e23ab 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -184,9 +184,13 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne * 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 { +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 } }) + return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } }) } return null } From 03e6ee98fef3a0d3e5880f8d2c91588a5c253966 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 11:18:24 -0700 Subject: [PATCH 08/13] feat(api): add workflow group writes to the v2 tables surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 exposed GET /groups but none of the writes, so the public API could run an enrichment or workflow column and read its binding, but never create one. A caller could add a plain data column and trigger the machine; wiring the two together still required the UI. Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is the unit that fills columns — one group feeds several — so creating one creates its output columns in the same call, matching the first-party shape rather than inverting it onto the column endpoint. Four departures from the first-party body, all public-surface concerns: - group.id is optional and server-generated. The UI mints an id to render optimistically; a public caller has no such need and a client-chosen id is a collision waiting to happen. - outputColumns[].workflowGroupId is dropped from the body and stamped from the resolved group, so it cannot disagree with it. - autoRun defaults to false. First-party defaults true so a UI add fills cells immediately; here it would make one POST fan out a metered run across every existing row. - A group naming neither a workflowId (type manual) nor an enrichmentId (type enrichment) is a 400 rather than a half-specified group the route has to guess about. Also rejects an outputColumns entry no group output feeds — the two arrays are joined by column name, and the first-party client builds both from one picker so it cannot desync, but a public caller can. Workspace containment on workflowId is asserted before it is persisted, on create and on any update that re-points the group; without it a table becomes a way to invoke workflows the key cannot otherwise reach. --- apps/docs/openapi-v2-tables.json | 598 ++++++++++++++++++ .../v2/tables/[tableId]/groups/route.test.ts | 319 +++++++++- .../api/v2/tables/[tableId]/groups/route.ts | 297 ++++++++- apps/sim/lib/api/contracts/tables.ts | 2 +- apps/sim/lib/api/contracts/v2/tables.ts | 133 ++++ 5 files changed, 1337 insertions(+), 12 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 97b02195026..1f3ce6597cc 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2393,6 +2393,373 @@ "$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": { @@ -5475,6 +5842,237 @@ "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", + "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": { + "id": { + "type": "string", + "minLength": 1, + "description": "Optional. Omit to have the server generate one." + }, + "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", + "minItems": 1, + "description": "Where each value comes from. Workflow outputs carry `blockId`/`path`; enrichment outputs carry `outputId`.", + "items": { + "type": "object" + } + }, + "inputMappings": { + "type": "array", + "description": "Workflow Start-block inputs fed from table columns.", + "items": { + "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." + } + } + }, + "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", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["group", "columns"], + "properties": { + "group": { + "$ref": "#/components/schemas/WorkflowGroup" + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "DeleteWorkflowGroupEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted", "columns"], + "properties": { + "id": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "enum": [true] + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } } }, "responses": { 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 index f42f437eb8a..0a847cabcac 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -8,13 +8,27 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockGateError } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGateError: vi.fn(), - })) +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, @@ -28,9 +42,21 @@ vi.mock('@/app/api/table/utils', () => ({ 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 { GET } from '@/app/api/v2/tables/[tableId]/groups/route' +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/groups/route' const GROUP = { id: 'group-1', @@ -132,3 +158,280 @@ describe('GET /api/v2/tables/[tableId]/groups', () => { 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 index 0f4bc8e3fd7..2d96bf9149a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -1,21 +1,36 @@ 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 { v2ListWorkflowGroupsContract } from '@/lib/api/contracts/v2/tables' +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 { TableSchema } from '@/lib/table' -import { checkAccess } from '@/app/api/table/utils' +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') @@ -74,3 +89,279 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR 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/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index e7db3c5bd3c..666b9d55552 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -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(), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 1e18a20d8ad..55758ba8960 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { + addWorkflowGroupBodySchema, cancelTableJobBodySchema, cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, @@ -9,6 +10,7 @@ import { csvImportMappingSchema, csvImportModeSchema, deleteTableColumnBodySchema, + deleteWorkflowGroupBodySchema, exportDownloadQuerySchema, exportTableAsyncBodySchema, importIntoTableAsyncBodySchema, @@ -33,7 +35,9 @@ import { 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' @@ -682,6 +686,135 @@ export const v2ListWorkflowGroupsContract = defineRouteContract({ }, }) +/** + * 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 From f6f5084b4c744a668d0a734eb002686889840566 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 11:55:13 -0700 Subject: [PATCH 09/13] improvement(api): make v2 table import and export async-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the three synchronous entry points: POST /tables/[tableId]/import, POST /tables/import-csv, and GET /tables/[tableId]/export. Sync import tied a write to the lifetime of an HTTP request. The body *was* the data, so it carried a 10 MB cap that Next silently truncates past — a partial import reporting success. It also had no job, so a timeout mid-write left rows in place with nothing to poll and nothing to cancel. The async path reads the file from storage instead: upload via POST /api/v2/files for a key, start with POST /import-async, watch GET /tables/[tableId] -> job, stop with POST /job/cancel. Sync export carried no such hazard, but one shape per operation beats two: with both removed the surface has exactly one way to move a table in or out, and the CLI wraps the extra calls. This also removes the last multipart handling in v2 tables. Those were the only routes bypassing parseRequest — form fields were parsed by hand against separate form schemas, outside the contract system every other v2 write goes through. Create-a-table-from-CSV is now two calls: POST /tables, then /import-async with createColumns. csvImportModeSchema is append|replace, so there is no single-call create. Route baseline 1064 -> 1061. --- apps/docs/openapi-v2-tables.json | 370 ------------------ .../v2/tables/[tableId]/export/route.test.ts | 170 -------- .../api/v2/tables/[tableId]/export/route.ts | 111 ------ .../v2/tables/[tableId]/import/route.test.ts | 233 ----------- .../api/v2/tables/[tableId]/import/route.ts | 137 ------- .../api/v2/tables/import-csv/route.test.ts | 228 ----------- .../sim/app/api/v2/tables/import-csv/route.ts | 140 ------- apps/sim/lib/api/contracts/v2/tables.ts | 89 +---- scripts/check-api-validation-contracts.ts | 4 +- 9 files changed, 9 insertions(+), 1473 deletions(-) delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/route.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/import/route.ts delete mode 100644 apps/sim/app/api/v2/tables/import-csv/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/import-csv/route.ts diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 1f3ce6597cc..3eef499e49e 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3076,77 +3076,6 @@ } } }, - "/api/v2/tables/import-csv": { - "post": { - "operationId": "createTableFromCsv", - "summary": "Create Table From CSV", - "description": "Create a table from a CSV or TSV file. The column schema is inferred from the file’s first rows and the table is named after the file.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part, so an unauthorized upload is rejected before its bytes are read. Rows stream in as they are parsed, so a file larger than memory still imports; a failure part way through drops the half-populated table rather than leaving it behind.", - "tags": ["Tables"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/import-csv\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"workspaceId=YOUR_WORKSPACE_ID\" \\\n -F \"file=@contacts.csv\"" - } - ], - "requestBody": { - "required": true, - "description": "Bodies over 10 MB are rejected with 413 — use the async import instead.", - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateTableFromCsvForm" - } - } - } - }, - "responses": { - "201": { - "description": "The created table.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/RateLimitLimit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/RateLimitRemaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/RateLimitReset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TableEnvelope" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/tables/jobs": { "get": { "operationId": "listTableJobs", @@ -3224,99 +3153,6 @@ } } }, - "/api/v2/tables/{tableId}/import": { - "post": { - "operationId": "importTableCsv", - "summary": "Import CSV", - "description": "Import a CSV or TSV into an existing table, appending or replacing its rows.\n\nSend `multipart/form-data` with `workspaceId` **before** the file part. Omit `mapping` to auto-map CSV headers to same-named columns; pass `createColumns` to have unmatched headers created as new columns, with types inferred from the file. The response reports what was written AND what was not (`skippedHeaders`, `unmappedColumns`), so a partial mapping is visible without diffing the schema.\n\nThe table’s single write-job slot is held for the whole import, so a concurrent import or delete gets 409. Files over 10 MB must use `POST /import-async`.", - "tags": ["Tables"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"workspaceId=YOUR_WORKSPACE_ID\" \\\n -F \"mode=append\" \\\n -F \"file=@contacts.csv\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/TableId" - } - ], - "requestBody": { - "required": true, - "description": "Bodies over 10 MB are rejected with 413 — use the async import instead.", - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ImportTableForm" - } - } - } - }, - "responses": { - "200": { - "description": "The import summary.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/RateLimitLimit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/RateLimitRemaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/RateLimitReset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImportTableEnvelope" - }, - "example": { - "data": { - "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "mode": "append", - "insertedCount": 250, - "mappedColumns": ["Email", "Full Name"], - "skippedHeaders": ["Notes"], - "unmappedColumns": ["created_by"], - "sourceFile": "contacts.csv" - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/tables/{tableId}/import-async": { "post": { "operationId": "importTableCsvAsync", @@ -3407,89 +3243,6 @@ } } }, - "/api/v2/tables/{tableId}/export": { - "get": { - "operationId": "exportTable", - "summary": "Export Table", - "description": "Stream the whole table as a CSV or JSON file attachment.\n\nThe only endpoint whose success body is the file itself rather than the `{ data }` envelope. Rows are written as they are read, so nothing is buffered — but once the stream has started a failure can only tear the connection down. Large tables should use `POST /export-async`, which survives a dropped connection and leaves a re-downloadable result.", - "tags": ["Tables"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export?workspaceId=YOUR_WORKSPACE_ID&format=csv\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o table.csv" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/TableId" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - }, - { - "$ref": "#/components/parameters/ExportFormatQuery" - } - ], - "responses": { - "200": { - "description": "The table contents. CSV carries a header row of column names; JSON is an array of name-keyed row objects.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/RateLimitLimit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/RateLimitRemaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/RateLimitReset" - }, - "Content-Disposition": { - "description": "Attachment filename, derived from the table name.", - "schema": { - "type": "string", - "example": "attachment; filename=\"customers.csv\"" - } - } - }, - "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/tables/{tableId}/export-async": { "post": { "operationId": "exportTableAsync", @@ -5422,129 +5175,6 @@ } } }, - "ImportTableForm": { - "type": "object", - "description": "Multipart form for a synchronous import. `mapping` and `createColumns` are JSON-encoded strings, since every multipart field arrives as text.", - "required": ["workspaceId", "file"], - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table. Must appear BEFORE the file part — the server rejects an unauthorized upload before reading its bytes." - }, - "file": { - "type": "string", - "format": "binary", - "description": "The .csv or .tsv file." - }, - "mode": { - "enum": ["append", "replace"], - "default": "append", - "description": "`append` adds rows; `replace` deletes every existing row first." - }, - "mapping": { - "type": "string", - "description": "JSON object mapping each CSV header to a column name, or null to skip that header. Omit to auto-map headers to same-named columns.", - "example": "{\"Email\":\"email\",\"Full Name\":\"name\",\"Notes\":null}" - }, - "createColumns": { - "type": "string", - "description": "JSON array of CSV headers to create as new columns before importing. Their types are inferred from the file.", - "example": "[\"Phone\"]" - }, - "timezone": { - "type": "string", - "description": "IANA zone used to read naive datetimes (Excel and Sheets exports carry no offset). Defaults to the API key owner’s saved timezone, else UTC.", - "example": "America/New_York" - } - } - }, - "CreateTableFromCsvForm": { - "type": "object", - "description": "Multipart form for creating a table from a file.", - "required": ["workspaceId", "file"], - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace to create the table in. Must appear BEFORE the file part — the server rejects an unauthorized upload before reading its bytes." - }, - "file": { - "type": "string", - "format": "binary", - "description": "The .csv or .tsv file." - }, - "folderId": { - "type": "string", - "description": "Folder to create the table in. Omit to create it at the workspace root." - }, - "timezone": { - "type": "string", - "description": "IANA zone used to read naive datetimes. Defaults to the API key owner’s saved timezone, else UTC.", - "example": "America/New_York" - } - } - }, - "ImportTableEnvelope": { - "type": "object", - "description": "Synchronous-import summary wrapped in the v2 data envelope.", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": [ - "tableId", - "mode", - "insertedCount", - "mappedColumns", - "skippedHeaders", - "unmappedColumns", - "sourceFile" - ], - "properties": { - "tableId": { - "type": "string" - }, - "mode": { - "enum": ["append", "replace"] - }, - "insertedCount": { - "type": "integer", - "description": "Rows written." - }, - "deletedCount": { - "type": "integer", - "description": "Rows removed first. Present only for `mode: \"replace\"`." - }, - "mappedColumns": { - "type": "array", - "items": { - "type": "string" - }, - "description": "CSV headers that were written to a column." - }, - "skippedHeaders": { - "type": "array", - "items": { - "type": "string" - }, - "description": "CSV headers the mapping explicitly skipped." - }, - "unmappedColumns": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Table columns no CSV header supplied — left at their existing values." - }, - "sourceFile": { - "type": "string", - "description": "Uploaded filename, echoed back." - } - } - } - } - }, "ImportAsyncEnvelope": { "type": "object", "description": "Background-import kickoff acknowledgement.", diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts deleted file mode 100644 index 82ebb10a801..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/export/route.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 streaming export — the one v2 success body that is a file rather - * than the `{ data }` envelope. The audit is recorded BEFORE the first byte: - * rows leave incrementally, so a mid-stream failure has still exfiltrated - * whatever was written. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockCreateExportStream, - mockRecordAudit, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockCreateExportStream: vi.fn(), - mockRecordAudit: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { TABLE_EXPORTED: 'table.exported' }, - AuditResourceType: { TABLE: 'table' }, - recordAudit: mockRecordAudit, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, -})) - -vi.mock('@/lib/table/export-stream', () => ({ - createTableExportStream: mockCreateExportStream, - exportContentType: (format: string) => - format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', - sanitizeExportFilename: (name: string) => name, -})) - -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { GET } from '@/app/api/v2/tables/[tableId]/export/route' - -const TABLE = { - id: 'table-1', - name: 'customers', - workspaceId: 'ws-1', - rowCount: 3, - schema: { columns: [] }, -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callGet(query = 'workspaceId=ws-1') { - const req = new NextRequest(`http://localhost:3000/api/v2/tables/table-1/export?${query}`, { - method: 'GET', - }) - return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockCreateExportStream.mockReturnValue( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('email\na@b.c\n')) - controller.close() - }, - }) - ) - mockGateError.mockResolvedValue(null) -}) - -describe('GET /api/v2/tables/[tableId]/export', () => { - it('streams the file with the rate-limit and attachment headers', async () => { - const res = await callGet() - - expect(res.status).toBe(200) - expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8') - expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="customers.csv"') - // The envelope carries these on every other v2 endpoint; a stream response - // has to set them by hand or the whole surface stops being uniform. - expect(res.headers.get('X-RateLimit-Limit')).toBe('100') - expect(await res.text()).toBe('email\na@b.c\n') - expect(mockCreateExportStream).toHaveBeenCalledWith(TABLE, 'csv', expect.any(String)) - }) - - it('defaults to csv and honours an explicit json format', async () => { - const res = await callGet('workspaceId=ws-1&format=json') - - expect(res.headers.get('Content-Type')).toBe('application/json') - expect(mockCreateExportStream).toHaveBeenCalledWith(TABLE, 'json', expect.any(String)) - }) - - it('audits before the first byte leaves', async () => { - await callGet() - - expect(mockRecordAudit).toHaveBeenCalledWith( - expect.objectContaining({ resourceId: 'table-1', actorId: 'user-1' }) - ) - }) - - it('400s an unsupported format', async () => { - const res = await callGet('workspaceId=ws-1&format=xml') - - expect(res.status).toBe(400) - expect(mockCreateExportStream).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockCreateExportStream).not.toHaveBeenCalled() - expect(mockRecordAudit).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockCreateExportStream).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockCreateExportStream).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export/route.ts deleted file mode 100644 index 6570551b5c1..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/export/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { v2ExportTableContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { - createTableExportStream, - exportContentType, - sanitizeExportFilename, -} from '@/lib/table/export-stream' -import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - rateLimitHeaders, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2TableExportAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * GET /api/v2/tables/[tableId]/export — Stream the whole table as a file. - * - * The one v2 endpoint whose success body is NOT the `{ data }` envelope: the - * body is the file. Rate-limit headers are attached by hand for the same - * reason. Errors before the first byte still use the canonical envelope; once - * the stream has started a failure can only tear the connection down, which is - * why large tables belong on the async export. - */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ExportTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, format } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const { table } = access - - // Audit BEFORE streaming: rows leave incrementally, so a mid-stream failure - // has still exfiltrated whatever was written. - recordAudit({ - workspaceId: table.workspaceId ?? null, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Exported table "${table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: table.rowCount }, - request, - }) - captureServerEvent( - userId, - 'table_exported', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return new NextResponse(createTableExportStream(table, format, requestId), { - status: 200, - headers: { - ...rateLimitHeaders(rateLimit), - 'Content-Type': exportContentType(format), - 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, - 'Cache-Control': 'private, no-store', - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error exporting table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts deleted file mode 100644 index f7f28c4cd93..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/import/route.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 synchronous CSV import. The body is multipart, so it never goes - * through `parseRequest`; the collected text fields are parsed against the - * contract's form schema instead, and the whole import is delegated to the - * orchestration function so v1 and v2 cannot drift on what an import does. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockReadMultipart, - mockPerformImport, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockReadMultipart: vi.fn(), - mockPerformImport: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, -})) - -vi.mock('@/lib/core/utils/multipart', () => ({ - readMultipart: mockReadMultipart, - isMultipartError: (error: unknown) => - typeof error === 'object' && error !== null && 'code' in error, -})) - -vi.mock('@/lib/table/orchestration', () => ({ performTableCsvImport: mockPerformImport })) -vi.mock('@/lib/table', () => ({ CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024 })) -vi.mock('@/lib/users/queries', () => ({ - getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { POST } from '@/app/api/v2/tables/[tableId]/import/route' - -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -const IMPORT_DATA = { - tableId: 'table-1', - mode: 'append', - insertedCount: 3, - mappedColumns: ['Email'], - skippedHeaders: [], - unmappedColumns: [], - sourceFile: 'contacts.csv', -} - -function fileStream() { - return { destroy: vi.fn() } -} - -function callPost(options: { contentLength?: string } = {}) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/import', { - method: 'POST', - headers: { - 'Content-Type': 'multipart/form-data; boundary=x', - ...(options.contentLength ? { 'content-length': options.contentLength } : {}), - }, - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockReadMultipart.mockResolvedValue({ - fields: { workspaceId: 'ws-1', mode: 'append' }, - file: { filename: 'contacts.csv', stream: fileStream() }, - }) - mockPerformImport.mockResolvedValue({ success: true, data: IMPORT_DATA }) - mockGateError.mockResolvedValue(null) -}) - -describe('POST /api/v2/tables/[tableId]/import', () => { - it('delegates the whole import and returns the summary', async () => { - const res = await callPost() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual(IMPORT_DATA) - expect(mockPerformImport).toHaveBeenCalledWith( - expect.objectContaining({ - table: TABLE, - workspaceId: 'ws-1', - userId: 'user-1', - fileName: 'contacts.csv', - fallbackDelimiter: ',', - mode: 'append', - }) - ) - }) - - it('picks the tab fallback from a .tsv extension', async () => { - mockReadMultipart.mockResolvedValue({ - fields: { workspaceId: 'ws-1' }, - file: { filename: 'contacts.tsv', stream: fileStream() }, - }) - - await callPost() - - expect(mockPerformImport).toHaveBeenCalledWith( - expect.objectContaining({ fallbackDelimiter: '\t', mode: 'append' }) - ) - }) - - it('requires workspaceId ahead of the file part so an unauthorized upload is never read', async () => { - await callPost() - - expect(mockReadMultipart).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ requiredFieldsBeforeFile: ['workspaceId'] }) - ) - }) - - it('413s an oversize body rather than importing a silently truncated file', async () => { - const res = await callPost({ contentLength: String(11 * 1024 * 1024) }) - - expect(res.status).toBe(413) - expect(mockReadMultipart).not.toHaveBeenCalled() - expect(mockPerformImport).not.toHaveBeenCalled() - }) - - it('400s an unsupported file extension', async () => { - mockReadMultipart.mockResolvedValue({ - fields: { workspaceId: 'ws-1' }, - file: { filename: 'contacts.xlsx', stream: fileStream() }, - }) - - const res = await callPost() - - expect(res.status).toBe(400) - expect(mockPerformImport).not.toHaveBeenCalled() - }) - - it('400s a form with no workspaceId', async () => { - mockReadMultipart.mockResolvedValue({ - fields: {}, - file: { filename: 'contacts.csv', stream: fileStream() }, - }) - - const res = await callPost() - - expect(res.status).toBe(400) - expect(mockPerformImport).not.toHaveBeenCalled() - }) - - it('404s a table in another workspace without importing', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callPost() - - expect(res.status).toBe(404) - expect(mockPerformImport).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost() - - expect(res.status).toBe(403) - expect(mockPerformImport).not.toHaveBeenCalled() - }) - - it.each([ - ['conflict', 409, 'CONFLICT'], - ['locked', 423, 'LOCKED'], - ['validation', 400, 'BAD_REQUEST'], - ])('maps a %s import failure to %i', async (errorCode, status, code) => { - mockPerformImport.mockResolvedValue({ success: false, errorCode, error: 'nope' }) - - const res = await callPost() - - expect(res.status).toBe(status) - expect((await res.json()).error.code).toBe(code) - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost() - - expect(res.status).toBe(404) - expect(mockReadMultipart).not.toHaveBeenCalled() - expect(mockPerformImport).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost() - - expect(res.status).toBe(429) - expect(mockPerformImport).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import/route.ts deleted file mode 100644 index 67c11575988..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/import/route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { Readable } from 'node:stream' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { csvExtensionSchema } from '@/lib/api/contracts/tables' -import { - v2ImportIntoTableFormSchema, - v2ImportTableCsvContract, -} from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' -import { performTableCsvImport } from '@/lib/table/orchestration' -import { getUserSettings } from '@/lib/users/queries' -import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - v2CsvBodyCapError, - v2MultipartError, - v2TableAccessError, - v2TableOrchestrationError, -} from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableImportAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/v2/tables/[tableId]/import — Synchronous CSV/TSV import. - * - * `multipart/form-data`, so the body never goes through `parseRequest` — the - * streaming reader consumes the parts and the collected text fields are parsed - * in one pass against the contract's form schema. Auth still runs first: the - * reader is told to require `workspaceId` ahead of the file part so an - * unauthorized upload is rejected before its bytes are read. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - let fileStream: Readable | undefined - - try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ImportTableCsvContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - - const oversize = v2CsvBodyCapError(request) - if (oversize) return oversize - - let multipart: Awaited> - try { - multipart = await readMultipart(request, { - maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, - requiredFieldsBeforeFile: ['workspaceId'], - signal: request.signal, - }) - } catch (err) { - if (isMultipartError(err)) return v2MultipartError(err) - throw err - } - - const { fields, file } = multipart - if (!file) return v2Error('BAD_REQUEST', 'CSV file is required') - fileStream = file.stream - - const form = v2ImportIntoTableFormSchema.safeParse(fields) - if (!form.success) return v2ValidationError(form.error) - - const extension = csvExtensionSchema.safeParse(file.filename.split('.').pop()?.toLowerCase()) - if (!extension.success) return v2ValidationError(extension.error) - - const scopeError = await resolveWorkspaceScope(rateLimit, form.data.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== form.data.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performTableCsvImport({ - table: access.table, - workspaceId: form.data.workspaceId, - userId, - fileStream: file.stream, - fileName: file.filename, - fallbackDelimiter: extension.data === 'tsv' ? '\t' : ',', - mode: form.data.mode, - mapping: form.data.mapping, - createColumns: form.data.createColumns, - timezone: form.data.timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', - requestId, - }) - - if (!outcome.success || !outcome.data) { - return v2TableOrchestrationError(outcome, 'Failed to import CSV') - } - - return v2Data(outcome.data, { rateLimit }) - } catch (error) { - if (isMultipartError(error)) return v2MultipartError(error) - - logger.error(`[${requestId}] Error importing CSV into table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } finally { - fileStream?.destroy() - } -}) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.test.ts b/apps/sim/app/api/v2/tables/import-csv/route.test.ts deleted file mode 100644 index a13d5c901e2..00000000000 --- a/apps/sim/app/api/v2/tables/import-csv/route.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 create-table-from-CSV. Workspace-scoped rather than table-scoped — - * there is no table to authorize against yet — and the response is re-read - * through `toApiTable` so it carries the same table shape as every other v2 - * endpoint rather than the import's partial view. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockReadMultipart, - mockPerformCreate, - mockGetTableById, - mockFindActiveFolder, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockReadMultipart: vi.fn(), - mockPerformCreate: vi.fn(), - mockGetTableById: vi.fn(), - mockFindActiveFolder: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/core/utils/multipart', () => ({ - readMultipart: mockReadMultipart, - isMultipartError: (error: unknown) => - typeof error === 'object' && error !== null && 'code' in error, -})) - -vi.mock('@/lib/table/orchestration', () => ({ performCreateTableFromCsv: mockPerformCreate })) -vi.mock('@/lib/table', () => ({ - CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024, - getTableById: mockGetTableById, -})) -vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) -vi.mock('@/lib/users/queries', () => ({ - getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { POST } from '@/app/api/v2/tables/import-csv/route' - -const UNLOCKED = { - schemaLocked: false, - insertLocked: false, - updateLocked: false, - deleteLocked: false, -} -const CREATED_TABLE = { - id: 'table-1', - name: 'contacts', - description: 'Imported from contacts.csv', - workspaceId: 'ws-1', - schema: { columns: [] }, - rowCount: 3, - maxRows: 1000, - folderId: null, - locks: UNLOCKED, - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-01T00:00:00Z'), -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callPost(options: { contentLength?: string } = {}) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/import-csv', { - method: 'POST', - headers: { - 'Content-Type': 'multipart/form-data; boundary=x', - ...(options.contentLength ? { 'content-length': options.contentLength } : {}), - }, - }) - return POST(req) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockReadMultipart.mockResolvedValue({ - fields: { workspaceId: 'ws-1' }, - file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, - }) - mockPerformCreate.mockResolvedValue({ success: true, data: { table: { id: 'table-1' } } }) - mockGetTableById.mockResolvedValue(CREATED_TABLE) - mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) - mockGateError.mockResolvedValue(null) -}) - -describe('POST /api/v2/tables/import-csv', () => { - it('creates the table and answers 201 with the canonical table shape', async () => { - const res = await callPost() - - expect(res.status).toBe(201) - expect((await res.json()).data).toEqual({ - table: { - id: 'table-1', - name: 'contacts', - description: 'Imported from contacts.csv', - schema: { columns: [] }, - rowCount: 3, - maxRows: 1000, - folderId: null, - locks: UNLOCKED, - job: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, - }) - expect(mockPerformCreate).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'ws-1', - userId: 'user-1', - fileName: 'contacts.csv', - fallbackDelimiter: ',', - folderId: null, - }) - ) - }) - - it('checks a supplied folder is a table folder in this workspace', async () => { - mockReadMultipart.mockResolvedValue({ - fields: { workspaceId: 'ws-1', folderId: 'folder-1' }, - file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, - }) - - await callPost() - - expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') - expect(mockPerformCreate).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'folder-1' }) - ) - }) - - it('404s a folder from outside the workspace without importing', async () => { - mockReadMultipart.mockResolvedValue({ - fields: { workspaceId: 'ws-1', folderId: 'folder-elsewhere' }, - file: { filename: 'contacts.csv', stream: { destroy: vi.fn() } }, - }) - mockFindActiveFolder.mockResolvedValue(null) - - const res = await callPost() - - expect(res.status).toBe(404) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('413s an oversize body rather than importing a silently truncated file', async () => { - const res = await callPost({ contentLength: String(11 * 1024 * 1024) }) - - expect(res.status).toBe(413) - expect(mockReadMultipart).not.toHaveBeenCalled() - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('403s a caller without workspace write', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const res = await callPost() - - expect(res.status).toBe(403) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('400s a file with no data rows', async () => { - mockPerformCreate.mockResolvedValue({ - success: false, - errorCode: 'validation', - error: 'CSV file has no data rows', - }) - - const res = await callPost() - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe('CSV file has no data rows') - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost() - - expect(res.status).toBe(404) - expect(mockReadMultipart).not.toHaveBeenCalled() - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost() - - expect(res.status).toBe(429) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/import-csv/route.ts b/apps/sim/app/api/v2/tables/import-csv/route.ts deleted file mode 100644 index d207c4cff91..00000000000 --- a/apps/sim/app/api/v2/tables/import-csv/route.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Readable } from 'node:stream' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { csvExtensionSchema } from '@/lib/api/contracts/tables' -import { - v2CreateTableFromCsvContract, - v2CreateTableFromCsvFormSchema, -} from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { findActiveFolder } from '@/lib/folders/queries' -import { CSV_MAX_FILE_SIZE_BYTES, getTableById } from '@/lib/table' -import { performCreateTableFromCsv } from '@/lib/table/orchestration' -import { getUserSettings } from '@/lib/users/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { toApiTable, v2CsvBodyCapError, v2MultipartError } from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2CreateTableFromCsvAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -/** - * POST /api/v2/tables/import-csv — Create a table from a CSV/TSV. - * - * The column schema is inferred from the file's first rows and the table is - * named after the file. Workspace-scoped rather than table-scoped, so the - * permission check is the workspace one — there is no table to authorize - * against yet. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - let fileStream: Readable | undefined - - try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateTableFromCsvContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const oversize = v2CsvBodyCapError(request) - if (oversize) return oversize - - let multipart: Awaited> - try { - multipart = await readMultipart(request, { - maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, - requiredFieldsBeforeFile: ['workspaceId'], - signal: request.signal, - }) - } catch (err) { - if (isMultipartError(err)) return v2MultipartError(err) - throw err - } - - const { fields, file } = multipart - if (!file) return v2Error('BAD_REQUEST', 'CSV file is required') - fileStream = file.stream - - const form = v2CreateTableFromCsvFormSchema.safeParse(fields) - if (!form.success) return v2ValidationError(form.error) - - const extension = csvExtensionSchema.safeParse(file.filename.split('.').pop()?.toLowerCase()) - if (!extension.success) return v2ValidationError(extension.error) - - const accessError = await resolveWorkspaceAccess( - rateLimit, - userId, - form.data.workspaceId, - 'write' - ) - if (accessError) return v2WorkspaceAccessError(accessError) - - // Scoped to `resourceType: 'table'` so a folder id from another resource's - // tree can't file the imported table where Tables never lists it. - if ( - form.data.folderId && - !(await findActiveFolder(form.data.folderId, form.data.workspaceId, 'table')) - ) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } - - const outcome = await performCreateTableFromCsv({ - workspaceId: form.data.workspaceId, - userId, - fileStream: file.stream, - fileName: file.filename, - fallbackDelimiter: extension.data === 'tsv' ? '\t' : ',', - folderId: form.data.folderId ?? null, - timezone: form.data.timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', - requestId, - }) - - if (!outcome.success || !outcome.data) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to import CSV') - } - - // Re-read so the response carries the canonical v2 table shape (row count, - // plan row cap, timestamps) rather than the import's partial view. - const table = await getTableById(outcome.data.table.id) - if (!table) return v2Error('INTERNAL_ERROR', 'Internal server error') - - return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) - } catch (error) { - if (isMultipartError(error)) return v2MultipartError(error) - - logger.error(`[${requestId}] Error creating table from CSV`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } finally { - fileStream?.destroy() - } -}) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 55758ba8960..7d5f9903688 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -22,7 +22,6 @@ import { runColumnScopeMutexRefine, sortSpecSchema, tableColumnSchema, - tableExportFormatSchema, tableIdParamsSchema, tableJobSummarySchema, tableLocksSchema, @@ -937,61 +936,6 @@ export const v2ImportIntoTableFormSchema = z.object({ }) export type V2ImportIntoTableForm = z.input -/** - * Synchronous-import summary. `deletedCount` is present only for - * `mode: "replace"`; `skippedHeaders` and `unmappedColumns` report what the - * import chose NOT to write, so a caller can tell a partial mapping from a - * complete one without diffing the schema. - */ -export const v2ImportTableDataSchema = z.object({ - tableId: z.string(), - mode: csvImportModeSchema, - insertedCount: z.number(), - deletedCount: z.number().optional(), - mappedColumns: z.array(z.string()), - skippedHeaders: z.array(z.string()), - unmappedColumns: z.array(z.string()), - sourceFile: z.string(), -}) -export type V2ImportTableData = z.output - -/** - * Synchronous CSV/TSV import into an existing table. Bounded by the request - * body cap — larger files go through `POST /import-async`, which reads the file - * from storage instead of the request. - */ -export const v2ImportTableCsvContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/[tableId]/import', - params: tableIdParamsSchema, - response: { - mode: 'json', - schema: v2DataResponse(v2ImportTableDataSchema), - }, -}) - -/** Multipart form fields for `POST /api/v2/tables/import-csv`. */ -export const v2CreateTableFromCsvFormSchema = z.object({ - workspaceId: workspaceIdSchema, - folderId: folderIdSchema.optional(), - timezone: ianaTimezoneSchema.optional(), -}) -export type V2CreateTableFromCsvForm = z.input - -/** - * Creates a NEW table from a CSV/TSV: the column schema is inferred from the - * file's first rows and the table is named after the file. Returns the created - * table in the same shape as every other v2 table endpoint. - */ -export const v2CreateTableFromCsvContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/import-csv', - response: { - mode: 'json', - schema: v2DataResponse(v2TableDataSchema), - }, -}) - /** Kickoff acknowledgement for a background import. */ export const v2ImportAsyncDataSchema = z.object({ tableId: z.string(), @@ -1000,9 +944,13 @@ export const v2ImportAsyncDataSchema = z.object({ export type V2ImportAsyncData = z.output /** - * Starts a background import of a file already uploaded to workspace storage. - * Returns immediately; track the job through `GET /api/v2/tables/jobs` and stop - * it with `POST /api/v2/tables/[tableId]/job/cancel`. + * Starts a background import of a file already uploaded to workspace storage + * (`POST /api/v2/files` returns the `key`). Import is the only way in — there is + * no synchronous upload endpoint, so no request-body size cliff. + * + * Returns immediately. A table carries at most one write job, so progress is + * read off the table itself (`GET /api/v2/tables/[tableId]` → `job`) rather than + * the export-only jobs list; stop it with `POST /api/v2/tables/[tableId]/job/cancel`. */ export const v2ImportTableAsyncContract = defineRouteContract({ method: 'POST', @@ -1015,29 +963,6 @@ export const v2ImportTableAsyncContract = defineRouteContract({ }, }) -/** Export query: the workspace scope plus the serialization format. */ -export const v2ExportTableQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - format: tableExportFormatSchema, -}) -export type V2ExportTableQuery = z.input - -/** - * Streams the whole table as a CSV or JSON attachment. `mode: 'stream'` because - * the body is the file itself, not the v2 JSON envelope — rows are written as - * they are read, so nothing is buffered. Large tables should use - * `POST /export-async` instead, which survives a dropped connection. - */ -export const v2ExportTableContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/tables/[tableId]/export', - params: tableIdParamsSchema, - query: v2ExportTableQuerySchema, - response: { - mode: 'stream', - }, -}) - /** Kickoff acknowledgement for a background export. */ export const v2ExportAsyncDataSchema = z.object({ tableId: z.string(), diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 18385c04b3e..1434211c000 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: 1064, - zodRoutes: 1064, + totalRoutes: 1061, + zodRoutes: 1061, nonZodRoutes: 0, } as const From 0427ff7b0ac0af45bcabaff006556bc04fecc942 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 12:13:13 -0700 Subject: [PATCH 10/13] docs(api): correct the import-async note about upload size limits The docstring claimed there is no synchronous upload endpoint and so no request-body size cliff. Both are wrong: POST /api/v2/files is a synchronous multipart upload with a 100 MB cap, and it is the only v2 upload path (presigned is deliberately absent). What async-only actually bought: the cap went 10 MB -> 100 MB, it fails on an explicit size check and a bounded body read rather than a proxy cap that silently truncates, authorization completes before any body is buffered, and the table write is a job that can be watched and cancelled. --- apps/sim/lib/api/contracts/v2/tables.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 7d5f9903688..cd10df711a2 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -945,8 +945,12 @@ export type V2ImportAsyncData = z.output /** * Starts a background import of a file already uploaded to workspace storage - * (`POST /api/v2/files` returns the `key`). Import is the only way in — there is - * no synchronous upload endpoint, so no request-body size cliff. + * (`POST /api/v2/files` returns the `key`). + * + * The upload step is still a synchronous multipart request capped at 100 MB, so + * the byte limit moved rather than vanished — but it now fails loudly on an + * explicit size check instead of relying on a proxy cap that truncates, and the + * table write itself is a job that can be watched and cancelled. * * Returns immediately. A table carries at most one write job, so progress is * read off the table itself (`GET /api/v2/tables/[tableId]` → `job`) rather than From 66144844f629e007125de4760bcc52fcac2b2d8f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 15:00:22 -0700 Subject: [PATCH 11/13] feat(api): unify file and table transfers --- apps/docs/openapi-v2-files-audit.json | 144 +- apps/docs/openapi-v2-tables.json | 275 +- .../cron/cleanup-stale-executions/route.ts | 95 +- .../uploads/[uploadId]/complete/route.ts | 63 + .../files/uploads/[uploadId]/parts/route.ts | 44 + .../app/api/files/uploads/[uploadId]/route.ts | 66 + apps/sim/app/api/files/uploads/route.ts | 39 + apps/sim/app/api/files/uploads/utils.ts | 29 + .../app/api/table/[tableId]/exports/route.ts | 39 + .../exports/[exportId]/download/route.ts | 47 + .../app/api/table/exports/[exportId]/route.ts | 68 + .../imports/[importId]/complete/route.ts | 64 + .../table/imports/[importId]/parts/route.ts | 49 + .../app/api/table/imports/[importId]/route.ts | 65 + apps/sim/app/api/table/imports/route.ts | 27 + apps/sim/app/api/v2/files/route.test.ts | 175 +- apps/sim/app/api/v2/files/route.ts | 143 +- .../uploads/[uploadId]/complete/route.ts | 90 + .../files/uploads/[uploadId]/parts/route.ts | 58 + .../api/v2/files/uploads/[uploadId]/route.ts | 83 + .../app/api/v2/files/uploads/route.test.ts | 146 + apps/sim/app/api/v2/files/uploads/route.ts | 61 + apps/sim/app/api/v2/files/uploads/utils.ts | 37 + .../[tableId]/export-async/route.test.ts | 177 - .../v2/tables/[tableId]/export-async/route.ts | 129 - .../[tableId]/export/download/route.test.ts | 169 - .../tables/[tableId]/export/download/route.ts | 90 - .../api/v2/tables/[tableId]/exports/route.ts | 67 + .../[tableId]/import-async/route.test.ts | 210 - .../v2/tables/[tableId]/import-async/route.ts | 148 - .../tables/[tableId]/job/cancel/route.test.ts | 162 - .../v2/tables/[tableId]/job/cancel/route.ts | 90 - apps/sim/app/api/v2/tables/[tableId]/route.ts | 2 - .../exports/[exportId]/download/route.ts | 69 + .../api/v2/tables/exports/[exportId]/route.ts | 92 + .../imports/[importId]/complete/route.ts | 84 + .../tables/imports/[importId]/parts/route.ts | 68 + .../api/v2/tables/imports/[importId]/route.ts | 88 + apps/sim/app/api/v2/tables/imports/route.ts | 53 + apps/sim/app/api/v2/tables/jobs/route.test.ts | 127 - apps/sim/app/api/v2/tables/jobs/route.ts | 69 - .../[uploadId]/parts/[partNumber]/route.ts | 58 + .../components/file-viewer/csv-import.ts | 26 +- .../components/file-viewer/file-viewer.tsx | 1 + .../components/file-viewer/preview-panel.tsx | 4 +- .../components/file-viewer/text-editor.tsx | 1 + .../resource-content/resource-content.tsx | 15 +- .../[tableId]/hooks/use-table-event-stream.ts | 2 +- .../[workspaceId]/tables/[tableId]/table.tsx | 20 +- .../import-csv-dialog/import-csv-dialog.tsx | 104 +- .../import-progress-menu.tsx | 6 +- .../use-workspace-imports.ts | 5 +- .../workspace/[workspaceId]/tables/tables.tsx | 167 +- apps/sim/background/cleanup-soft-deletes.ts | 11 +- .../lib/copy/storage-quota.ts | 2 +- apps/sim/hooks/queries/tables.ts | 405 +- apps/sim/hooks/queries/workspace-files.ts | 124 +- apps/sim/lib/api/contracts/table-transfers.ts | 90 + apps/sim/lib/api/contracts/upload-sessions.ts | 72 + apps/sim/lib/api/contracts/v2/files.ts | 104 +- apps/sim/lib/api/contracts/v2/tables.ts | 310 +- apps/sim/lib/api/contracts/v2/uploads.ts | 44 + apps/sim/lib/api/list-query.ts | 2 +- .../sim/lib/billing/storage/payer-transfer.ts | 4 +- apps/sim/lib/table/export-stream.ts | 21 +- apps/sim/lib/table/import-resource-store.ts | 57 + apps/sim/lib/table/import-runner.ts | 37 +- .../table/orchestration/export-resource.ts | 129 + .../table/orchestration/import-resource.ts | 379 + apps/sim/lib/table/types.ts | 5 +- .../uploads/client/multipart-session.test.ts | 86 + .../lib/uploads/client/multipart-session.ts | 88 + apps/sim/lib/uploads/client/session-upload.ts | 65 + apps/sim/lib/uploads/config.ts | 3 + .../workspace/workspace-file-manager.ts | 38 +- apps/sim/lib/uploads/core/storage-service.ts | 18 +- .../lib/uploads/multipart-session/provider.ts | 319 + .../lib/uploads/multipart-session/service.ts | 441 + apps/sim/lib/uploads/providers/blob/client.ts | 4 +- apps/sim/lib/uploads/server/metadata.ts | 11 +- apps/sim/lib/uploads/shared/types.ts | 12 + apps/sim/stores/table/import-tray/store.ts | 6 +- packages/db/migrations/0280_first_korath.sql | 58 + .../db/migrations/0281_fancy_blue_shield.sql | 1 + .../db/migrations/meta/0280_snapshot.json | 18813 +++++++++++++++ .../db/migrations/meta/0281_snapshot.json | 18819 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 14 + packages/db/schema.ts | 96 + scripts/check-api-validation-contracts.ts | 4 +- 89 files changed, 42445 insertions(+), 2457 deletions(-) create mode 100644 apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/files/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/files/uploads/route.ts create mode 100644 apps/sim/app/api/files/uploads/utils.ts create mode 100644 apps/sim/app/api/table/[tableId]/exports/route.ts create mode 100644 apps/sim/app/api/table/exports/[exportId]/download/route.ts create mode 100644 apps/sim/app/api/table/exports/[exportId]/route.ts create mode 100644 apps/sim/app/api/table/imports/[importId]/complete/route.ts create mode 100644 apps/sim/app/api/table/imports/[importId]/parts/route.ts create mode 100644 apps/sim/app/api/table/imports/[importId]/route.ts create mode 100644 apps/sim/app/api/table/imports/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/route.test.ts create mode 100644 apps/sim/app/api/v2/files/uploads/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/utils.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/exports/route.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts create mode 100644 apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts create mode 100644 apps/sim/app/api/v2/tables/exports/[exportId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/route.ts delete mode 100644 apps/sim/app/api/v2/tables/jobs/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/jobs/route.ts create mode 100644 apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts create mode 100644 apps/sim/lib/api/contracts/table-transfers.ts create mode 100644 apps/sim/lib/api/contracts/upload-sessions.ts create mode 100644 apps/sim/lib/api/contracts/v2/uploads.ts create mode 100644 apps/sim/lib/table/import-resource-store.ts create mode 100644 apps/sim/lib/table/orchestration/export-resource.ts create mode 100644 apps/sim/lib/table/orchestration/import-resource.ts create mode 100644 apps/sim/lib/uploads/client/multipart-session.test.ts create mode 100644 apps/sim/lib/uploads/client/multipart-session.ts create mode 100644 apps/sim/lib/uploads/client/session-upload.ts create mode 100644 apps/sim/lib/uploads/multipart-session/provider.ts create mode 100644 apps/sim/lib/uploads/multipart-session/service.ts create mode 100644 packages/db/migrations/0280_first_korath.sql create mode 100644 packages/db/migrations/0281_fancy_blue_shield.sql create mode 100644 packages/db/migrations/meta/0280_snapshot.json create mode 100644 packages/db/migrations/meta/0281_snapshot.json diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 476d289159e..505033bd690 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,148 @@ } } }, + "/api/v2/files/uploads": { + "post": { + "operationId": "createFileUpload", + "summary": "Create File Upload", + "description": "Create a durable multipart upload session. 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}": { + "get": { + "operationId": "getFileUpload", + "summary": "Get File Upload", + "description": "Read the durable state of a file upload session.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The upload session.", + "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": "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" } + ], + "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" } + ], + "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" } + ], + "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", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3eef499e49e..951065cb19b 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3077,7 +3077,7 @@ } }, "/api/v2/tables/jobs": { - "get": { + "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`.", @@ -3154,7 +3154,7 @@ } }, "/api/v2/tables/{tableId}/import-async": { - "post": { + "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.", @@ -3244,7 +3244,7 @@ } }, "/api/v2/tables/{tableId}/export-async": { - "post": { + "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`.", @@ -3329,7 +3329,7 @@ } }, "/api/v2/tables/{tableId}/export/download": { - "get": { + "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\".", @@ -3409,7 +3409,7 @@ } }, "/api/v2/tables/{tableId}/job/cancel": { - "post": { + "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`.", @@ -3490,6 +3490,265 @@ } } }, + "/api/v2/tables/imports": { + "post": { + "operationId": "createTableImport", + "summary": "Create Table Import", + "description": "Create one durable import resource for either a new or existing table. Upload sources return multipart details; workspace-file sources start immediately.", + "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 upload, processing, progress, and terminal state 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" } + ], + "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" } + ], + "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" } + ], + "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", @@ -5448,12 +5707,12 @@ }, "TableJobState": { "type": "object", - "description": "The table's in-flight background job. Import and delete jobs are derived onto the table itself (one write job per table), so the table is their status endpoint — poll `GET /api/v2/tables/{tableId}` after starting one. Exports are read-only and run concurrently, so they are listed separately by `GET /api/v2/tables/jobs` instead.", + "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": { "id": { "type": ["string", "null"], - "description": "Job id — pass to `POST /job/cancel` to stop it." + "description": "Job id. For durable imports this is also the import resource id." }, "type": { "enum": ["import", "delete", "export", "backfill", "update", null], @@ -5858,7 +6117,7 @@ } }, "PayloadTooLarge": { - "description": "The upload is too large for a synchronous import. Upload the file to workspace storage and use `POST /api/v2/tables/{tableId}/import-async` instead.", + "description": "The import source exceeds the 5 GB resource limit.", "content": { "application/json": { "schema": { diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index e58d9a037d4..b701bdcaa7f 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,5 +1,11 @@ import { asyncJobs, db } from '@sim/db' -import { tableJobs, workflowDeploymentOperation, workflowExecutionLogs } from '@sim/db/schema' +import { + tableImports, + tableJobs, + uploadSessions, + workflowDeploymentOperation, + workflowExecutionLogs, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, exists, gt, inArray, lt, sql } from 'drizzle-orm' @@ -10,6 +16,7 @@ import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteFile } from '@/lib/uploads/core/storage-service' +import { expireUploadSessions } from '@/lib/uploads/multipart-session/service' const logger = createLogger('CleanupStaleExecutions') @@ -128,6 +135,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // place (no rollback); the user retries. Also prune long-settled terminal jobs so the table // doesn't grow unbounded (the latest job per table is what list/detail reads surface). let staleTableJobsMarkedFailed = 0 + let stalePreparingImportsMarkedFailed = 0 + let expiredUploadSessions = 0 try { const now = new Date() const staleJobs = await db @@ -142,10 +151,80 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .returning({ id: tableJobs.id }) staleTableJobsMarkedFailed = staleJobs.length + if (staleJobs.length > 0) { + const now = new Date() + await db + .update(tableImports) + .set({ + status: 'failed', + error: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes`, + completedAt: now, + updatedAt: now, + }) + .where( + inArray( + tableImports.id, + staleJobs.map((job) => job.id) + ) + ) + } if (staleTableJobsMarkedFailed > 0) { logger.info(`Marked ${staleTableJobsMarkedFailed} stale table jobs as failed`) } + const stalePreparingImports = await db + .select({ id: tableImports.id }) + .from(tableImports) + .where( + and(eq(tableImports.status, 'preparing'), lt(tableImports.updatedAt, staleThreshold)) + ) + .orderBy(tableImports.updatedAt) + .limit(100) + if (stalePreparingImports.length > 0) { + const failedImports = await db + .update(tableImports) + .set({ + status: 'failed', + error: `Import terminated: preparation did not finish within ${STALE_THRESHOLD_MINUTES} minutes`, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableImports.status, 'preparing'), + inArray( + tableImports.id, + stalePreparingImports.map((record) => record.id) + ) + ) + ) + .returning({ uploadSessionId: tableImports.uploadSessionId }) + stalePreparingImportsMarkedFailed = failedImports.length + + const uploadSessionIds = failedImports.flatMap((record) => + record.uploadSessionId ? [record.uploadSessionId] : [] + ) + if (uploadSessionIds.length > 0) { + const uploads = await db + .select({ storageKey: uploadSessions.storageKey }) + .from(uploadSessions) + .where(inArray(uploadSessions.id, uploadSessionIds)) + for (const upload of uploads) { + await deleteFile({ key: upload.storageKey, context: 'table-import' }).catch((error) => { + logger.warn('Failed to delete source for a stale table import', { + storageKey: upload.storageKey, + error: toError(error).message, + }) + }) + } + } + } + if (stalePreparingImportsMarkedFailed > 0) { + logger.info( + `Marked ${stalePreparingImportsMarkedFailed} stale preparing table imports as failed` + ) + } + const terminalRetention = new Date(Date.now() - TABLE_JOB_RETENTION_HOURS * 60 * 60 * 1000) const pruned = await db .delete(tableJobs) @@ -176,6 +255,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + try { + expiredUploadSessions = await expireUploadSessions(new Date(), 100) + } catch (error) { + logger.error('Failed to expire multipart upload sessions:', { + error: toError(error).message, + }) + } + // Clean up stale pending jobs (never started, e.g., due to server crash before startJob()) let stalePendingJobsMarkedFailed = 0 @@ -306,6 +393,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { tableJobs: { staleMarkedFailed: staleTableJobsMarkedFailed, }, + tableImports: { + stalePreparingMarkedFailed: stalePreparingImportsMarkedFailed, + }, + uploadSessions: { + expired: expiredUploadSessions, + }, deploymentOperations: { pruned: deploymentOperationsPruned, retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS, 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..5486dc3adbb --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,63 @@ +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 = await getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + }) + 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 ?? completed.session.completedFileId + 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..158da882b22 --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,44 @@ +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 = await getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + }) + 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..d98fc68e27c --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + abortWorkspaceFileUploadContract, + getWorkspaceFileUploadContract, +} from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +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 GET = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(getWorkspaceFileUploadContract, 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 = await getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + }) + const file = upload.completedFileId + ? await getWorkspaceFile(workspaceId, upload.completedFileId, { throwOnError: true }) + : null + return NextResponse.json({ data: toV2FileUpload(upload, file) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) + +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 = await getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + }) + 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]/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/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/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..66a1440874a --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -0,0 +1,64 @@ +import { getErrorMessage } from '@sim/utils/errors' +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 { markTrackedImportTerminal } from '@/lib/table/import-resource-store' +import { + getOwnedTableImport, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { + completeUploadSession, + getOwnedUploadSession, +} 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 record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + }) + if (!record.uploadSessionId) { + return NextResponse.json({ error: 'Import has no upload source' }, { status: 409 }) + } + const upload = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId: record.workspaceId, + userId: auth.userId, + }) + await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async () => ({ value: null }), + onFailure: async (_session, error) => { + await markTrackedImportTerminal({ + importId: record.id, + status: 'failed', + error: getErrorMessage(error, 'Upload finalization failed'), + }) + }, + }) + return NextResponse.json({ + data: await toV2TableImport(await startUploadedTableImport(record.id)), + }) + } 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..4b24b5c7347 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -0,0 +1,49 @@ +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 { getOwnedTableImport } from '@/lib/table/orchestration/import-resource' +import { + createUploadPartUrls, + getOwnedUploadSession, +} 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 record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + }) + if (!record.uploadSessionId) { + return NextResponse.json({ error: 'Import has no upload source' }, { status: 409 }) + } + const upload = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId: record.workspaceId, + userId: auth.userId, + }) + 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..b3769d751a5 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -0,0 +1,65 @@ +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 { + 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 record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + return NextResponse.json({ + data: await toV2TableImport(await cancelTableImportResource(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/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..73960956c94 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,90 @@ +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 = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + 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 ?? result.session.completedFileId + 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..d6964406766 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,58 @@ +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 = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + 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..6bdbff94eea --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2AbortFileUploadContract, v2GetFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +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 GET = 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(v2GetFileUploadContract, 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 = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + const file = session.completedFileId + ? await getWorkspaceFile(workspaceId, session.completedFileId, { throwOnError: true }) + : null + return v2Data(toV2FileUpload(session, file), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +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 = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + 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..982a8d98147 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -0,0 +1,146 @@ +/** + * @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', + 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 durable 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, + 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..e97467c5c1f --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -0,0 +1,37 @@ +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, + 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/tables/[tableId]/export-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts deleted file mode 100644 index fafe14ee422..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 background export. Export jobs are read-only, so `read` access is - * enough and the job bypasses the one-write-job-per-table gate. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockMarkTableJobRunning, - mockRunDetached, - mockRecordAudit, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockMarkTableJobRunning: vi.fn(), - mockRunDetached: vi.fn(), - mockRecordAudit: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { TABLE_EXPORTED: 'table.exported' }, - AuditResourceType: { TABLE: 'table' }, - recordAudit: mockRecordAudit, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, -})) - -vi.mock('@/lib/table/jobs/service', () => ({ - markTableJobRunning: mockMarkTableJobRunning, - releaseJobClaim: vi.fn(), -})) -vi.mock('@/lib/table/export-runner', () => ({ runTableExport: vi.fn() })) -vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) -vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { POST } from '@/app/api/v2/tables/[tableId]/export-async/route' - -const TABLE = { - id: 'table-1', - name: 'customers', - workspaceId: 'ws-1', - rowCount: 3, - schema: { columns: [] }, -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/export-async', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockMarkTableJobRunning.mockResolvedValue(true) - mockGateError.mockResolvedValue(null) -}) - -describe('POST /api/v2/tables/[tableId]/export-async', () => { - it('queues the export and returns its job id', async () => { - const res = await callPost({ workspaceId: 'ws-1', format: 'csv' }) - - expect(res.status).toBe(200) - const { data } = await res.json() - expect(data.tableId).toBe('table-1') - expect(data.jobId).toEqual(expect.any(String)) - // Typed `export` so the partial-unique index lets it run alongside a write job. - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', data.jobId, 'export', { - format: 'csv', - }) - expect(mockRunDetached).toHaveBeenCalledWith('table-export', expect.any(Function)) - }) - - it('defaults the format to csv', async () => { - await callPost({ workspaceId: 'ws-1' }) - - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', expect.any(String), 'export', { - format: 'csv', - }) - }) - - it('audits at authorization so an abandoned job still records the request', async () => { - await callPost({ workspaceId: 'ws-1' }) - - expect(mockRecordAudit).toHaveBeenCalledWith( - expect.objectContaining({ - resourceId: 'table-1', - metadata: expect.objectContaining({ async: true }), - }) - ) - }) - - it('409s when the claim is lost', async () => { - mockMarkTableJobRunning.mockResolvedValue(false) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(409) - expect(mockRunDetached).not.toHaveBeenCalled() - }) - - it('400s an unsupported format', async () => { - const res = await callPost({ workspaceId: 'ws-1', format: 'xml' }) - - expect(res.status).toBe(400) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(429) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts deleted file mode 100644 index 39128148ce5..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/export-async/route.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' -import { v2ExportTableAsyncContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' -import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import type { TableExportJobPayload } from '@/lib/table/types' -import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2TableExportAsyncAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/v2/tables/[tableId]/export-async — Start a background export. - * - * Export jobs are read-only, so they bypass the one-write-job-per-table gate - * (the partial-unique index excludes them) and can run alongside an import or - * delete. Poll `GET /api/v2/tables/jobs`, then fetch the file from - * `GET /export/download` once the job reports `ready`. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ExportTableAsyncContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, format } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const jobId = generateId() - const jobPayload: TableExportJobPayload = { format } - if (!(await markTableJobRunning(tableId, jobId, 'export', jobPayload))) { - return v2Error('CONFLICT', 'Failed to start export') - } - - const payload: TableExportPayload = { jobId, tableId, workspaceId, format } - if (isTriggerDevEnabled) { - try { - const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-export'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger('table-export', payload, { - tags: [`tableId:${tableId}`, `jobId:${jobId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - // A failed dispatch must not leave a ghost `running` job behind. - await releaseJobClaim(tableId, jobId).catch(() => {}) - throw error - } - } else { - runDetached('table-export', () => runTableExport(payload)) - } - - // Audit at authorization (like the streaming route) so an abandoned job - // still records that the data was requested. - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: access.table.name, - description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: access.table.rowCount, async: true }, - request, - }) - captureServerEvent( - userId, - 'table_exported', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) - - return v2Data({ tableId, jobId }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error starting async export`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts deleted file mode 100644 index 11183817ff3..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 export download. The three failure modes are deliberately - * distinct — a caller polling to completion has to tell "not yet" (409) from - * "never again" (410) from "wrong id" (404). - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockGetTableJob, - mockPresignedUrl, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGetTableJob: vi.fn(), - mockPresignedUrl: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, -})) - -vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - generatePresignedDownloadUrl: mockPresignedUrl, -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { GET } from '@/app/api/v2/tables/[tableId]/export/download/route' - -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } -const READY_JOB = { - type: 'export', - status: 'ready', - payload: { format: 'csv', resultKey: 'workspace/ws-1/exports/customers.csv' }, -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callGet() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/export/download?workspaceId=ws-1&jobId=job-1', - { method: 'GET' } - ) - return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGetTableJob.mockResolvedValue(READY_JOB) - mockPresignedUrl.mockResolvedValue('https://storage.example/signed') - mockGateError.mockResolvedValue(null) -}) - -describe('GET /api/v2/tables/[tableId]/export/download', () => { - it('issues a presigned URL for a ready job', async () => { - const res = await callGet() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - url: 'https://storage.example/signed', - fileName: 'customers.csv', - }) - expect(mockGetTableJob).toHaveBeenCalledWith('table-1', 'job-1') - expect(mockPresignedUrl).toHaveBeenCalledWith( - 'workspace/ws-1/exports/customers.csv', - 'workspace' - ) - }) - - it('404s a job id that is not an export of this table', async () => { - mockGetTableJob.mockResolvedValue({ type: 'import', status: 'ready' }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockPresignedUrl).not.toHaveBeenCalled() - }) - - it('409s a job that is still running — retry later, not a dead end', async () => { - mockGetTableJob.mockResolvedValue({ ...READY_JOB, status: 'running' }) - - const res = await callGet() - - expect(res.status).toBe(409) - expect((await res.json()).error.message).toBe('Export is not ready') - }) - - it('410s once the generated file has aged out of storage', async () => { - mockGetTableJob.mockResolvedValue({ ...READY_JOB, payload: { format: 'csv' } }) - - const res = await callGet() - - expect(res.status).toBe(410) - expect(mockPresignedUrl).not.toHaveBeenCalled() - }) - - it('400s a request with no jobId', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/export/download?workspaceId=ws-1', - { method: 'GET' } - ) - const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) - - expect(res.status).toBe(400) - expect(mockGetTableJob).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockGetTableJob).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockGetTableJob).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts b/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts deleted file mode 100644 index d31fafd4d1c..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/export/download/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2ExportDownloadContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getTableJob } from '@/lib/table/jobs/service' -import type { TableExportJobPayload } from '@/lib/table/types' -import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' -import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2TableExportDownloadAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * GET /api/v2/tables/[tableId]/export/download — Presigned URL for a finished - * export. - * - * The three failure modes are deliberately distinct: a job that isn't an export - * of this table is 404, one still running is 409 (retry later), and one whose - * generated file has aged out of storage is 410 (start a new export) — a caller - * polling to completion needs to tell "not yet" from "never again". - */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ExportDownloadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, jobId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const job = await getTableJob(tableId, jobId) - if (!job || job.type !== 'export') return v2Error('NOT_FOUND', 'Export job not found') - if (job.status !== 'ready') return v2Error('CONFLICT', 'Export is not ready') - - const payload = job.payload as TableExportJobPayload | null - if (!payload?.resultKey) { - return v2Error('NOT_FOUND', 'Export file is no longer available', { status: 410 }) - } - - const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') - const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` - - logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) - - return v2Data({ url, fileName }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error issuing export download URL`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/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]/import-async/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts deleted file mode 100644 index 91672b9802e..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 background import. Two orderings are load-bearing: the - * client-supplied `fileKey` is checked against the workspace's own storage - * prefix, and the table's locks are asserted BEFORE the single write-job slot - * is claimed so a locked table never holds the slot. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockMarkTableJobRunning, - mockReleaseJobClaim, - mockRunDetached, - mockAssertRowInsert, - mockAssertRowDelete, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockMarkTableJobRunning: vi.fn(), - mockReleaseJobClaim: vi.fn(), - mockRunDetached: vi.fn(), - mockAssertRowInsert: vi.fn(), - mockAssertRowDelete: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, -})) - -vi.mock('@/lib/table/jobs/service', () => ({ - markTableJobRunning: mockMarkTableJobRunning, - releaseJobClaim: mockReleaseJobClaim, -})) -// Only the assert helpers are stubbed — `TableLockedError` stays real so the -// route's `v2TableLockError` recognizes it by `instanceof` and reports the lock -// kind, exactly as it would in production. -vi.mock('@/lib/table/mutation-locks', async (importOriginal) => ({ - ...(await importOriginal>()), - assertRowInsert: mockAssertRowInsert, - assertRowDelete: mockAssertRowDelete, - assertSchemaMutable: vi.fn(), -})) -vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) -vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) -vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) -vi.mock('@/lib/users/queries', () => ({ - getUserSettings: vi.fn().mockResolvedValue({ timezone: 'UTC' }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { TableLockedError } from '@/lib/table/mutation-locks' -import { POST } from '@/app/api/v2/tables/[tableId]/import-async/route' - -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] }, archivedAt: null } - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -const BODY = { - workspaceId: 'ws-1', - fileKey: 'workspace/ws-1/imports/contacts.csv', - fileName: 'contacts.csv', - mode: 'append', -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/import-async', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockMarkTableJobRunning.mockResolvedValue(true) - // `clearAllMocks` drops recorded calls but keeps implementations, so the - // throwing lock assertion below would leak into every later test. - mockAssertRowInsert.mockImplementation(() => {}) - mockAssertRowDelete.mockImplementation(() => {}) - mockGateError.mockResolvedValue(null) -}) - -describe('POST /api/v2/tables/[tableId]/import-async', () => { - it('claims the job slot and dispatches the import', async () => { - const res = await callPost(BODY) - - expect(res.status).toBe(200) - const { data } = await res.json() - expect(data.tableId).toBe('table-1') - expect(data.importId).toEqual(expect.any(String)) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', data.importId, 'import') - expect(mockRunDetached).toHaveBeenCalledWith('table-import', expect.any(Function)) - }) - - it('rejects a fileKey outside the workspace prefix', async () => { - const res = await callPost({ ...BODY, fileKey: 'workspace/ws-other/imports/contacts.csv' }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe('Invalid file key for workspace') - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('asserts the insert lock BEFORE claiming the slot, and names the lock in the 423', async () => { - mockAssertRowInsert.mockImplementation(() => { - throw new TableLockedError('insert') - }) - - const res = await callPost(BODY) - - expect(res.status).toBe(423) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - // A table has four independent locks, so "LOCKED" alone doesn't tell the - // caller which one to clear. - const body = await res.json() - expect(body.error.code).toBe('LOCKED') - expect(body.error.details).toEqual({ lock: 'insert' }) - }) - - it('asserts the delete lock too when the mode replaces rows', async () => { - await callPost({ ...BODY, mode: 'replace' }) - - expect(mockAssertRowDelete).toHaveBeenCalledWith(TABLE) - }) - - it('409s when another job already holds the slot', async () => { - mockMarkTableJobRunning.mockResolvedValue(false) - - const res = await callPost(BODY) - - expect(res.status).toBe(409) - expect(mockRunDetached).not.toHaveBeenCalled() - }) - - it('400s an unsupported file extension', async () => { - const res = await callPost({ ...BODY, fileName: 'contacts.xlsx' }) - - expect(res.status).toBe(400) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('400s a body missing fileKey', async () => { - const res = await callPost({ workspaceId: 'ws-1', fileName: 'c.csv', mode: 'append' }) - - expect(res.status).toBe(400) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost(BODY) - - expect(res.status).toBe(403) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost(BODY) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost(BODY) - - expect(res.status).toBe(429) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts b/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts deleted file mode 100644 index 474b59a4ef0..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/import-async/route.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' -import { v2ImportTableAsyncContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' -import { getUserSettings } from '@/lib/users/queries' -import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableImportAsyncAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/v2/tables/[tableId]/import-async — Start a background import. - * - * The file must already be in the workspace's storage; `fileKey` is - * client-supplied, so it is checked against the workspace's own prefix — a - * caller must not be able to import another workspace's uploaded object. - * Progress is observable through `GET /api/v2/tables/jobs` and the job can be - * stopped with `POST /job/cancel`. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ImportTableAsyncContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = - parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - const { table } = access - if (table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { - return v2Error('BAD_REQUEST', 'Invalid file key for workspace') - } - if (table.archivedAt) { - return v2Error('BAD_REQUEST', 'Cannot import into an archived table') - } - - const extension = fileName.split('.').pop()?.toLowerCase() - if (extension !== 'csv' && extension !== 'tsv') { - return v2Error('BAD_REQUEST', 'Only CSV and TSV files are supported') - } - - // Gate the locks BEFORE claiming the single write-job slot, so a locked - // table reports 423 here instead of holding the slot and failing inside the - // worker. - assertRowInsert(table) - if (mode === 'replace') assertRowDelete(table) - if (createColumns && createColumns.length > 0) assertSchemaMutable(table) - - const importId = generateId() - if (!(await markTableJobRunning(tableId, importId, 'import'))) { - return v2Error('CONFLICT', 'A job is already in progress for this table') - } - - const payload: TableImportPayload = { - importId, - tableId, - workspaceId, - userId, - fileKey, - fileName, - delimiter: extension === 'tsv' ? '\t' : ',', - mode, - mapping, - createColumns, - timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', - } - - if (isTriggerDevEnabled) { - // Runs outside the web container, so the import survives app deploys. - try { - const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-import'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger('table-import', payload, { - tags: [`tableId:${tableId}`, `jobId:${importId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - // A failed dispatch must not leave a ghost `running` job holding the - // table's one write-job slot until the stale-job janitor fires. - await releaseJobClaim(tableId, importId).catch(() => {}) - throw error - } - } else { - runDetached('table-import', () => runTableImport(payload)) - } - - logger.info(`[${requestId}] Async CSV import started`, { tableId, importId, mode, fileName }) - - return v2Data({ tableId, importId }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - - logger.error(`[${requestId}] Error starting async import`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts deleted file mode 100644 index e0db26f5381..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 job cancel — the "stop it" half of the async import/export story. - * Idempotent by design: cancelling a job that already finished reports - * `canceled: false` rather than failing. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockGetTableJob, - mockMarkJobCanceled, - mockAppendTableEvent, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGetTableJob: vi.fn(), - mockMarkJobCanceled: vi.fn(), - mockAppendTableEvent: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, -})) - -vi.mock('@/lib/table/jobs/service', () => ({ - getTableJob: mockGetTableJob, - markJobCanceled: mockMarkJobCanceled, -})) -vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { POST } from '@/app/api/v2/tables/[tableId]/job/cancel/route' - -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/job/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGetTableJob.mockResolvedValue({ type: 'import' }) - mockMarkJobCanceled.mockResolvedValue(true) - mockGateError.mockResolvedValue(null) -}) - -describe('POST /api/v2/tables/[tableId]/job/cancel', () => { - it('cancels the job and emits the event with the job’s real type', async () => { - const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ jobId: 'job-1', canceled: true }) - expect(mockMarkJobCanceled).toHaveBeenCalledWith('table-1', 'job-1') - // The table-level derivation excludes exports, so the type has to come from - // the job's own row or an export cancel would announce itself as an import. - expect(mockAppendTableEvent).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'job', type: 'import', jobId: 'job-1', status: 'canceled' }) - ) - }) - - it('reads the type from an export job rather than defaulting', async () => { - mockGetTableJob.mockResolvedValue({ type: 'export' }) - - await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(mockAppendTableEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'export' })) - }) - - it('reports canceled: false for a job that already finished, and emits nothing', async () => { - mockMarkJobCanceled.mockResolvedValue(false) - - const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ jobId: 'job-1', canceled: false }) - expect(mockAppendTableEvent).not.toHaveBeenCalled() - }) - - it('400s a body with no jobId', async () => { - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(400) - expect(mockMarkJobCanceled).not.toHaveBeenCalled() - }) - - it('404s a table in another workspace without cancelling', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(res.status).toBe(404) - expect(mockMarkJobCanceled).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(res.status).toBe(403) - expect(mockMarkJobCanceled).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1', jobId: 'job-1' }) - - expect(res.status).toBe(429) - expect(mockMarkJobCanceled).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts b/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts deleted file mode 100644 index 42969c1fcb6..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/job/cancel/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2CancelTableJobContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { appendTableEvent } from '@/lib/table/events' -import { getTableJob, markJobCanceled } from '@/lib/table/jobs/service' -import type { TableJobType } from '@/lib/table/types' -import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableAccessError } from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableJobCancelAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/v2/tables/[tableId]/job/cancel — Stop an in-flight import or delete. - * - * Flips the job's status so the worker's next ownership check fails and it - * stops. Work already committed (rows inserted or deleted) is left in place — - * there is no rollback. Idempotent: cancelling a job that already finished - * reports `canceled: false` rather than failing, so a client racing the - * worker's completion is not an error. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-jobs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CancelTableJobContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, jobId } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // Resolve the job's real type from its own row — the table-level derivation - // excludes exports — so the cancel event carries the right `type`. - const job = await getTableJob(tableId, jobId) - const type = (job?.type ?? 'import') as TableJobType - - const canceled = await markJobCanceled(tableId, jobId) - if (canceled) { - void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) - } - - logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) - - return v2Data({ jobId, canceled }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error cancelling table job`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 66e0c92bac4..447bd76e4c2 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -149,7 +149,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl return v2Error('NOT_FOUND', 'Table not found') } - // ── Validate every field BEFORE the first write ── // 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 @@ -162,7 +161,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl } } - // ── Apply ── // Every deterministic rejection is already behind us, so a failure here is // a genuine fault (lost race, archived mid-request, database error) rather // than a bad request. The two operations commit independently — a single 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.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..55a824be354 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -0,0 +1,84 @@ +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 { markTrackedImportTerminal } from '@/lib/table/import-resource-store' +import { + getOwnedTableImport, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { + completeUploadSession, + getOwnedUploadSession, +} 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 record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId, + userId, + }) + if (!record.uploadSessionId) return v2Error('CONFLICT', 'Import has no upload source') + const upload = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId, + userId, + }) + await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async () => ({ value: null }), + onFailure: async (_session, error) => { + await markTrackedImportTerminal({ + importId: record.id, + status: 'failed', + error: getErrorMessage(error, 'Upload finalization failed'), + }) + }, + }) + const started = await startUploadedTableImport(record.id) + 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..738a3ad17d1 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -0,0 +1,68 @@ +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 { getOwnedTableImport } from '@/lib/table/orchestration/import-resource' +import { + createUploadPartUrls, + getOwnedUploadSession, +} 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 record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId, + userId, + }) + if (!record.uploadSessionId) return v2Error('CONFLICT', 'Import has no upload source') + const session = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId, + userId, + }) + 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..dbda6743c51 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -0,0 +1,88 @@ +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 { + 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 record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + return v2Data(await toV2TableImport(await cancelTableImportResource(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/jobs/route.test.ts b/apps/sim/app/api/v2/tables/jobs/route.test.ts deleted file mode 100644 index 749c29fd70f..00000000000 --- a/apps/sim/app/api/v2/tables/jobs/route.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 export-job listing — the observability half of the async - * import/export story. Workspace-scoped, so the permission check is the - * workspace one rather than a table's. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListJobs, mockGateError } = vi.hoisted( - () => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListJobs: vi.fn(), - mockGateError: vi.fn(), - }) -) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/table/jobs/service', () => ({ listWorkspaceExportJobs: mockListJobs })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { GET } from '@/app/api/v2/tables/jobs/route' - -const JOB = { - jobId: 'job-1', - tableId: 'table-1', - tableName: 'customers', - status: 'ready', - rowsProcessed: 12, - format: 'csv', - hasResult: true, - error: null, -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callGet(query = 'workspaceId=ws-1&type=export') { - return GET( - new NextRequest(`http://localhost:3000/api/v2/tables/jobs?${query}`, { method: 'GET' }) - ) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListJobs.mockResolvedValue([JOB]) - mockGateError.mockResolvedValue(null) -}) - -describe('GET /api/v2/tables/jobs', () => { - it('returns the workspace export jobs as one full page', async () => { - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [JOB], nextCursor: null }) - expect(mockListJobs).toHaveBeenCalledWith('ws-1') - }) - - it('400s a request with no type, so widening the parameter can never surprise a caller', async () => { - const res = await callGet('workspaceId=ws-1') - - expect(res.status).toBe(400) - expect(mockListJobs).not.toHaveBeenCalled() - }) - - it('400s an unsupported job type', async () => { - const res = await callGet('workspaceId=ws-1&type=import') - - expect(res.status).toBe(400) - expect(mockListJobs).not.toHaveBeenCalled() - }) - - it('403s a caller without workspace access', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const res = await callGet() - - expect(res.status).toBe(403) - expect(mockListJobs).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListJobs).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockListJobs).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/jobs/route.ts b/apps/sim/app/api/v2/tables/jobs/route.ts deleted file mode 100644 index 77d1067920f..00000000000 --- a/apps/sim/app/api/v2/tables/jobs/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2ListTableJobsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2TableJobsAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -/** - * GET /api/v2/tables/jobs — Export jobs across a workspace. - * - * Export-only today, and `type` is a required literal rather than a default so - * the parameter can widen to other job kinds later without silently changing - * what an existing caller receives. Running jobs plus recently finished ones, - * so a completed export stays re-downloadable. Workspace-scoped, so the - * permission check is the workspace one. - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-jobs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListTableJobsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.query - - const accessError = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (accessError) return v2WorkspaceAccessError(accessError) - - const jobs = await listWorkspaceExportJobs(workspaceId) - - return v2CursorList(jobs, null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing table jobs`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts new file mode 100644 index 00000000000..e56e708ada3 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -0,0 +1,58 @@ +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 { verifyUploadToken } from '@/lib/uploads/core/upload-token' +import { writeLocalMultipartPart } from '@/lib/uploads/multipart-session/provider' +import { + expectedUploadPartSize, + getOwnedUploadSession, +} 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 verification = verifyUploadToken(request.nextUrl.searchParams.get('token') ?? '') + if (!verification.valid || verification.payload.uploadId !== uploadId) { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + const parsed = await parseRequest(localUploadPartContract, request, context) + if (!parsed.success) return parsed.response + + const session = await getOwnedUploadSession({ + uploadId, + workspaceId: verification.payload.workspaceId, + userId: verification.payload.userId, + }) + if (session.storageProvider !== 'local' || session.storageKey !== verification.payload.key) { + 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..1435dc41699 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,109 @@ export function useRestoreTable() { }) } -interface UploadCsvParams { +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 } -/** - * 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, +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 +}) { + 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 }, + body: { partNumbers }, }) - - 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() + return response.data.parts }, - onError: (error) => { - logger.error('Failed to upload CSV:', error) - toast.error(error.message, { duration: 5000 }) + complete: async (parts) => { + const response = await requestJson(completeTableImportResourceContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + body: { parts }, + }) + return response.data }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + abort: async () => { + await requestJson(cancelTableImportResourceContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + }) }, }) } -interface ImportCsvAsyncParams { - workspaceId: string - /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null - file: File - 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, - 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, - }) - 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 its durable import resource 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 +1839,9 @@ export function useImportCsvAsync() { interface ImportFileAsTableParams { workspaceId: string - fileKey: string + fileId: string fileName: string + onCreated?: (importId: string) => void } /** @@ -1846,11 +1855,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 +1890,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 +1906,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 +1938,11 @@ 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 }, }) } @@ -2046,23 +1986,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 +2007,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 +2030,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..af1057b323c --- /dev/null +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -0,0 +1,90 @@ +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, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, +} 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, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + 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/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts new file mode 100644 index 00000000000..573a9eeb951 --- /dev/null +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -0,0 +1,72 @@ +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, +} 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 getWorkspaceFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const abortWorkspaceFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const createWorkspaceFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeWorkspaceFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/complete', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + 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..9aad6876e5a 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -8,6 +8,13 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, +} 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 +32,9 @@ 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 are durable multipart sessions. The control plane owns cleanup and + * completion atomically registers the workspace file, so an abandoned direct + * upload cannot become an untracked permanent object. */ /** A workspace file as exposed by the v2 surface. */ @@ -52,6 +57,37 @@ 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(), + 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 +161,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 +320,45 @@ 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 v2GetFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2AbortFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2CreateFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + 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, + 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 cd10df711a2..5be7bddcd7b 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -2,19 +2,14 @@ import { z } from 'zod' import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { addWorkflowGroupBodySchema, - cancelTableJobBodySchema, cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, createTableViewBodySchema, csvImportCreateColumnsSchema, csvImportMappingSchema, - csvImportModeSchema, deleteTableColumnBodySchema, deleteWorkflowGroupBodySchema, - exportDownloadQuerySchema, exportTableAsyncBodySchema, - importIntoTableAsyncBodySchema, - listTableJobsQuerySchema, predicateSchema, refineCancelTableRunsScope, runColumnBodyBaseSchema, @@ -23,7 +18,6 @@ import { sortSpecSchema, tableColumnSchema, tableIdParamsSchema, - tableJobSummarySchema, tableLocksSchema, tableNameSchema, tableRowParamsSchema, @@ -51,7 +45,13 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, +} 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. @@ -77,7 +77,7 @@ 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 /** @@ -87,10 +87,8 @@ export const V2_MAX_ROW_LIMIT = 1000 /** * The table's current background job, or `null` when idle. * - * This is how an async import or delete is observed. Those jobs are derived - * onto the table itself (one write job per table at a time), so the table is - * their status endpoint — unlike exports, which are read-only, run concurrently, - * and therefore have the dedicated `GET /api/v2/tables/jobs` list instead. + * 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(), @@ -385,7 +383,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(), @@ -918,138 +916,202 @@ export const v2FindTableRowsContract = defineRouteContract({ }, }) -/** - * Multipart form fields for `POST /api/v2/tables/[tableId]/import`. - * - * Not declared as the contract's `body`: the request is `multipart/form-data`, - * so the route reads the parts with the streaming multipart reader and parses - * the collected text fields through this schema in one pass. Every value - * arrives as a string — `mapping` and `createColumns` are JSON-encoded and - * decoded by their shared field schemas. - */ -export const v2ImportIntoTableFormSchema = z.object({ - workspaceId: workspaceIdSchema, - mode: csvImportModeSchema.default('append'), - mapping: csvImportMappingSchema.optional(), - createColumns: csvImportCreateColumnsSchema.optional(), - timezone: ianaTimezoneSchema.optional(), +export 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({ + partSize: z.number().int().positive(), + partCount: z.number().int().positive(), + expiresAt: z.string().datetime(), }) -export type V2ImportIntoTableForm = z.input -/** Kickoff acknowledgement for a background import. */ -export const v2ImportAsyncDataSchema = z.object({ - tableId: z.string(), - importId: z.string(), +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 V2ImportAsyncData = z.output +export type V2TableImport = z.output -/** - * Starts a background import of a file already uploaded to workspace storage - * (`POST /api/v2/files` returns the `key`). - * - * The upload step is still a synchronous multipart request capped at 100 MB, so - * the byte limit moved rather than vanished — but it now fails loudly on an - * explicit size check instead of relying on a proxy cap that truncates, and the - * table write itself is a job that can be watched and cancelled. - * - * Returns immediately. A table carries at most one write job, so progress is - * read off the table itself (`GET /api/v2/tables/[tableId]` → `job`) rather than - * the export-only jobs list; stop it with `POST /api/v2/tables/[tableId]/job/cancel`. - */ -export const v2ImportTableAsyncContract = defineRouteContract({ +export const v2CreateTableImportContract = defineRouteContract({ method: 'POST', - path: '/api/v2/tables/[tableId]/import-async', - params: tableIdParamsSchema, - body: importIntoTableAsyncBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2ImportAsyncDataSchema), - }, + path: '/api/v2/tables/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) -/** Kickoff acknowledgement for a background export. */ -export const v2ExportAsyncDataSchema = z.object({ - tableId: z.string(), - jobId: z.string(), +export const v2GetTableImportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) -export type V2ExportAsyncData = z.output -/** - * Starts a background export. Export jobs are read-only, so they bypass the - * one-write-job-per-table gate and can run alongside an import or delete. - */ -export const v2ExportTableAsyncContract = defineRouteContract({ +export const v2CancelTableImportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CreateTableImportPartUrlsContract = defineRouteContract({ method: 'POST', - path: '/api/v2/tables/[tableId]/export-async', - params: tableIdParamsSchema, - body: exportTableAsyncBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2ExportAsyncDataSchema), - }, + path: '/api/v2/tables/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, }) -/** A short-lived presigned URL for a finished export. */ -export const v2ExportDownloadDataSchema = z.object({ - url: z.string(), - fileName: z.string(), +export const v2CompleteTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) -export type V2ExportDownloadData = z.output -/** - * Resolves a `ready` export job to a presigned download URL. Returns 409 while - * the job is still running and 410 once the generated file has aged out. - */ -export const v2ExportDownloadContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/tables/[tableId]/export/download', +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, - query: exportDownloadQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2ExportDownloadDataSchema), - }, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, }) -/** - * Workspace-scoped export-job listing: running jobs plus recently finished ones - * (kept so a completed export stays re-downloadable). Bounded server-side, so a - * single full page — `nextCursor` is always `null`. - */ -export const v2ListTableJobsContract = defineRouteContract({ +export const v2GetTableExportContract = defineRouteContract({ method: 'GET', - path: '/api/v2/tables/jobs', - query: listTableJobsQuerySchema, - response: { - mode: 'json', - schema: v2CursorListResponse(tableJobSummarySchema), - }, + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, }) -/** - * Cancel outcome. `canceled` is `false` when the job had already finished — - * cancelling is idempotent and a late request is not an error. - */ -export const v2CancelTableJobDataSchema = z.object({ - jobId: z.string(), - canceled: z.boolean(), +export const v2CancelTableExportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, }) -export type V2CancelTableJobData = z.output -/** - * Stops an in-flight import or delete. The worker halts at its next ownership - * check; work already committed (rows inserted or deleted) stays — there is no - * rollback. - */ -export const v2CancelTableJobContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/[tableId]/job/cancel', - params: tableIdParamsSchema, - body: cancelTableJobBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2CancelTableJobDataSchema), - }, +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) }, }) /** @@ -1071,8 +1133,8 @@ export type V2CancelTableRunsData = z.output /** * Stops in-flight and pending workflow/enrichment cell runs — the counterpart - * to `POST /columns/run`. Distinct from `POST /job/cancel`, which stops an - * import or delete job. + * to `POST /columns/run`. Import and export work is canceled by deleting its + * resource instead. */ export const v2CancelTableRunsContract = defineRouteContract({ method: 'POST', 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..97b6d935446 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +export const v2UploadStatusSchema = z.enum([ + 'uploading', + 'finalizing', + 'completed', + 'failed', + 'aborted', + 'expired', +]) +export type V2UploadStatus = z.output + +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 index 66fbd497090..abf2d027820 100644 --- a/apps/sim/lib/table/export-stream.ts +++ b/apps/sim/lib/table/export-stream.ts @@ -10,17 +10,6 @@ const logger = createLogger('TableExportStream') const EXPORT_BATCH_SIZE = 1000 -/** - * Synchronous table export as a byte stream, shared by the first-party and - * public surfaces so both emit byte-identical files. - * - * Rows are paged out as they are read rather than buffered, so a table larger - * than memory still exports — at the cost of a mid-stream failure being - * unrecoverable (the response has already started). Large tables should use the - * background export instead. - */ - -/** Filename-safe stem for the downloaded file. */ export function sanitizeExportFilename(name: string): string { const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') return cleaned || 'table' @@ -34,11 +23,19 @@ function toCsvRow(values: string[]): string { return values.map(escapeCsvField).join(',') } -/** `Content-Type` for an export in `format`. */ export function exportContentType(format: TableExportFormat): string { return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' } +/** + * 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, diff --git a/apps/sim/lib/table/import-resource-store.ts b/apps/sim/lib/table/import-resource-store.ts new file mode 100644 index 00000000000..58cd6de00b3 --- /dev/null +++ b/apps/sim/lib/table/import-resource-store.ts @@ -0,0 +1,57 @@ +import { db } from '@sim/db' +import { tableImports } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' + +export type TableImportRecord = typeof tableImports.$inferSelect + +export async function getTableImport(importId: string): Promise { + const [record] = await db + .select() + .from(tableImports) + .where(eq(tableImports.id, importId)) + .limit(1) + return record ?? null +} + +export async function updateTrackedImportProgress( + importId: string, + rowsProcessed: number +): Promise { + await db + .update(tableImports) + .set({ status: 'processing', rowsProcessed, updatedAt: new Date() }) + .where(and(eq(tableImports.id, importId), eq(tableImports.status, 'processing'))) +} + +export async function markTrackedImportProcessing(importId: string): Promise { + const [claimed] = await db + .update(tableImports) + .set({ status: 'processing', updatedAt: new Date() }) + .where(and(eq(tableImports.id, importId), eq(tableImports.status, 'queued'))) + .returning({ id: tableImports.id }) + if (!claimed) throw new Error(`Table import ${importId} is no longer queued`) +} + +export async function markTrackedImportTerminal(params: { + importId: string + status: 'completed' | 'failed' | 'canceled' + rowsProcessed?: number + error?: string | null +}): Promise { + const now = new Date() + await db + .update(tableImports) + .set({ + status: params.status, + ...(params.rowsProcessed === undefined ? {} : { rowsProcessed: params.rowsProcessed }), + error: params.error ?? null, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableImports.id, params.importId), + inArray(tableImports.status, ['uploading', 'preparing', 'queued', 'processing']) + ) + ) +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 93ae112d415..f879132d333 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -27,6 +27,11 @@ import { deleteAllTableRows, setTableSchemaForImport, } from '@/lib/table/import-data' +import { + markTrackedImportProcessing, + markTrackedImportTerminal, + updateTrackedImportProgress, +} from '@/lib/table/import-resource-store' import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' @@ -77,6 +82,10 @@ 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' + /** Persist progress to the public table-import resource in addition to the table job. */ + trackImportResource?: boolean } /** @@ -89,12 +98,15 @@ 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 (payload.trackImportResource) await markTrackedImportProcessing(importId) + 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 +143,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 +195,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') { @@ -281,6 +296,7 @@ export async function runTableImport(payload: TableImportPayload): Promise }) inserted += result.inserted lastOrderKey = result.lastOrderKey + if (payload.trackImportResource) await updateTrackedImportProgress(importId, inserted) // Emit after the first batch, then every interval, so the bar appears early without flooding. if ( inserted - lastReported >= PROGRESS_INTERVAL_ROWS || @@ -326,6 +342,9 @@ export async function runTableImport(payload: TableImportPayload): Promise // No data rows — fail rather than report a successful empty import (matches the sync route). const message = 'CSV file has no data rows' await markJobFailed(tableId, importId, message) + if (payload.trackImportResource) { + await markTrackedImportTerminal({ importId, status: 'failed', error: message }) + } void appendTableEvent({ kind: 'job', type: 'import', @@ -361,6 +380,13 @@ export async function runTableImport(payload: TableImportPayload): Promise // right at the end makes this a no-op, and we must not emit a false `ready`. const becameReady = await markJobReady(tableId, importId) if (becameReady) { + if (payload.trackImportResource) { + await markTrackedImportTerminal({ + importId, + status: 'completed', + rowsProcessed: inserted, + }) + } void appendTableEvent({ kind: 'job', type: 'import', @@ -404,6 +430,11 @@ export async function runTableImport(payload: TableImportPayload): Promise logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) // Scoped to importId — a no-op if a newer import has taken over. await markJobFailed(tableId, importId, message).catch(() => {}) + if (payload.trackImportResource) { + await markTrackedImportTerminal({ importId, status: 'failed', error: message }).catch( + () => {} + ) + } void appendTableEvent({ kind: 'job', type: 'import', @@ -433,7 +464,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/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..6d8f47b06a4 --- /dev/null +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -0,0 +1,379 @@ +import { db } from '@sim/db' +import { tableImports } 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 V2TableImportStatus, + type V2TableImportTarget, + 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 { + getTableImport, + markTrackedImportTerminal, + type TableImportRecord, +} from '@/lib/table/import-resource-store' +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 { 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' + +interface CreateTableImportResult { + record: TableImportRecord + 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 = { + mapping: body.mapping, + createColumns: body.createColumns, + timezone: body.timezone, + } + + 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, + }) + try { + const [record] = await db + .insert(tableImports) + .values({ + id: importId, + workspaceId: body.workspaceId, + userId, + uploadSessionId: upload.id, + sourceType: 'upload', + targetType: body.target.type, + sourceFileId: null, + tableId: body.target.type === 'existing' ? body.target.tableId : null, + source: body.source, + target: body.target, + options, + status: 'uploading', + }) + .returning() + if (!record) throw new Error('Table import insert returned no row') + return { record, upload } + } catch (error) { + await abortUploadSession(upload).catch(() => {}) + throw error + } + } + + const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) + assertCsvFileName(file.name) + const [record] = await db + .insert(tableImports) + .values({ + id: importId, + workspaceId: body.workspaceId, + userId, + uploadSessionId: null, + sourceFileId: file.id, + sourceType: 'workspace_file', + targetType: body.target.type, + tableId: body.target.type === 'existing' ? body.target.tableId : null, + source: body.source, + target: body.target, + options, + status: 'queued', + }) + .returning() + if (!record) throw new Error('Table import insert returned no row') + return { + record: await startTableImport(record, file.key, file.name, 'workspace', false), + upload: null, + } +} + +export async function startUploadedTableImport(importId: string): Promise { + const record = await getTableImport(importId) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + if (record.status !== 'uploading') return record + if (!record.uploadSessionId) throw new Error(`Table import ${importId} has no upload session`) + const upload = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId: record.workspaceId, + userId: record.userId, + }) + if (upload.status !== 'completed') { + throw new OrchestrationError('conflict', `Table import upload is ${upload.status}`) + } + return startTableImport(record, upload.storageKey, upload.fileName, 'table-import', true) +} + +export async function getOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const [record] = await db + .select() + .from(tableImports) + .where( + and( + eq(tableImports.id, params.importId), + eq(tableImports.workspaceId, params.workspaceId), + eq(tableImports.userId, params.userId) + ) + ) + .limit(1) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +export async function cancelTableImportResource( + record: TableImportRecord +): Promise { + if (record.status === 'canceled') return record + if (record.status === 'completed' || record.status === 'failed' || record.status === 'expired') { + throw new OrchestrationError('conflict', `Table import is ${record.status}`) + } + + if (record.status === 'uploading') { + if (!record.uploadSessionId) throw new Error(`Table import ${record.id} has no upload session`) + const upload = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId: record.workspaceId, + userId: record.userId, + }) + await abortUploadSession(upload) + } else if (record.tableId) { + await markJobCanceled(record.tableId, record.id) + } + await markTrackedImportTerminal({ importId: record.id, status: 'canceled' }) + const updated = await getTableImport(record.id) + if (!updated) throw new Error(`Canceled table import ${record.id} disappeared`) + return updated +} + +export async function toV2TableImport(record: TableImportRecord): Promise { + const source = v2TableImportSourceSchema.parse(record.source) + const target = v2TableImportTargetSchema.parse(record.target) + let upload: V2TableImport['upload'] = null + if (record.uploadSessionId) { + const session = await getOwnedUploadSession({ + uploadId: record.uploadSessionId, + workspaceId: record.workspaceId, + userId: record.userId, + }) + upload = { + partSize: session.partSize, + partCount: session.partCount, + expiresAt: session.expiresAt.toISOString(), + } + } + return { + id: record.id, + workspaceId: record.workspaceId, + status: publicImportStatus(record.status), + source, + target, + tableId: record.tableId, + rowsProcessed: record.rowsProcessed, + error: record.error, + upload, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +async function startTableImport( + record: TableImportRecord, + fileKey: string, + fileName: string, + storageContext: 'workspace' | 'table-import', + deleteSourceFile: boolean +): Promise { + const [claimed] = await db + .update(tableImports) + .set({ status: 'preparing', updatedAt: new Date() }) + .where(and(eq(tableImports.id, record.id), eq(tableImports.status, record.status))) + .returning() + if (!claimed) { + const current = await getTableImport(record.id) + if (!current) throw new Error(`Table import ${record.id} disappeared while starting`) + return current + } + + const target = v2TableImportTargetSchema.parse(claimed.target) + const options = claimed.options as { + mapping?: TableImportPayload['mapping'] + createColumns?: string[] + timezone?: string + } + const requestId = generateRequestId() + let tableId: string | null = null + try { + if (target.type === 'new') { + const limits = await getWorkspaceTableLimits(claimed.workspaceId) + const table = await createTable( + { + name: target.name, + description: `Imported from ${fileName}`, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId: claimed.workspaceId, + folderId: target.folderId ?? null, + userId: claimed.userId, + maxTables: limits.maxTables, + jobStatus: 'running', + jobType: 'import', + jobId: claimed.id, + }, + requestId + ) + tableId = table.id + } else { + const table = await requireExistingTarget(claimed.workspaceId, target) + tableId = table.id + if (!(await markTableJobRunning(tableId, claimed.id, 'import'))) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + } + + const [queued] = await db + .update(tableImports) + .set({ tableId, status: 'queued', updatedAt: new Date() }) + .where(and(eq(tableImports.id, claimed.id), eq(tableImports.status, 'preparing'))) + .returning() + if (!queued) + throw new OrchestrationError('conflict', 'Table import was canceled while starting') + + const payload: TableImportPayload = { + importId: claimed.id, + tableId, + workspaceId: claimed.workspaceId, + userId: claimed.userId, + fileKey, + fileName, + delimiter: fileName.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: target.type === 'new' ? 'create' : target.mode, + mapping: options.mapping, + createColumns: options.createColumns, + deleteSourceFile, + storageContext, + trackImportResource: true, + timezone: options.timezone ?? (await getUserSettings(claimed.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:${claimed.id}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-import', () => runTableImport(payload)) + } + return queued + } catch (error) { + const message = getErrorMessage(error, 'Failed to dispatch table import') + if (tableId) await markJobFailed(tableId, claimed.id, message).catch(() => {}) + await markTrackedImportTerminal({ importId: claimed.id, status: 'failed', error: message }) + if (deleteSourceFile) { + const { deleteFile } = await import('@/lib/uploads/core/storage-service') + await deleteFile({ key: fileKey, context: storageContext }).catch(() => {}) + } + throw error + } +} + +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 publicImportStatus(status: string): V2TableImportStatus { + if (status === 'preparing') return 'queued' + if ( + status !== 'uploading' && + status !== 'queued' && + status !== 'processing' && + status !== 'completed' && + status !== 'failed' && + status !== 'canceled' && + status !== 'expired' + ) { + throw new Error(`Invalid table import status: ${status}`) + } + return status +} diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index a0e53c0a922..40130c51f58 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -348,14 +348,13 @@ 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. */ -/** Serialization a table export produces. */ -export type TableExportFormat = 'csv' | 'json' - export interface TableExportJobPayload { format: TableExportFormat resultKey?: string 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..28f936ba32e --- /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 durable 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..3b57e6bf962 --- /dev/null +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -0,0 +1,65 @@ +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 }, + body: { partNumbers }, + signal, + }) + return batch.data.parts + }, + complete: async (parts) => { + const completed = await requestJson(completeWorkspaceFileUploadContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + 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 }, + }) + }, + }) +} 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/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..5c96bf84c02 --- /dev/null +++ b/apps/sim/lib/uploads/multipart-session/service.ts @@ -0,0 +1,441 @@ +import { db } from '@sim/db' +import { tableImports, uploadSessions } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, lt } from 'drizzle-orm' +import { + checkStorageQuotaForBillingContext, + resolveStorageBillingContext, +} from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' +import { deleteFile, headObject } from '@/lib/uploads/core/storage-service' +import { signUploadToken } 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' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborted' + | 'expired' + +export type UploadSessionRecord = typeof uploadSessions.$inferSelect + +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 context: 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, + localUploadId: id, + }) + + try { + const [created] = await db + .insert(uploadSessions) + .values({ + id, + workspaceId: params.workspaceId, + userId: params.userId, + purpose: params.purpose, + storageContext: context, + 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: params.metadata ?? {}, + expiresAt: new Date(Date.now() + MULTIPART_SESSION_TTL_MS), + }) + .returning() + if (!created) throw new Error('Upload session insert returned no row') + return created + } catch (error) { + await abortMultipartProviderUpload({ + provider: initiated.provider, + providerUploadId: initiated.providerUploadId, + uploadId: id, + key: storageKey, + context, + }).catch(() => {}) + throw error + } +} + +export async function getOwnedUploadSession(params: { + uploadId: string + workspaceId: string + userId?: string +}): Promise { + const conditions = [ + eq(uploadSessions.id, params.uploadId), + eq(uploadSessions.workspaceId, params.workspaceId), + ] + if (params.userId) conditions.push(eq(uploadSessions.userId, params.userId)) + const [session] = await db + .select() + .from(uploadSessions) + .where(and(...conditions)) + .limit(1) + if (!session) throw new UploadSessionError('not_found', 'Upload session not found') + return session +} + +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}` + ) + } + } + + const context = storageContext(params.session) + const token = signUploadToken({ + uploadId: params.session.id, + key: params.session.storageKey, + userId: params.session.userId, + workspaceId: params.session.workspaceId, + context, + }) + return getMultipartProviderPartUrls({ + provider: storageProvider(params.session), + providerUploadId: params.session.providerUploadId, + key: params.session.storageKey, + context, + partNumbers: params.partNumbers, + localUrl: (partNumber) => + `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(token)}`, + }) +} + +export async function completeUploadSession(params: { + session: UploadSessionRecord + parts: CompletedUploadPart[] + finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> + onFailure?: (session: UploadSessionRecord, error: unknown) => Promise +}): Promise<{ session: UploadSessionRecord; value: T | null; alreadyCompleted: boolean }> { + if (params.session.status === 'completed') { + return { session: params.session, value: null, alreadyCompleted: true } + } + assertUploadable(params.session) + validateCompletedParts(params.session, params.parts) + + const [claimed] = await db + .update(uploadSessions) + .set({ status: 'finalizing', updatedAt: new Date() }) + .where(and(eq(uploadSessions.id, params.session.id), eq(uploadSessions.status, 'uploading'))) + .returning() + if (!claimed) { + throw new UploadSessionError('conflict', 'Upload session is no longer uploadable') + } + + const context = storageContext(claimed) + let objectCompleted = false + try { + await completeMultipartProviderUpload({ + provider: storageProvider(claimed), + providerUploadId: claimed.providerUploadId, + uploadId: claimed.id, + key: claimed.storageKey, + contentType: claimed.contentType, + context, + parts: params.parts, + }) + objectCompleted = true + const head = await headObject(claimed.storageKey, context) + if (!head) throw new Error('Completed upload object not found') + if (head.size !== claimed.fileSize) { + throw new UploadSessionError( + 'validation', + `Uploaded object has ${head.size} bytes; expected ${claimed.fileSize}` + ) + } + + const finalized = await params.finalize(claimed) + const now = new Date() + const [completed] = await db + .update(uploadSessions) + .set({ + status: 'completed', + completedFileId: finalized.completedFileId, + error: null, + completedAt: now, + updatedAt: now, + }) + .where(and(eq(uploadSessions.id, claimed.id), eq(uploadSessions.status, 'finalizing'))) + .returning() + if (!completed) throw new Error('Upload session completion state was lost') + return { session: completed, value: finalized.value, alreadyCompleted: false } + } catch (error) { + if (objectCompleted) { + await deleteFile({ key: claimed.storageKey, context }).catch(() => {}) + } else { + await abortMultipartProviderUpload({ + provider: storageProvider(claimed), + providerUploadId: claimed.providerUploadId, + uploadId: claimed.id, + key: claimed.storageKey, + context, + }).catch(() => {}) + } + await db + .update(uploadSessions) + .set({ status: 'failed', error: getErrorMessage(error), updatedAt: new Date() }) + .where(eq(uploadSessions.id, claimed.id)) + await params.onFailure?.(claimed, error) + throw error + } +} + +export async function abortUploadSession( + session: UploadSessionRecord +): Promise { + if (session.status === 'aborted') return session + if (session.status === 'completed') { + throw new UploadSessionError('conflict', 'Completed uploads cannot be aborted') + } + if (session.status !== 'uploading') { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + const [claimed] = await db + .update(uploadSessions) + .set({ status: 'finalizing', updatedAt: new Date() }) + .where(and(eq(uploadSessions.id, session.id), eq(uploadSessions.status, 'uploading'))) + .returning() + if (!claimed) throw new UploadSessionError('conflict', 'Upload session is no longer uploadable') + try { + await abortMultipartProviderUpload({ + provider: storageProvider(claimed), + providerUploadId: claimed.providerUploadId, + uploadId: claimed.id, + key: claimed.storageKey, + context: storageContext(claimed), + }) + const now = new Date() + const [aborted] = await db + .update(uploadSessions) + .set({ status: 'aborted', completedAt: now, updatedAt: now }) + .where(eq(uploadSessions.id, claimed.id)) + .returning() + if (!aborted) throw new Error('Upload session abort state was lost') + return aborted + } catch (error) { + await db + .update(uploadSessions) + .set({ status: 'failed', error: getErrorMessage(error), updatedAt: new Date() }) + .where(eq(uploadSessions.id, claimed.id)) + throw error + } +} + +export async function expireUploadSessions(now = new Date(), limit = 100): Promise { + const expired = await db + .select() + .from(uploadSessions) + .where( + and( + inArray(uploadSessions.status, ['uploading', 'finalizing']), + lt(uploadSessions.expiresAt, now) + ) + ) + .orderBy(uploadSessions.expiresAt) + .limit(limit) + for (const session of expired) { + if (session.status === 'uploading') { + await abortMultipartProviderUpload({ + provider: storageProvider(session), + providerUploadId: session.providerUploadId, + uploadId: session.id, + key: session.storageKey, + context: storageContext(session), + }) + } else { + await abortMultipartProviderUpload({ + provider: storageProvider(session), + providerUploadId: session.providerUploadId, + uploadId: session.id, + key: session.storageKey, + context: storageContext(session), + }).catch(() => {}) + await deleteFile({ key: session.storageKey, context: storageContext(session) }).catch( + () => {} + ) + } + await db + .update(uploadSessions) + .set({ status: 'expired', completedAt: now, updatedAt: now }) + .where( + and( + eq(uploadSessions.id, session.id), + inArray(uploadSessions.status, ['uploading', 'finalizing']) + ) + ) + if (session.purpose === 'table_import') { + await db + .update(tableImports) + .set({ status: 'expired', completedAt: now, updatedAt: now }) + .where( + and( + eq(tableImports.uploadSessionId, session.id), + inArray(tableImports.status, ['uploading', 'preparing']) + ) + ) + } + } + return expired.length +} + +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) + const provider = storageProvider(session) + 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 ((provider === 's3' || provider === 'gcs') && !sorted[index].etag) { + throw new UploadSessionError( + 'validation', + `etag is required for ${provider} 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` + ) + } +} + +function storageContext(session: UploadSessionRecord): StorageContext { + if (session.storageContext !== 'workspace' && session.storageContext !== 'table-import') { + throw new Error(`Unsupported upload session storage context: ${session.storageContext}`) + } + return session.storageContext +} + +function storageProvider(session: UploadSessionRecord): MultipartStorageProvider { + if ( + session.storageProvider !== 's3' && + session.storageProvider !== 'blob' && + session.storageProvider !== 'gcs' && + session.storageProvider !== 'local' + ) { + throw new Error(`Unsupported upload session storage provider: ${session.storageProvider}`) + } + return session.storageProvider +} 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..b8247811b68 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 durable import resource 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_first_korath.sql b/packages/db/migrations/0280_first_korath.sql new file mode 100644 index 00000000000..bfcf373b848 --- /dev/null +++ b/packages/db/migrations/0280_first_korath.sql @@ -0,0 +1,58 @@ +CREATE TABLE "table_imports" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "user_id" text NOT NULL, + "upload_session_id" text, + "source_file_id" text, + "source_type" text NOT NULL, + "target_type" text NOT NULL, + "table_id" text, + "source" jsonb NOT NULL, + "target" jsonb NOT NULL, + "options" jsonb DEFAULT '{}'::jsonb NOT NULL, + "status" text NOT NULL, + "rows_processed" integer DEFAULT 0 NOT NULL, + "error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "completed_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "upload_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "user_id" text NOT NULL, + "purpose" text NOT NULL, + "storage_context" text NOT NULL, + "storage_key" text NOT NULL, + "storage_provider" text NOT NULL, + "provider_upload_id" text, + "file_name" text NOT NULL, + "content_type" text NOT NULL, + "file_size" bigint NOT NULL, + "part_size" integer NOT NULL, + "part_count" integer NOT NULL, + "status" text DEFAULT 'uploading' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "completed_file_id" text, + "error" text, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "completed_at" timestamp, + CONSTRAINT "upload_sessions_storage_key_unique" UNIQUE("storage_key") +); +--> statement-breakpoint +ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_upload_session_id_upload_sessions_id_fk" FOREIGN KEY ("upload_session_id") REFERENCES "public"."upload_sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_source_file_id_workspace_files_id_fk" FOREIGN KEY ("source_file_id") REFERENCES "public"."workspace_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_table_id_user_table_definitions_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."user_table_definitions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "upload_sessions" ADD CONSTRAINT "upload_sessions_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "upload_sessions" ADD CONSTRAINT "upload_sessions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "upload_sessions" ADD CONSTRAINT "upload_sessions_completed_file_id_workspace_files_id_fk" FOREIGN KEY ("completed_file_id") REFERENCES "public"."workspace_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "table_imports_workspace_created_idx" ON "table_imports" USING btree ("workspace_id","created_at");--> statement-breakpoint +CREATE INDEX "table_imports_status_updated_idx" ON "table_imports" USING btree ("status","updated_at");--> statement-breakpoint +CREATE INDEX "table_imports_table_idx" ON "table_imports" USING btree ("table_id");--> statement-breakpoint +CREATE INDEX "upload_sessions_workspace_created_idx" ON "upload_sessions" USING btree ("workspace_id","created_at");--> statement-breakpoint +CREATE INDEX "upload_sessions_status_expiry_idx" ON "upload_sessions" USING btree ("status","expires_at"); \ No newline at end of file diff --git a/packages/db/migrations/0281_fancy_blue_shield.sql b/packages/db/migrations/0281_fancy_blue_shield.sql new file mode 100644 index 00000000000..c4ee77d7e2a --- /dev/null +++ b/packages/db/migrations/0281_fancy_blue_shield.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..ff10e9a0cd5 --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18813 @@ +{ + "id": "6c0d9bfe-2f4f-47fa-9c73-33be60a82fdc", + "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_imports": { + "name": "table_imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upload_session_id": { + "name": "upload_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_file_id": { + "name": "source_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_imports_workspace_created_idx": { + "name": "table_imports_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": {} + }, + "table_imports_status_updated_idx": { + "name": "table_imports_status_updated_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_imports_table_idx": { + "name": "table_imports_table_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_imports_workspace_id_workspace_id_fk": { + "name": "table_imports_workspace_id_workspace_id_fk", + "tableFrom": "table_imports", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_imports_user_id_user_id_fk": { + "name": "table_imports_user_id_user_id_fk", + "tableFrom": "table_imports", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_imports_upload_session_id_upload_sessions_id_fk": { + "name": "table_imports_upload_session_id_upload_sessions_id_fk", + "tableFrom": "table_imports", + "tableTo": "upload_sessions", + "columnsFrom": ["upload_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_imports_source_file_id_workspace_files_id_fk": { + "name": "table_imports_source_file_id_workspace_files_id_fk", + "tableFrom": "table_imports", + "tableTo": "workspace_files", + "columnsFrom": ["source_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_imports_table_id_user_table_definitions_id_fk": { + "name": "table_imports_table_id_user_table_definitions_id_fk", + "tableFrom": "table_imports", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "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()" + }, + "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": { + "upload_sessions_workspace_created_idx": { + "name": "upload_sessions_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": {} + }, + "upload_sessions_status_expiry_idx": { + "name": "upload_sessions_status_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upload_sessions_workspace_id_workspace_id_fk": { + "name": "upload_sessions_workspace_id_workspace_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_user_id_user_id_fk": { + "name": "upload_sessions_user_id_user_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_completed_file_id_workspace_files_id_fk": { + "name": "upload_sessions_completed_file_id_workspace_files_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "workspace_files", + "columnsFrom": ["completed_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upload_sessions_storage_key_unique": { + "name": "upload_sessions_storage_key_unique", + "nullsNotDistinct": false, + "columns": ["storage_key"] + } + }, + "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 + }, + "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/0281_snapshot.json b/packages/db/migrations/meta/0281_snapshot.json new file mode 100644 index 00000000000..c806d32269d --- /dev/null +++ b/packages/db/migrations/meta/0281_snapshot.json @@ -0,0 +1,18819 @@ +{ + "id": "88587e61-9ab0-4ec0-855d-e109810aeb08", + "prevId": "6c0d9bfe-2f4f-47fa-9c73-33be60a82fdc", + "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_imports": { + "name": "table_imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upload_session_id": { + "name": "upload_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_file_id": { + "name": "source_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_imports_workspace_created_idx": { + "name": "table_imports_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": {} + }, + "table_imports_status_updated_idx": { + "name": "table_imports_status_updated_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_imports_table_idx": { + "name": "table_imports_table_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_imports_workspace_id_workspace_id_fk": { + "name": "table_imports_workspace_id_workspace_id_fk", + "tableFrom": "table_imports", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_imports_user_id_user_id_fk": { + "name": "table_imports_user_id_user_id_fk", + "tableFrom": "table_imports", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_imports_upload_session_id_upload_sessions_id_fk": { + "name": "table_imports_upload_session_id_upload_sessions_id_fk", + "tableFrom": "table_imports", + "tableTo": "upload_sessions", + "columnsFrom": ["upload_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_imports_source_file_id_workspace_files_id_fk": { + "name": "table_imports_source_file_id_workspace_files_id_fk", + "tableFrom": "table_imports", + "tableTo": "workspace_files", + "columnsFrom": ["source_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_imports_table_id_user_table_definitions_id_fk": { + "name": "table_imports_table_id_user_table_definitions_id_fk", + "tableFrom": "table_imports", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "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()" + }, + "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": { + "upload_sessions_workspace_created_idx": { + "name": "upload_sessions_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": {} + }, + "upload_sessions_status_expiry_idx": { + "name": "upload_sessions_status_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upload_sessions_workspace_id_workspace_id_fk": { + "name": "upload_sessions_workspace_id_workspace_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_user_id_user_id_fk": { + "name": "upload_sessions_user_id_user_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_completed_file_id_workspace_files_id_fk": { + "name": "upload_sessions_completed_file_id_workspace_files_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "workspace_files", + "columnsFrom": ["completed_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upload_sessions_storage_key_unique": { + "name": "upload_sessions_storage_key_unique", + "nullsNotDistinct": false, + "columns": ["storage_key"] + } + }, + "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..893bdfa59f5 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,20 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785790027131, + "tag": "0280_first_korath", + "breakpoints": true + }, + { + "idx": 281, + "version": "7", + "when": 1785790842256, + "tag": "0281_fancy_blue_shield", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..921cbae411b 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(), @@ -3968,6 +3971,99 @@ export const tableViews = pgTable( }) ) +/** + * Durable control-plane state for direct multipart uploads. The row exists before any bytes are + * accepted, which lets completion register storage atomically and lets the janitor abort uploads + * whose clients disappear. Provider ids and storage keys never cross the public API boundary. + */ +export const uploadSessions = pgTable( + 'upload_sessions', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + /** `'workspace_file'` | `'table_import'`. */ + purpose: text('purpose').notNull(), + storageContext: text('storage_context').notNull(), + storageKey: text('storage_key').notNull().unique(), + storageProvider: text('storage_provider').notNull(), + providerUploadId: text('provider_upload_id'), + fileName: text('file_name').notNull(), + contentType: text('content_type').notNull(), + fileSize: bigint('file_size', { mode: 'number' }).notNull(), + partSize: integer('part_size').notNull(), + partCount: integer('part_count').notNull(), + /** `'uploading'` → `'finalizing'` → `'completed'` | `'failed'` | `'aborted'` | `'expired'`. */ + status: text('status').notNull().default('uploading'), + metadata: jsonb('metadata').notNull().default({}), + completedFileId: text('completed_file_id').references(() => workspaceFiles.id, { + onDelete: 'set null', + }), + error: text('error'), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + completedAt: timestamp('completed_at'), + }, + (table) => ({ + workspaceCreatedIdx: index('upload_sessions_workspace_created_idx').on( + table.workspaceId, + table.createdAt + ), + statusExpiryIdx: index('upload_sessions_status_expiry_idx').on(table.status, table.expiresAt), + }) +) + +/** + * Public table-import resource. Upload-backed imports share their id with an upload session; once + * processing begins the same id is also used by `table_jobs`, so clients never translate ids. + */ +export const tableImports = pgTable( + 'table_imports', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + uploadSessionId: text('upload_session_id').references(() => uploadSessions.id, { + onDelete: 'set null', + }), + sourceFileId: text('source_file_id').references(() => workspaceFiles.id, { + onDelete: 'set null', + }), + /** `'upload'` | `'workspace_file'`. */ + sourceType: text('source_type').notNull(), + /** `'new'` | `'existing'`. */ + targetType: text('target_type').notNull(), + tableId: text('table_id').references(() => userTableDefinitions.id, { onDelete: 'set null' }), + source: jsonb('source').notNull(), + target: jsonb('target').notNull(), + options: jsonb('options').notNull().default({}), + /** Internal lifecycle, including `preparing` between upload completion and job dispatch. */ + status: text('status').notNull(), + rowsProcessed: integer('rows_processed').notNull().default(0), + error: text('error'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + completedAt: timestamp('completed_at'), + }, + (table) => ({ + workspaceCreatedIdx: index('table_imports_workspace_created_idx').on( + table.workspaceId, + table.createdAt + ), + statusUpdatedIdx: index('table_imports_status_updated_idx').on(table.status, table.updatedAt), + tableIdx: index('table_imports_table_idx').on(table.tableId), + }) +) + /** * Background data-mutation jobs on a user table (CSV import, bulk filtered delete). One row per * job. A detached worker streams progress into `rows_processed` and flips `status` to a terminal diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1434211c000..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: 1061, - zodRoutes: 1061, + totalRoutes: 1079, + zodRoutes: 1079, nonZodRoutes: 0, } as const From a36741f0ea36672b04a809125e444a547d70859a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 16:54:57 -0700 Subject: [PATCH 12/13] improvement(api): make multipart transfers stateless --- apps/docs/openapi-v2-files-audit.json | 43 +- apps/docs/openapi-v2-tables.json | 27 +- .../cron/cleanup-stale-executions/route.ts | 95 +- .../uploads/[uploadId]/complete/route.ts | 5 +- .../files/uploads/[uploadId]/parts/route.ts | 3 +- .../app/api/files/uploads/[uploadId]/route.ts | 34 +- .../imports/[importId]/complete/route.ts | 31 +- .../table/imports/[importId]/parts/route.ts | 18 +- .../app/api/table/imports/[importId]/route.ts | 23 +- .../uploads/[uploadId]/complete/route.ts | 9 +- .../files/uploads/[uploadId]/parts/route.ts | 7 +- .../api/v2/files/uploads/[uploadId]/route.ts | 40 +- .../app/api/v2/files/uploads/route.test.ts | 4 +- apps/sim/app/api/v2/files/uploads/utils.ts | 1 + .../imports/[importId]/complete/route.ts | 28 +- .../tables/imports/[importId]/parts/route.ts | 16 +- .../api/v2/tables/imports/[importId]/route.ts | 23 +- .../[uploadId]/parts/[partNumber]/route.ts | 18 +- apps/sim/hooks/queries/tables.ts | 6 +- apps/sim/lib/api/contracts/table-transfers.ts | 5 + apps/sim/lib/api/contracts/upload-sessions.ts | 12 +- apps/sim/lib/api/contracts/v2/files.ts | 18 +- apps/sim/lib/api/contracts/v2/tables.ts | 6 + apps/sim/lib/api/contracts/v2/uploads.ts | 9 + apps/sim/lib/table/import-resource-store.ts | 57 - apps/sim/lib/table/import-runner.ts | 24 - .../table/orchestration/import-resource.ts | 442 +- apps/sim/lib/table/service.ts | 1 + apps/sim/lib/table/types.ts | 15 + .../uploads/client/multipart-session.test.ts | 2 +- apps/sim/lib/uploads/client/session-upload.ts | 3 + .../sim/lib/uploads/core/upload-token.test.ts | 63 + apps/sim/lib/uploads/core/upload-token.ts | 34 + .../lib/uploads/multipart-session/service.ts | 447 +- apps/sim/stores/table/import-tray/store.ts | 2 +- packages/db/migrations/0280_first_korath.sql | 58 - ...blue_shield.sql => 0280_smart_la_nuit.sql} | 0 .../db/migrations/meta/0280_snapshot.json | 451 +- .../db/migrations/meta/0281_snapshot.json | 18819 ---------------- packages/db/migrations/meta/_journal.json | 11 +- packages/db/schema.ts | 93 - 41 files changed, 749 insertions(+), 20254 deletions(-) delete mode 100644 apps/sim/lib/table/import-resource-store.ts create mode 100644 apps/sim/lib/uploads/core/upload-token.test.ts delete mode 100644 packages/db/migrations/0280_first_korath.sql rename packages/db/migrations/{0281_fancy_blue_shield.sql => 0280_smart_la_nuit.sql} (100%) delete mode 100644 packages/db/migrations/meta/0281_snapshot.json diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 505033bd690..c379903774c 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -319,7 +319,7 @@ "post": { "operationId": "createFileUpload", "summary": "Create File Upload", - "description": "Create a durable multipart upload session. Every file uses this flow; a small file is a single part. The maximum file size is 5 GB.", + "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, @@ -339,31 +339,6 @@ } }, "/api/v2/files/uploads/{uploadId}": { - "get": { - "operationId": "getFileUpload", - "summary": "Get File Upload", - "description": "Read the durable state of a file upload session.", - "tags": ["Files"], - "parameters": [ - { - "name": "uploadId", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } - ], - "responses": { - "200": { - "description": "The upload session.", - "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": "abortFileUpload", "summary": "Abort File Upload", @@ -376,7 +351,8 @@ "required": true, "schema": { "type": "string" } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } ], "responses": { "200": { @@ -404,7 +380,8 @@ "required": true, "schema": { "type": "string" } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } ], "requestBody": { "required": true, @@ -437,7 +414,8 @@ "required": true, "schema": { "type": "string" } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } ], "requestBody": { "required": true, @@ -1699,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 951065cb19b..5212bd88b78 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3494,7 +3494,7 @@ "post": { "operationId": "createTableImport", "summary": "Create Table Import", - "description": "Create one durable import resource for either a new or existing table. Upload sources return multipart details; workspace-file sources start immediately.", + "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, @@ -3519,7 +3519,7 @@ "get": { "operationId": "getTableImport", "summary": "Get Table Import", - "description": "Read upload, processing, progress, and terminal state using the same import id.", + "description": "Read processing progress and terminal state from the table job using the same import id.", "tags": ["Tables"], "parameters": [ { @@ -3553,7 +3553,8 @@ "required": true, "schema": { "type": "string" } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/OptionalUploadTokenHeader" } ], "responses": { "200": { @@ -3581,7 +3582,8 @@ "required": true, "schema": { "type": "string" } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } ], "requestBody": { "required": true, @@ -3614,7 +3616,8 @@ "required": true, "schema": { "type": "string" } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } ], "requestBody": { "required": true, @@ -3884,6 +3887,20 @@ }, "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", diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index b701bdcaa7f..e58d9a037d4 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,11 +1,5 @@ import { asyncJobs, db } from '@sim/db' -import { - tableImports, - tableJobs, - uploadSessions, - workflowDeploymentOperation, - workflowExecutionLogs, -} from '@sim/db/schema' +import { tableJobs, workflowDeploymentOperation, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, exists, gt, inArray, lt, sql } from 'drizzle-orm' @@ -16,7 +10,6 @@ import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteFile } from '@/lib/uploads/core/storage-service' -import { expireUploadSessions } from '@/lib/uploads/multipart-session/service' const logger = createLogger('CleanupStaleExecutions') @@ -135,8 +128,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // place (no rollback); the user retries. Also prune long-settled terminal jobs so the table // doesn't grow unbounded (the latest job per table is what list/detail reads surface). let staleTableJobsMarkedFailed = 0 - let stalePreparingImportsMarkedFailed = 0 - let expiredUploadSessions = 0 try { const now = new Date() const staleJobs = await db @@ -151,80 +142,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .returning({ id: tableJobs.id }) staleTableJobsMarkedFailed = staleJobs.length - if (staleJobs.length > 0) { - const now = new Date() - await db - .update(tableImports) - .set({ - status: 'failed', - error: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes`, - completedAt: now, - updatedAt: now, - }) - .where( - inArray( - tableImports.id, - staleJobs.map((job) => job.id) - ) - ) - } if (staleTableJobsMarkedFailed > 0) { logger.info(`Marked ${staleTableJobsMarkedFailed} stale table jobs as failed`) } - const stalePreparingImports = await db - .select({ id: tableImports.id }) - .from(tableImports) - .where( - and(eq(tableImports.status, 'preparing'), lt(tableImports.updatedAt, staleThreshold)) - ) - .orderBy(tableImports.updatedAt) - .limit(100) - if (stalePreparingImports.length > 0) { - const failedImports = await db - .update(tableImports) - .set({ - status: 'failed', - error: `Import terminated: preparation did not finish within ${STALE_THRESHOLD_MINUTES} minutes`, - completedAt: now, - updatedAt: now, - }) - .where( - and( - eq(tableImports.status, 'preparing'), - inArray( - tableImports.id, - stalePreparingImports.map((record) => record.id) - ) - ) - ) - .returning({ uploadSessionId: tableImports.uploadSessionId }) - stalePreparingImportsMarkedFailed = failedImports.length - - const uploadSessionIds = failedImports.flatMap((record) => - record.uploadSessionId ? [record.uploadSessionId] : [] - ) - if (uploadSessionIds.length > 0) { - const uploads = await db - .select({ storageKey: uploadSessions.storageKey }) - .from(uploadSessions) - .where(inArray(uploadSessions.id, uploadSessionIds)) - for (const upload of uploads) { - await deleteFile({ key: upload.storageKey, context: 'table-import' }).catch((error) => { - logger.warn('Failed to delete source for a stale table import', { - storageKey: upload.storageKey, - error: toError(error).message, - }) - }) - } - } - } - if (stalePreparingImportsMarkedFailed > 0) { - logger.info( - `Marked ${stalePreparingImportsMarkedFailed} stale preparing table imports as failed` - ) - } - const terminalRetention = new Date(Date.now() - TABLE_JOB_RETENTION_HOURS * 60 * 60 * 1000) const pruned = await db .delete(tableJobs) @@ -255,14 +176,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - try { - expiredUploadSessions = await expireUploadSessions(new Date(), 100) - } catch (error) { - logger.error('Failed to expire multipart upload sessions:', { - error: toError(error).message, - }) - } - // Clean up stale pending jobs (never started, e.g., due to server crash before startJob()) let stalePendingJobsMarkedFailed = 0 @@ -393,12 +306,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { tableJobs: { staleMarkedFailed: staleTableJobsMarkedFailed, }, - tableImports: { - stalePreparingMarkedFailed: stalePreparingImportsMarkedFailed, - }, - uploadSessions: { - expired: expiredUploadSessions, - }, deploymentOperations: { pruned: deploymentOperationsPruned, retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS, diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index 5486dc3adbb..560357745da 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -28,10 +28,11 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa const access = await requireWorkspaceWrite(user, workspaceId) if (access) return access try { - const upload = await getOwnedUploadSession({ + 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({ @@ -49,7 +50,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa return { value: registered.file.id, completedFileId: registered.file.id } }, }) - const fileId = completed.value ?? completed.session.completedFileId + 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`) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index 158da882b22..a01a9c4ca9d 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -25,10 +25,11 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa const access = await requireWorkspaceWrite(user, workspaceId) if (access) return access try { - const upload = await getOwnedUploadSession({ + const upload = getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, workspaceId, userId: user, + uploadToken: parsed.data.headers['upload-token'], }) const parts = await createUploadPartUrls({ session: upload, diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index d98fc68e27c..ffda91c0c44 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -1,11 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' -import { - abortWorkspaceFileUploadContract, - getWorkspaceFileUploadContract, -} from '@/lib/api/contracts/upload-sessions' +import { abortWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' import { requireUploadUser, @@ -18,31 +14,6 @@ interface UploadRouteParams { params: Promise<{ uploadId: string }> } -export const GET = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { - const user = await requireUploadUser() - if (user instanceof NextResponse) return user - const parsed = await parseRequest(getWorkspaceFileUploadContract, 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 = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - workspaceId, - userId: user, - }) - const file = upload.completedFileId - ? await getWorkspaceFile(workspaceId, upload.completedFileId, { throwOnError: true }) - : null - return NextResponse.json({ data: toV2FileUpload(upload, file) }) - } catch (error) { - const classified = uploadSessionErrorResponse(error) - if (classified) return classified - throw error - } -}) - export const DELETE = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { const user = await requireUploadUser() if (user instanceof NextResponse) return user @@ -52,10 +23,11 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl const access = await requireWorkspaceWrite(user, workspaceId) if (access) return access try { - const upload = await getOwnedUploadSession({ + 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) { diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 66a1440874a..5482fac5e05 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -1,19 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' 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 { markTrackedImportTerminal } from '@/lib/table/import-resource-store' import { - getOwnedTableImport, + getOwnedTableImportUpload, startUploadedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { - completeUploadSession, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' import { orchestrationErrorResponse } from '@/app/api/table/utils' interface ImportRouteParams { @@ -28,33 +23,19 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor const parsed = await parseRequest(completeTableImportResourceContract, request, context) if (!parsed.success) return parsed.response try { - const record = await getOwnedTableImport({ + const upload = getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId: parsed.data.query.workspaceId, userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], }) - if (!record.uploadSessionId) { - return NextResponse.json({ error: 'Import has no upload source' }, { status: 409 }) - } - const upload = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId: record.workspaceId, - userId: auth.userId, - }) - await completeUploadSession({ + const completed = await completeUploadSession({ session: upload, parts: parsed.data.body.parts, finalize: async () => ({ value: null }), - onFailure: async (_session, error) => { - await markTrackedImportTerminal({ - importId: record.id, - status: 'failed', - error: getErrorMessage(error, 'Upload finalization failed'), - }) - }, }) return NextResponse.json({ - data: await toV2TableImport(await startUploadedTableImport(record.id)), + data: toV2TableImport(await startUploadedTableImport(completed.session)), }) } catch (error) { const classified = orchestrationErrorResponse(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 index 4b24b5c7347..f3534b65958 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -3,11 +3,8 @@ import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-tra import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getOwnedTableImport } from '@/lib/table/orchestration/import-resource' -import { - createUploadPartUrls, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +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 { @@ -22,18 +19,11 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) if (!parsed.success) return parsed.response try { - const record = await getOwnedTableImport({ + const upload = getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId: parsed.data.query.workspaceId, userId: auth.userId, - }) - if (!record.uploadSessionId) { - return NextResponse.json({ error: 'Import has no upload source' }, { status: 409 }) - } - const upload = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId: record.workspaceId, - userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], }) const parts = await createUploadPartUrls({ session: upload, diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts index b3769d751a5..15fb5cd2914 100644 --- a/apps/sim/app/api/table/imports/[importId]/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -7,6 +7,7 @@ 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, @@ -49,13 +50,23 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Imp const parsed = await parseRequest(cancelTableImportResourceContract, 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, - }) + 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: await toV2TableImport(await cancelTableImportResource(record)), + data: toV2TableImport(record), }) } catch (error) { const classified = orchestrationErrorResponse(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 index 73960956c94..3dfca6ca127 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -44,7 +44,12 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + 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, @@ -61,7 +66,7 @@ export const POST = withRouteHandler( return { value: registered.file.id, completedFileId: registered.file.id } }, }) - const fileId = result.value ?? result.session.completedFileId + 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`) 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 index d6964406766..4272e75796f 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -41,7 +41,12 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) const parts = await createUploadPartUrls({ session, partNumbers: parsed.data.body.partNumbers, diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 6bdbff94eea..cccb93f3524 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,10 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2AbortFileUploadContract, v2GetFileUploadContract } from '@/lib/api/contracts/v2/files' +import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' 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' @@ -24,36 +23,6 @@ interface FileUploadRouteParams { params: Promise<{ uploadId: string }> } -export const GET = 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(v2GetFileUploadContract, 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 = await getOwnedUploadSession({ uploadId, workspaceId, userId }) - const file = session.completedFileId - ? await getWorkspaceFile(workspaceId, session.completedFileId, { throwOnError: true }) - : null - return v2Data(toV2FileUpload(session, file), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to read file upload session', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) - export const DELETE = withRouteHandler( async (request: NextRequest, context: FileUploadRouteParams) => { try { @@ -70,7 +39,12 @@ export const DELETE = withRouteHandler( const { workspaceId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ uploadId, workspaceId, userId }) + 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) { diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 982a8d98147..34c934f0183 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -76,6 +76,7 @@ describe('POST /api/v2/files/uploads', () => { partSize: 8 * 1024 * 1024, partCount: 1, status: 'uploading', + uploadToken: 'signed-upload-token', metadata: {}, completedFileId: null, error: null, @@ -86,7 +87,7 @@ describe('POST /api/v2/files/uploads', () => { }) }) - it('creates one durable multipart session for a small file', async () => { + it('creates one signed multipart session for a small file', async () => { const response = await request({ workspaceId: WORKSPACE_ID, name: 'file.csv', @@ -99,6 +100,7 @@ describe('POST /api/v2/files/uploads', () => { id: 'upload-1', status: 'uploading', partCount: 1, + uploadToken: 'signed-upload-token', file: null, }) expect(mockCreateUploadSession).toHaveBeenCalledWith({ diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index e97467c5c1f..c79baf22bb1 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -16,6 +16,7 @@ export function toV2FileUpload( size: session.fileSize, partSize: session.partSize, partCount: session.partCount, + uploadToken: session.uploadToken, expiresAt: session.expiresAt.toISOString(), error: session.error, file: file ? toV2File(file) : null, 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 index 55a824be354..8ff45ebebd4 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -4,16 +4,12 @@ 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 { markTrackedImportTerminal } from '@/lib/table/import-resource-store' import { - getOwnedTableImport, + getOwnedTableImportUpload, startUploadedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { - completeUploadSession, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +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 { @@ -47,30 +43,18 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await getOwnedTableImport({ + const upload = getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId, userId, + uploadToken: parsed.data.headers['upload-token'], }) - if (!record.uploadSessionId) return v2Error('CONFLICT', 'Import has no upload source') - const upload = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId, - userId, - }) - await completeUploadSession({ + const completed = await completeUploadSession({ session: upload, parts: parsed.data.body.parts, finalize: async () => ({ value: null }), - onFailure: async (_session, error) => { - await markTrackedImportTerminal({ - importId: record.id, - status: 'failed', - error: getErrorMessage(error, 'Upload finalization failed'), - }) - }, }) - const started = await startUploadedTableImport(record.id) + const started = await startUploadedTableImport(completed.session) return v2Data(await toV2TableImport(started), { rateLimit }) } catch (error) { const lockError = v2TableLockError(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 index 738a3ad17d1..74f3153a9be 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -4,11 +4,8 @@ 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 { getOwnedTableImport } from '@/lib/table/orchestration/import-resource' -import { - createUploadPartUrls, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +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 { @@ -41,16 +38,11 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await getOwnedTableImport({ + const session = getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId, userId, - }) - if (!record.uploadSessionId) return v2Error('CONFLICT', 'Import has no upload source') - const session = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId, - userId, + uploadToken: parsed.data.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index dbda6743c51..22005ef907e 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -8,6 +8,7 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + abortTableImportUpload, cancelTableImportResource, getOwnedTableImport, toV2TableImport, @@ -72,12 +73,22 @@ export const DELETE = withRouteHandler( 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(await cancelTableImportResource(record)), { rateLimit }) + 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 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 index e56e708ada3..3baff93ec04 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -2,11 +2,11 @@ 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 { verifyUploadToken } from '@/lib/uploads/core/upload-token' import { writeLocalMultipartPart } from '@/lib/uploads/multipart-session/provider' import { expectedUploadPartSize, - getOwnedUploadSession, + type UploadSessionRecord, + verifyUploadSessionToken, } from '@/lib/uploads/multipart-session/service' interface LocalPartRouteParams { @@ -20,19 +20,17 @@ interface LocalPartRouteParams { export const PUT = withRouteHandler( async (request: NextRequest, context: LocalPartRouteParams): Promise => { const { uploadId } = await context.params - const verification = verifyUploadToken(request.nextUrl.searchParams.get('token') ?? '') - if (!verification.valid || verification.payload.uploadId !== uploadId) { + 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 - const session = await getOwnedUploadSession({ - uploadId, - workspaceId: verification.payload.workspaceId, - userId: verification.payload.userId, - }) - if (session.storageProvider !== 'local' || session.storageKey !== verification.payload.key) { + if (session.id !== uploadId || session.storageProvider !== 'local') { return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) } if (session.status !== 'uploading') { diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 1435dc41699..f519258ad33 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1771,6 +1771,7 @@ async function createAndUploadTableImport(params: { 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 @@ -1779,6 +1780,7 @@ async function createAndUploadTableImport(params: { const response = await requestJson(completeTableImportResourceContract, { params: { importId: created.data.id }, query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, body: { parts }, }) return response.data @@ -1787,12 +1789,13 @@ async function createAndUploadTableImport(params: { await requestJson(cancelTableImportResourceContract, { params: { importId: created.data.id }, query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, }) }, }) } -/** Uploads a CSV/TSV through its durable import resource and creates a table from it. */ +/** Uploads a CSV/TSV through a signed multipart session and creates a table from it. */ export function useImportCsv() { const queryClient = useQueryClient() const timezone = useTimezone() @@ -1943,6 +1946,7 @@ export async function cancelTableImport(workspaceId: string, importId: string): await requestJson(cancelTableImportResourceContract, { params: { importId }, query: { workspaceId }, + headers: {}, }) } diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts index af1057b323c..69a5c4f4ec6 100644 --- a/apps/sim/lib/api/contracts/table-transfers.ts +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -12,8 +12,10 @@ import { } from '@/lib/api/contracts/v2/tables' import { v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, } from '@/lib/api/contracts/v2/uploads' export const createTableImportResourceContract = defineRouteContract({ @@ -36,6 +38,7 @@ export const cancelTableImportResourceContract = defineRouteContract({ path: '/api/table/imports/[importId]', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) @@ -44,6 +47,7 @@ export const createTableImportPartUrlsContract = defineRouteContract({ path: '/api/table/imports/[importId]/parts', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2PartUrlsBodySchema, response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, }) @@ -53,6 +57,7 @@ export const completeTableImportResourceContract = defineRouteContract({ path: '/api/table/imports/[importId]/complete', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts index 573a9eeb951..c56d8ca014b 100644 --- a/apps/sim/lib/api/contracts/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -11,6 +11,7 @@ import { v2CompleteUploadBodySchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, } from '@/lib/api/contracts/v2/uploads' export const createWorkspaceFileUploadContract = defineRouteContract({ @@ -20,19 +21,12 @@ export const createWorkspaceFileUploadContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) -export const getWorkspaceFileUploadContract = defineRouteContract({ - method: 'GET', - path: '/api/files/uploads/[uploadId]', - params: v2FileUploadParamsSchema, - query: v2FileUploadWorkspaceQuerySchema, - 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) }, }) @@ -41,6 +35,7 @@ export const createWorkspaceFileUploadPartUrlsContract = defineRouteContract({ path: '/api/files/uploads/[uploadId]/parts', params: v2FileUploadParamsSchema, query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2PartUrlsBodySchema, response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, }) @@ -50,6 +45,7 @@ export const completeWorkspaceFileUploadContract = defineRouteContract({ path: '/api/files/uploads/[uploadId]/complete', params: v2FileUploadParamsSchema, query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 9aad6876e5a..749d4a9bda7 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -13,6 +13,7 @@ import { v2PartUrlsBodySchema, v2PartUrlsDataSchema, v2UploadStatusSchema, + v2UploadTokenHeadersSchema, } from '@/lib/api/contracts/v2/uploads' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -32,9 +33,8 @@ import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' * contract; folder management belongs on `/api/v2/folders` once that surface * serves `resourceType: 'file'`. * - * Uploads are durable multipart sessions. The control plane owns cleanup and - * completion atomically registers the workspace file, so an abandoned direct - * upload cannot become an untracked permanent object. + * 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. */ @@ -82,6 +82,7 @@ export const v2FileUploadSchema = z.object({ 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(), @@ -327,19 +328,12 @@ export const v2CreateFileUploadContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) -export const v2GetFileUploadContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/files/uploads/[uploadId]', - params: v2FileUploadParamsSchema, - query: v2FileUploadWorkspaceQuerySchema, - 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) }, }) @@ -348,6 +342,7 @@ export const v2CreateFileUploadPartUrlsContract = defineRouteContract({ path: '/api/v2/files/uploads/[uploadId]/parts', params: v2FileUploadParamsSchema, query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2PartUrlsBodySchema, response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, }) @@ -357,6 +352,7 @@ export const v2CompleteFileUploadContract = defineRouteContract({ path: '/api/v2/files/uploads/[uploadId]/complete', params: v2FileUploadParamsSchema, query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 5be7bddcd7b..18e817d58a8 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -47,8 +47,10 @@ import { } 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' @@ -991,6 +993,7 @@ export const v2TableImportStatusSchema = z.enum([ 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(), @@ -1032,6 +1035,7 @@ export const v2CancelTableImportContract = defineRouteContract({ path: '/api/v2/tables/imports/[importId]', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) @@ -1040,6 +1044,7 @@ export const v2CreateTableImportPartUrlsContract = defineRouteContract({ path: '/api/v2/tables/imports/[importId]/parts', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2PartUrlsBodySchema, response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, }) @@ -1049,6 +1054,7 @@ export const v2CompleteTableImportContract = defineRouteContract({ path: '/api/v2/tables/imports/[importId]/complete', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index 97b6d935446..d18f9124692 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -10,6 +10,15 @@ export const v2UploadStatusSchema = z.enum([ ]) 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), diff --git a/apps/sim/lib/table/import-resource-store.ts b/apps/sim/lib/table/import-resource-store.ts deleted file mode 100644 index 58cd6de00b3..00000000000 --- a/apps/sim/lib/table/import-resource-store.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { db } from '@sim/db' -import { tableImports } from '@sim/db/schema' -import { and, eq, inArray } from 'drizzle-orm' - -export type TableImportRecord = typeof tableImports.$inferSelect - -export async function getTableImport(importId: string): Promise { - const [record] = await db - .select() - .from(tableImports) - .where(eq(tableImports.id, importId)) - .limit(1) - return record ?? null -} - -export async function updateTrackedImportProgress( - importId: string, - rowsProcessed: number -): Promise { - await db - .update(tableImports) - .set({ status: 'processing', rowsProcessed, updatedAt: new Date() }) - .where(and(eq(tableImports.id, importId), eq(tableImports.status, 'processing'))) -} - -export async function markTrackedImportProcessing(importId: string): Promise { - const [claimed] = await db - .update(tableImports) - .set({ status: 'processing', updatedAt: new Date() }) - .where(and(eq(tableImports.id, importId), eq(tableImports.status, 'queued'))) - .returning({ id: tableImports.id }) - if (!claimed) throw new Error(`Table import ${importId} is no longer queued`) -} - -export async function markTrackedImportTerminal(params: { - importId: string - status: 'completed' | 'failed' | 'canceled' - rowsProcessed?: number - error?: string | null -}): Promise { - const now = new Date() - await db - .update(tableImports) - .set({ - status: params.status, - ...(params.rowsProcessed === undefined ? {} : { rowsProcessed: params.rowsProcessed }), - error: params.error ?? null, - completedAt: now, - updatedAt: now, - }) - .where( - and( - eq(tableImports.id, params.importId), - inArray(tableImports.status, ['uploading', 'preparing', 'queued', 'processing']) - ) - ) -} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index f879132d333..b0eba05c14d 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -27,11 +27,6 @@ import { deleteAllTableRows, setTableSchemaForImport, } from '@/lib/table/import-data' -import { - markTrackedImportProcessing, - markTrackedImportTerminal, - updateTrackedImportProgress, -} from '@/lib/table/import-resource-store' import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' @@ -84,8 +79,6 @@ export interface TableImportPayload { timezone?: string /** Storage context for the source object. Legacy imports default to `workspace`. */ storageContext?: 'workspace' | 'table-import' - /** Persist progress to the public table-import resource in addition to the table job. */ - trackImportResource?: boolean } /** @@ -105,7 +98,6 @@ export async function runTableImport(payload: TableImportPayload): Promise let source: Readable | undefined try { - if (payload.trackImportResource) await markTrackedImportProcessing(importId) 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`) @@ -296,7 +288,6 @@ export async function runTableImport(payload: TableImportPayload): Promise }) inserted += result.inserted lastOrderKey = result.lastOrderKey - if (payload.trackImportResource) await updateTrackedImportProgress(importId, inserted) // Emit after the first batch, then every interval, so the bar appears early without flooding. if ( inserted - lastReported >= PROGRESS_INTERVAL_ROWS || @@ -342,9 +333,6 @@ export async function runTableImport(payload: TableImportPayload): Promise // No data rows — fail rather than report a successful empty import (matches the sync route). const message = 'CSV file has no data rows' await markJobFailed(tableId, importId, message) - if (payload.trackImportResource) { - await markTrackedImportTerminal({ importId, status: 'failed', error: message }) - } void appendTableEvent({ kind: 'job', type: 'import', @@ -380,13 +368,6 @@ export async function runTableImport(payload: TableImportPayload): Promise // right at the end makes this a no-op, and we must not emit a false `ready`. const becameReady = await markJobReady(tableId, importId) if (becameReady) { - if (payload.trackImportResource) { - await markTrackedImportTerminal({ - importId, - status: 'completed', - rowsProcessed: inserted, - }) - } void appendTableEvent({ kind: 'job', type: 'import', @@ -430,11 +411,6 @@ export async function runTableImport(payload: TableImportPayload): Promise logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) // Scoped to importId — a no-op if a newer import has taken over. await markJobFailed(tableId, importId, message).catch(() => {}) - if (payload.trackImportResource) { - await markTrackedImportTerminal({ importId, status: 'failed', error: message }).catch( - () => {} - ) - } void appendTableEvent({ kind: 'job', type: 'import', diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 6d8f47b06a4..9fb5970e0ad 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -1,13 +1,14 @@ import { db } from '@sim/db' -import { tableImports } from '@sim/db/schema' +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 V2TableImportStatus, + type V2TableImportSource, type V2TableImportTarget, + v2CreateTableImportBodySchema, v2TableImportSourceSchema, v2TableImportTargetSchema, } from '@/lib/api/contracts/v2/tables' @@ -17,15 +18,11 @@ 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 { - getTableImport, - markTrackedImportTerminal, - type TableImportRecord, -} from '@/lib/table/import-resource-store' 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, @@ -36,8 +33,27 @@ import { 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: TableImportRecord + record: TableImportResource upload: UploadSessionRecord | null } @@ -48,11 +64,7 @@ export async function createTableImportResource( await assertWorkspaceWrite(userId, body.workspaceId) await validateTarget(body.workspaceId, body.target) const importId = generateId() - const options = { - mapping: body.mapping, - createColumns: body.createColumns, - timezone: body.timezone, - } + const options = importOptions(body) if (body.source.type === 'upload') { assertCsvFileName(body.source.name) @@ -64,228 +76,232 @@ export async function createTableImportResource( fileName: body.source.name, contentType: body.source.contentType, fileSize: body.source.size, + metadata: { tableImport: body }, }) - try { - const [record] = await db - .insert(tableImports) - .values({ - id: importId, - workspaceId: body.workspaceId, - userId, - uploadSessionId: upload.id, - sourceType: 'upload', - targetType: body.target.type, - sourceFileId: null, - tableId: body.target.type === 'existing' ? body.target.tableId : null, - source: body.source, - target: body.target, - options, - status: 'uploading', - }) - .returning() - if (!record) throw new Error('Table import insert returned no row') - return { record, upload } - } catch (error) { - await abortUploadSession(upload).catch(() => {}) - throw error - } + return { record: resourceFromUpload(upload, body), upload } } const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) assertCsvFileName(file.name) - const [record] = await db - .insert(tableImports) - .values({ + return { + record: await startTableImport({ id: importId, workspaceId: body.workspaceId, userId, - uploadSessionId: null, - sourceFileId: file.id, - sourceType: 'workspace_file', - targetType: body.target.type, - tableId: body.target.type === 'existing' ? body.target.tableId : null, source: body.source, target: body.target, options, - status: 'queued', - }) - .returning() - if (!record) throw new Error('Table import insert returned no row') - return { - record: await startTableImport(record, file.key, file.name, 'workspace', false), + fileKey: file.key, + fileName: file.name, + storageContext: 'workspace', + deleteSourceFile: false, + }), upload: null, } } -export async function startUploadedTableImport(importId: string): Promise { - const record = await getTableImport(importId) - if (!record) throw new OrchestrationError('not_found', 'Table import not found') - if (record.status !== 'uploading') return record - if (!record.uploadSessionId) throw new Error(`Table import ${importId} has no upload session`) - const upload = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId: record.workspaceId, - userId: record.userId, +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 (upload.status !== 'completed') { - throw new OrchestrationError('conflict', `Table import upload is ${upload.status}`) - } - return startTableImport(record, upload.storageKey, upload.fileName, 'table-import', true) + 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 db +}): Promise { + const record = await findOwnedTableImport(params) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +async function findOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const [job] = await db .select() - .from(tableImports) + .from(tableJobs) .where( and( - eq(tableImports.id, params.importId), - eq(tableImports.workspaceId, params.workspaceId), - eq(tableImports.userId, params.userId) + eq(tableJobs.id, params.importId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'import') ) ) .limit(1) - if (!record) throw new OrchestrationError('not_found', 'Table import not found') - return record + 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: TableImportRecord -): Promise { + record: TableImportResource +): Promise { if (record.status === 'canceled') return record - if (record.status === 'completed' || record.status === 'failed' || record.status === 'expired') { - throw new OrchestrationError('conflict', `Table import is ${record.status}`) + if (record.status !== 'running' || !record.tableId) { + throw new OrchestrationError('conflict', `Table import is ${publicImportStatus(record.status)}`) } - - if (record.status === 'uploading') { - if (!record.uploadSessionId) throw new Error(`Table import ${record.id} has no upload session`) - const upload = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId: record.workspaceId, - userId: record.userId, - }) - await abortUploadSession(upload) - } else if (record.tableId) { - await markJobCanceled(record.tableId, record.id) - } - await markTrackedImportTerminal({ importId: record.id, status: 'canceled' }) - const updated = await getTableImport(record.id) - if (!updated) throw new Error(`Canceled table import ${record.id} disappeared`) - return updated + await markJobCanceled(record.tableId, record.id) + return getOwnedTableImport({ + importId: record.id, + workspaceId: record.workspaceId, + userId: record.userId, + }) } -export async function toV2TableImport(record: TableImportRecord): Promise { - const source = v2TableImportSourceSchema.parse(record.source) - const target = v2TableImportTargetSchema.parse(record.target) - let upload: V2TableImport['upload'] = null - if (record.uploadSessionId) { - const session = await getOwnedUploadSession({ - uploadId: record.uploadSessionId, - workspaceId: record.workspaceId, - userId: record.userId, - }) - upload = { - partSize: session.partSize, - partCount: session.partCount, - expiresAt: session.expiresAt.toISOString(), - } - } +export function toV2TableImport(record: TableImportResource): V2TableImport { return { id: record.id, workspaceId: record.workspaceId, status: publicImportStatus(record.status), - source, - target, + source: record.source, + target: record.target, tableId: record.tableId, rowsProcessed: record.rowsProcessed, error: record.error, - upload, + 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, } } -async function startTableImport( - record: TableImportRecord, - fileKey: string, - fileName: string, - storageContext: 'workspace' | 'table-import', +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 -): Promise { - const [claimed] = await db - .update(tableImports) - .set({ status: 'preparing', updatedAt: new Date() }) - .where(and(eq(tableImports.id, record.id), eq(tableImports.status, record.status))) - .returning() - if (!claimed) { - const current = await getTableImport(record.id) - if (!current) throw new Error(`Table import ${record.id} disappeared while starting`) - return current - } +} - const target = v2TableImportTargetSchema.parse(claimed.target) - const options = claimed.options as { - mapping?: TableImportPayload['mapping'] - createColumns?: string[] - timezone?: string - } +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 (target.type === 'new') { - const limits = await getWorkspaceTableLimits(claimed.workspaceId) + if (params.target.type === 'new') { + const limits = await getWorkspaceTableLimits(params.workspaceId) const table = await createTable( { - name: target.name, - description: `Imported from ${fileName}`, + name: params.target.name, + description: `Imported from ${params.fileName}`, schema: { columns: [{ name: 'column_1', type: 'string' }] }, - workspaceId: claimed.workspaceId, - folderId: target.folderId ?? null, - userId: claimed.userId, + workspaceId: params.workspaceId, + folderId: params.target.folderId ?? null, + userId: params.userId, maxTables: limits.maxTables, jobStatus: 'running', jobType: 'import', - jobId: claimed.id, + jobId: params.id, + jobPayload, }, requestId ) tableId = table.id } else { - const table = await requireExistingTarget(claimed.workspaceId, target) + const table = await requireExistingTarget(params.workspaceId, params.target) tableId = table.id - if (!(await markTableJobRunning(tableId, claimed.id, 'import'))) { + if (!(await markTableJobRunning(tableId, params.id, 'import', jobPayload))) { throw new OrchestrationError('conflict', 'A job is already in progress for this table') } } - const [queued] = await db - .update(tableImports) - .set({ tableId, status: 'queued', updatedAt: new Date() }) - .where(and(eq(tableImports.id, claimed.id), eq(tableImports.status, 'preparing'))) - .returning() - if (!queued) - throw new OrchestrationError('conflict', 'Table import was canceled while starting') - const payload: TableImportPayload = { - importId: claimed.id, + importId: params.id, tableId, - workspaceId: claimed.workspaceId, - userId: claimed.userId, - fileKey, - fileName, - delimiter: fileName.toLowerCase().endsWith('.tsv') ? '\t' : ',', - mode: target.type === 'new' ? 'create' : target.mode, - mapping: options.mapping, - createColumns: options.createColumns, - deleteSourceFile, - storageContext, - trackImportResource: true, - timezone: options.timezone ?? (await getUserSettings(claimed.userId)).timezone ?? 'UTC', + 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) { @@ -295,25 +311,87 @@ async function startTableImport( import('@/lib/core/async-jobs/region'), ]) await tasks.trigger('table-import', payload, { - tags: [`tableId:${tableId}`, `jobId:${claimed.id}`], + tags: [`tableId:${tableId}`, `jobId:${params.id}`], region: await resolveTriggerRegion(), }) } else { runDetached('table-import', () => runTableImport(payload)) } - return queued + 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, claimed.id, message).catch(() => {}) - await markTrackedImportTerminal({ importId: claimed.id, status: 'failed', error: message }) - if (deleteSourceFile) { + if (tableId) await markJobFailed(tableId, params.id, message).catch(() => {}) + if (params.deleteSourceFile) { const { deleteFile } = await import('@/lib/uploads/core/storage-service') - await deleteFile({ key: fileKey, context: storageContext }).catch(() => {}) + 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'))) { @@ -332,8 +410,9 @@ async function requireExistingTarget( if (!table || table.workspaceId !== workspaceId) { throw new OrchestrationError('not_found', 'Table not found') } - if (table.archivedAt) + if (table.archivedAt) { throw new OrchestrationError('validation', 'Cannot import into an archived table') + } assertRowInsert(table) if (target.mode === 'replace') assertRowDelete(table) return table @@ -362,18 +441,15 @@ function assertCsvFileName(fileName: string): void { } } -function publicImportStatus(status: string): V2TableImportStatus { - if (status === 'preparing') return 'queued' - if ( - status !== 'uploading' && - status !== 'queued' && - status !== 'processing' && - status !== 'completed' && - status !== 'failed' && - status !== 'canceled' && - status !== 'expired' - ) { - throw new Error(`Invalid table import status: ${status}`) +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/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 40130c51f58..fbe997dd29f 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -360,6 +360,19 @@ export interface TableExportJobPayload { 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 @@ -646,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/uploads/client/multipart-session.test.ts b/apps/sim/lib/uploads/client/multipart-session.test.ts index 28f936ba32e..0f8b495a2b1 100644 --- a/apps/sim/lib/uploads/client/multipart-session.test.ts +++ b/apps/sim/lib/uploads/client/multipart-session.test.ts @@ -52,7 +52,7 @@ describe('uploadMultipartSession', () => { expect(abort).not.toHaveBeenCalled() }) - it('aborts the durable session when a part upload is aborted', async () => { + 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 () => {}) diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts index 3b57e6bf962..eecc4237419 100644 --- a/apps/sim/lib/uploads/client/session-upload.ts +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -40,6 +40,7 @@ export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSess const batch = await requestJson(createWorkspaceFileUploadPartUrlsContract, { params: { uploadId: upload.id }, query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, body: { partNumbers }, signal, }) @@ -49,6 +50,7 @@ export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSess const completed = await requestJson(completeWorkspaceFileUploadContract, { params: { uploadId: upload.id }, query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, body: { parts }, signal, }) @@ -59,6 +61,7 @@ export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSess await requestJson(abortWorkspaceFileUploadContract, { params: { uploadId: upload.id }, query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, }) }, }) 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/service.ts b/apps/sim/lib/uploads/multipart-session/service.ts index 5c96bf84c02..fba4eb353ef 100644 --- a/apps/sim/lib/uploads/multipart-session/service.ts +++ b/apps/sim/lib/uploads/multipart-session/service.ts @@ -1,16 +1,12 @@ -import { db } from '@sim/db' -import { tableImports, uploadSessions } from '@sim/db/schema' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, lt } from 'drizzle-orm' import { checkStorageQuotaForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' -import { deleteFile, headObject } from '@/lib/uploads/core/storage-service' -import { signUploadToken } from '@/lib/uploads/core/upload-token' +import { headObject } from '@/lib/uploads/core/storage-service' +import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' import { abortMultipartProviderUpload, type CompletedUploadPart, @@ -28,15 +24,32 @@ 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' - | 'finalizing' - | 'completed' - | 'failed' - | 'aborted' - | 'expired' +export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' -export type UploadSessionRecord = typeof uploadSessions.$inferSelect +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( @@ -64,7 +77,8 @@ export async function createUploadSession( ): Promise { validateFileSize(params.fileSize) const id = params.id ?? generateId() - const context: StorageContext = params.purpose === 'workspace_file' ? 'workspace' : 'table-import' + const storageContext: StorageContext = + params.purpose === 'workspace_file' ? 'workspace' : 'table-import' const storageKey = params.purpose === 'workspace_file' ? generateWorkspaceFileKey(params.workspaceId, params.fileName) @@ -84,65 +98,131 @@ export async function createUploadSession( fileName: params.fileName, contentType: params.contentType, fileSize: params.fileSize, - context, + context: storageContext, localUploadId: id, }) - - try { - const [created] = await db - .insert(uploadSessions) - .values({ - id, - workspaceId: params.workspaceId, - userId: params.userId, - purpose: params.purpose, - storageContext: context, - 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: params.metadata ?? {}, - expiresAt: new Date(Date.now() + MULTIPART_SESSION_TTL_MS), - }) - .returning() - if (!created) throw new Error('Upload session insert returned no row') - return created - } catch (error) { - await abortMultipartProviderUpload({ - provider: initiated.provider, - providerUploadId: initiated.providerUploadId, + 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, - context, - }).catch(() => {}) - throw error + 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 async function getOwnedUploadSession(params: { +export function getOwnedUploadSession(params: { uploadId: string workspaceId: string userId?: string -}): Promise { - const conditions = [ - eq(uploadSessions.id, params.uploadId), - eq(uploadSessions.workspaceId, params.workspaceId), - ] - if (params.userId) conditions.push(eq(uploadSessions.userId, params.userId)) - const [session] = await db - .select() - .from(uploadSessions) - .where(and(...conditions)) - .limit(1) - if (!session) throw new UploadSessionError('not_found', 'Upload session not found') + 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[] @@ -171,22 +251,14 @@ export async function createUploadPartUrls(params: { } } - const context = storageContext(params.session) - const token = signUploadToken({ - uploadId: params.session.id, - key: params.session.storageKey, - userId: params.session.userId, - workspaceId: params.session.workspaceId, - context, - }) return getMultipartProviderPartUrls({ - provider: storageProvider(params.session), + provider: params.session.storageProvider, providerUploadId: params.session.providerUploadId, key: params.session.storageKey, - context, + context: params.session.storageContext, partNumbers: params.partNumbers, localUrl: (partNumber) => - `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(token)}`, + `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(params.session.uploadToken)}`, }) } @@ -194,177 +266,67 @@ export async function completeUploadSession(params: { session: UploadSessionRecord parts: CompletedUploadPart[] finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> - onFailure?: (session: UploadSessionRecord, error: unknown) => Promise -}): Promise<{ session: UploadSessionRecord; value: T | null; alreadyCompleted: boolean }> { - if (params.session.status === 'completed') { - return { session: params.session, value: null, alreadyCompleted: true } - } +}): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { assertUploadable(params.session) validateCompletedParts(params.session, params.parts) - const [claimed] = await db - .update(uploadSessions) - .set({ status: 'finalizing', updatedAt: new Date() }) - .where(and(eq(uploadSessions.id, params.session.id), eq(uploadSessions.status, 'uploading'))) - .returning() - if (!claimed) { - throw new UploadSessionError('conflict', 'Upload session is no longer uploadable') + 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}` + ) } - - const context = storageContext(claimed) - let objectCompleted = false - try { + if (!alreadyCompleted) { await completeMultipartProviderUpload({ - provider: storageProvider(claimed), - providerUploadId: claimed.providerUploadId, - uploadId: claimed.id, - key: claimed.storageKey, - contentType: claimed.contentType, - context, + 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, }) - objectCompleted = true - const head = await headObject(claimed.storageKey, context) - if (!head) throw new Error('Completed upload object not found') - if (head.size !== claimed.fileSize) { - throw new UploadSessionError( - 'validation', - `Uploaded object has ${head.size} bytes; expected ${claimed.fileSize}` - ) - } + } - const finalized = await params.finalize(claimed) - const now = new Date() - const [completed] = await db - .update(uploadSessions) - .set({ - status: 'completed', - completedFileId: finalized.completedFileId, - error: null, - completedAt: now, - updatedAt: now, - }) - .where(and(eq(uploadSessions.id, claimed.id), eq(uploadSessions.status, 'finalizing'))) - .returning() - if (!completed) throw new Error('Upload session completion state was lost') - return { session: completed, value: finalized.value, alreadyCompleted: false } - } catch (error) { - if (objectCompleted) { - await deleteFile({ key: claimed.storageKey, context }).catch(() => {}) - } else { - await abortMultipartProviderUpload({ - provider: storageProvider(claimed), - providerUploadId: claimed.providerUploadId, - uploadId: claimed.id, - key: claimed.storageKey, - context, - }).catch(() => {}) - } - await db - .update(uploadSessions) - .set({ status: 'failed', error: getErrorMessage(error), updatedAt: new Date() }) - .where(eq(uploadSessions.id, claimed.id)) - await params.onFailure?.(claimed, error) - throw error + 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 { - if (session.status === 'aborted') return session - if (session.status === 'completed') { - throw new UploadSessionError('conflict', 'Completed uploads cannot be aborted') - } - if (session.status !== 'uploading') { - throw new UploadSessionError('conflict', `Upload session is ${session.status}`) - } - const [claimed] = await db - .update(uploadSessions) - .set({ status: 'finalizing', updatedAt: new Date() }) - .where(and(eq(uploadSessions.id, session.id), eq(uploadSessions.status, 'uploading'))) - .returning() - if (!claimed) throw new UploadSessionError('conflict', 'Upload session is no longer uploadable') - try { - await abortMultipartProviderUpload({ - provider: storageProvider(claimed), - providerUploadId: claimed.providerUploadId, - uploadId: claimed.id, - key: claimed.storageKey, - context: storageContext(claimed), - }) - const now = new Date() - const [aborted] = await db - .update(uploadSessions) - .set({ status: 'aborted', completedAt: now, updatedAt: now }) - .where(eq(uploadSessions.id, claimed.id)) - .returning() - if (!aborted) throw new Error('Upload session abort state was lost') - return aborted - } catch (error) { - await db - .update(uploadSessions) - .set({ status: 'failed', error: getErrorMessage(error), updatedAt: new Date() }) - .where(eq(uploadSessions.id, claimed.id)) - throw error - } -} - -export async function expireUploadSessions(now = new Date(), limit = 100): Promise { - const expired = await db - .select() - .from(uploadSessions) - .where( - and( - inArray(uploadSessions.status, ['uploading', 'finalizing']), - lt(uploadSessions.expiresAt, now) - ) - ) - .orderBy(uploadSessions.expiresAt) - .limit(limit) - for (const session of expired) { - if (session.status === 'uploading') { - await abortMultipartProviderUpload({ - provider: storageProvider(session), - providerUploadId: session.providerUploadId, - uploadId: session.id, - key: session.storageKey, - context: storageContext(session), - }) - } else { - await abortMultipartProviderUpload({ - provider: storageProvider(session), - providerUploadId: session.providerUploadId, - uploadId: session.id, - key: session.storageKey, - context: storageContext(session), - }).catch(() => {}) - await deleteFile({ key: session.storageKey, context: storageContext(session) }).catch( - () => {} - ) - } - await db - .update(uploadSessions) - .set({ status: 'expired', completedAt: now, updatedAt: now }) - .where( - and( - eq(uploadSessions.id, session.id), - inArray(uploadSessions.status, ['uploading', 'finalizing']) - ) - ) - if (session.purpose === 'table_import') { - await db - .update(tableImports) - .set({ status: 'expired', completedAt: now, updatedAt: now }) - .where( - and( - eq(tableImports.uploadSessionId, session.id), - inArray(tableImports.status, ['uploading', 'preparing']) - ) - ) - } - } - return expired.length + 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 { @@ -392,7 +354,6 @@ function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUp ) } const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) - const provider = storageProvider(session) for (let index = 0; index < sorted.length; index++) { if (sorted[index].partNumber !== index + 1) { throw new UploadSessionError( @@ -400,10 +361,13 @@ function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUp 'Completed parts must contain every part exactly once' ) } - if ((provider === 's3' || provider === 'gcs') && !sorted[index].etag) { + if ( + (session.storageProvider === 's3' || session.storageProvider === 'gcs') && + !sorted[index].etag + ) { throw new UploadSessionError( 'validation', - `etag is required for ${provider} part ${sorted[index].partNumber}` + `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` ) } } @@ -420,22 +384,3 @@ function validateFileSize(fileSize: number): void { ) } } - -function storageContext(session: UploadSessionRecord): StorageContext { - if (session.storageContext !== 'workspace' && session.storageContext !== 'table-import') { - throw new Error(`Unsupported upload session storage context: ${session.storageContext}`) - } - return session.storageContext -} - -function storageProvider(session: UploadSessionRecord): MultipartStorageProvider { - if ( - session.storageProvider !== 's3' && - session.storageProvider !== 'blob' && - session.storageProvider !== 'gcs' && - session.storageProvider !== 'local' - ) { - throw new Error(`Unsupported upload session storage provider: ${session.storageProvider}`) - } - return session.storageProvider -} diff --git a/apps/sim/stores/table/import-tray/store.ts b/apps/sim/stores/table/import-tray/store.ts index b8247811b68..9b547b5f6f4 100644 --- a/apps/sim/stores/table/import-tray/store.ts +++ b/apps/sim/stores/table/import-tray/store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' /** - * An in-flight client upload, shown after its durable import resource is created but before the + * 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 { diff --git a/packages/db/migrations/0280_first_korath.sql b/packages/db/migrations/0280_first_korath.sql deleted file mode 100644 index bfcf373b848..00000000000 --- a/packages/db/migrations/0280_first_korath.sql +++ /dev/null @@ -1,58 +0,0 @@ -CREATE TABLE "table_imports" ( - "id" text PRIMARY KEY NOT NULL, - "workspace_id" text NOT NULL, - "user_id" text NOT NULL, - "upload_session_id" text, - "source_file_id" text, - "source_type" text NOT NULL, - "target_type" text NOT NULL, - "table_id" text, - "source" jsonb NOT NULL, - "target" jsonb NOT NULL, - "options" jsonb DEFAULT '{}'::jsonb NOT NULL, - "status" text NOT NULL, - "rows_processed" integer DEFAULT 0 NOT NULL, - "error" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - "completed_at" timestamp -); ---> statement-breakpoint -CREATE TABLE "upload_sessions" ( - "id" text PRIMARY KEY NOT NULL, - "workspace_id" text NOT NULL, - "user_id" text NOT NULL, - "purpose" text NOT NULL, - "storage_context" text NOT NULL, - "storage_key" text NOT NULL, - "storage_provider" text NOT NULL, - "provider_upload_id" text, - "file_name" text NOT NULL, - "content_type" text NOT NULL, - "file_size" bigint NOT NULL, - "part_size" integer NOT NULL, - "part_count" integer NOT NULL, - "status" text DEFAULT 'uploading' NOT NULL, - "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, - "completed_file_id" text, - "error" text, - "expires_at" timestamp NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - "completed_at" timestamp, - CONSTRAINT "upload_sessions_storage_key_unique" UNIQUE("storage_key") -); ---> statement-breakpoint -ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_upload_session_id_upload_sessions_id_fk" FOREIGN KEY ("upload_session_id") REFERENCES "public"."upload_sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_source_file_id_workspace_files_id_fk" FOREIGN KEY ("source_file_id") REFERENCES "public"."workspace_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "table_imports" ADD CONSTRAINT "table_imports_table_id_user_table_definitions_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."user_table_definitions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "upload_sessions" ADD CONSTRAINT "upload_sessions_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "upload_sessions" ADD CONSTRAINT "upload_sessions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "upload_sessions" ADD CONSTRAINT "upload_sessions_completed_file_id_workspace_files_id_fk" FOREIGN KEY ("completed_file_id") REFERENCES "public"."workspace_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "table_imports_workspace_created_idx" ON "table_imports" USING btree ("workspace_id","created_at");--> statement-breakpoint -CREATE INDEX "table_imports_status_updated_idx" ON "table_imports" USING btree ("status","updated_at");--> statement-breakpoint -CREATE INDEX "table_imports_table_idx" ON "table_imports" USING btree ("table_id");--> statement-breakpoint -CREATE INDEX "upload_sessions_workspace_created_idx" ON "upload_sessions" USING btree ("workspace_id","created_at");--> statement-breakpoint -CREATE INDEX "upload_sessions_status_expiry_idx" ON "upload_sessions" USING btree ("status","expires_at"); \ No newline at end of file diff --git a/packages/db/migrations/0281_fancy_blue_shield.sql b/packages/db/migrations/0280_smart_la_nuit.sql similarity index 100% rename from packages/db/migrations/0281_fancy_blue_shield.sql rename to packages/db/migrations/0280_smart_la_nuit.sql diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json index ff10e9a0cd5..2dee9eb794e 100644 --- a/packages/db/migrations/meta/0280_snapshot.json +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -1,5 +1,5 @@ { - "id": "6c0d9bfe-2f4f-47fa-9c73-33be60a82fdc", + "id": "006931de-2a8e-418b-91ea-92f853773e45", "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", "version": "7", "dialect": "postgresql", @@ -11437,229 +11437,6 @@ }, "isRLSEnabled": false }, - "public.table_imports": { - "name": "table_imports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "upload_session_id": { - "name": "upload_session_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_file_id": { - "name": "source_file_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "table_id": { - "name": "table_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source": { - "name": "source", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "target": { - "name": "target", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "options": { - "name": "options", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "rows_processed": { - "name": "rows_processed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "error": { - "name": "error", - "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()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "table_imports_workspace_created_idx": { - "name": "table_imports_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": {} - }, - "table_imports_status_updated_idx": { - "name": "table_imports_status_updated_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_imports_table_idx": { - "name": "table_imports_table_idx", - "columns": [ - { - "expression": "table_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "table_imports_workspace_id_workspace_id_fk": { - "name": "table_imports_workspace_id_workspace_id_fk", - "tableFrom": "table_imports", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "table_imports_user_id_user_id_fk": { - "name": "table_imports_user_id_user_id_fk", - "tableFrom": "table_imports", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "table_imports_upload_session_id_upload_sessions_id_fk": { - "name": "table_imports_upload_session_id_upload_sessions_id_fk", - "tableFrom": "table_imports", - "tableTo": "upload_sessions", - "columnsFrom": ["upload_session_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "table_imports_source_file_id_workspace_files_id_fk": { - "name": "table_imports_source_file_id_workspace_files_id_fk", - "tableFrom": "table_imports", - "tableTo": "workspace_files", - "columnsFrom": ["source_file_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "table_imports_table_id_user_table_definitions_id_fk": { - "name": "table_imports_table_id_user_table_definitions_id_fk", - "tableFrom": "table_imports", - "tableTo": "user_table_definitions", - "columnsFrom": ["table_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.table_jobs": { "name": "table_jobs", "schema": "", @@ -12315,226 +12092,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.upload_sessions": { - "name": "upload_sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_context": { - "name": "storage_context", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_provider": { - "name": "storage_provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_upload_id": { - "name": "provider_upload_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "file_size": { - "name": "file_size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "part_size": { - "name": "part_size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "part_count": { - "name": "part_count", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'uploading'" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "completed_file_id": { - "name": "completed_file_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "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()" - }, - "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": { - "upload_sessions_workspace_created_idx": { - "name": "upload_sessions_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": {} - }, - "upload_sessions_status_expiry_idx": { - "name": "upload_sessions_status_expiry_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "upload_sessions_workspace_id_workspace_id_fk": { - "name": "upload_sessions_workspace_id_workspace_id_fk", - "tableFrom": "upload_sessions", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "upload_sessions_user_id_user_id_fk": { - "name": "upload_sessions_user_id_user_id_fk", - "tableFrom": "upload_sessions", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "upload_sessions_completed_file_id_workspace_files_id_fk": { - "name": "upload_sessions_completed_file_id_workspace_files_id_fk", - "tableFrom": "upload_sessions", - "tableTo": "workspace_files", - "columnsFrom": ["completed_file_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "upload_sessions_storage_key_unique": { - "name": "upload_sessions_storage_key_unique", - "nullsNotDistinct": false, - "columns": ["storage_key"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.usage_log": { "name": "usage_log", "schema": "", @@ -17663,6 +17220,12 @@ "primaryKey": false, "notNull": true }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, "deleted_at": { "name": "deleted_at", "type": "timestamp", diff --git a/packages/db/migrations/meta/0281_snapshot.json b/packages/db/migrations/meta/0281_snapshot.json deleted file mode 100644 index c806d32269d..00000000000 --- a/packages/db/migrations/meta/0281_snapshot.json +++ /dev/null @@ -1,18819 +0,0 @@ -{ - "id": "88587e61-9ab0-4ec0-855d-e109810aeb08", - "prevId": "6c0d9bfe-2f4f-47fa-9c73-33be60a82fdc", - "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_imports": { - "name": "table_imports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "upload_session_id": { - "name": "upload_session_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_file_id": { - "name": "source_file_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "table_id": { - "name": "table_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source": { - "name": "source", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "target": { - "name": "target", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "options": { - "name": "options", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "rows_processed": { - "name": "rows_processed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "error": { - "name": "error", - "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()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "table_imports_workspace_created_idx": { - "name": "table_imports_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": {} - }, - "table_imports_status_updated_idx": { - "name": "table_imports_status_updated_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_imports_table_idx": { - "name": "table_imports_table_idx", - "columns": [ - { - "expression": "table_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "table_imports_workspace_id_workspace_id_fk": { - "name": "table_imports_workspace_id_workspace_id_fk", - "tableFrom": "table_imports", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "table_imports_user_id_user_id_fk": { - "name": "table_imports_user_id_user_id_fk", - "tableFrom": "table_imports", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "table_imports_upload_session_id_upload_sessions_id_fk": { - "name": "table_imports_upload_session_id_upload_sessions_id_fk", - "tableFrom": "table_imports", - "tableTo": "upload_sessions", - "columnsFrom": ["upload_session_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "table_imports_source_file_id_workspace_files_id_fk": { - "name": "table_imports_source_file_id_workspace_files_id_fk", - "tableFrom": "table_imports", - "tableTo": "workspace_files", - "columnsFrom": ["source_file_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "table_imports_table_id_user_table_definitions_id_fk": { - "name": "table_imports_table_id_user_table_definitions_id_fk", - "tableFrom": "table_imports", - "tableTo": "user_table_definitions", - "columnsFrom": ["table_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "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.upload_sessions": { - "name": "upload_sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_context": { - "name": "storage_context", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_provider": { - "name": "storage_provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_upload_id": { - "name": "provider_upload_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "file_size": { - "name": "file_size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "part_size": { - "name": "part_size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "part_count": { - "name": "part_count", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'uploading'" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "completed_file_id": { - "name": "completed_file_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "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()" - }, - "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": { - "upload_sessions_workspace_created_idx": { - "name": "upload_sessions_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": {} - }, - "upload_sessions_status_expiry_idx": { - "name": "upload_sessions_status_expiry_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "upload_sessions_workspace_id_workspace_id_fk": { - "name": "upload_sessions_workspace_id_workspace_id_fk", - "tableFrom": "upload_sessions", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "upload_sessions_user_id_user_id_fk": { - "name": "upload_sessions_user_id_user_id_fk", - "tableFrom": "upload_sessions", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "upload_sessions_completed_file_id_workspace_files_id_fk": { - "name": "upload_sessions_completed_file_id_workspace_files_id_fk", - "tableFrom": "upload_sessions", - "tableTo": "workspace_files", - "columnsFrom": ["completed_file_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "upload_sessions_storage_key_unique": { - "name": "upload_sessions_storage_key_unique", - "nullsNotDistinct": false, - "columns": ["storage_key"] - } - }, - "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 893bdfa59f5..43444b15f85 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1958,15 +1958,8 @@ { "idx": 280, "version": "7", - "when": 1785790027131, - "tag": "0280_first_korath", - "breakpoints": true - }, - { - "idx": 281, - "version": "7", - "when": 1785790842256, - "tag": "0281_fancy_blue_shield", + "when": 1785800321549, + "tag": "0280_smart_la_nuit", "breakpoints": true } ] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 921cbae411b..75dc5b04d60 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3971,99 +3971,6 @@ export const tableViews = pgTable( }) ) -/** - * Durable control-plane state for direct multipart uploads. The row exists before any bytes are - * accepted, which lets completion register storage atomically and lets the janitor abort uploads - * whose clients disappear. Provider ids and storage keys never cross the public API boundary. - */ -export const uploadSessions = pgTable( - 'upload_sessions', - { - id: text('id').primaryKey(), - workspaceId: text('workspace_id') - .notNull() - .references(() => workspace.id, { onDelete: 'cascade' }), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - /** `'workspace_file'` | `'table_import'`. */ - purpose: text('purpose').notNull(), - storageContext: text('storage_context').notNull(), - storageKey: text('storage_key').notNull().unique(), - storageProvider: text('storage_provider').notNull(), - providerUploadId: text('provider_upload_id'), - fileName: text('file_name').notNull(), - contentType: text('content_type').notNull(), - fileSize: bigint('file_size', { mode: 'number' }).notNull(), - partSize: integer('part_size').notNull(), - partCount: integer('part_count').notNull(), - /** `'uploading'` → `'finalizing'` → `'completed'` | `'failed'` | `'aborted'` | `'expired'`. */ - status: text('status').notNull().default('uploading'), - metadata: jsonb('metadata').notNull().default({}), - completedFileId: text('completed_file_id').references(() => workspaceFiles.id, { - onDelete: 'set null', - }), - error: text('error'), - expiresAt: timestamp('expires_at').notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - completedAt: timestamp('completed_at'), - }, - (table) => ({ - workspaceCreatedIdx: index('upload_sessions_workspace_created_idx').on( - table.workspaceId, - table.createdAt - ), - statusExpiryIdx: index('upload_sessions_status_expiry_idx').on(table.status, table.expiresAt), - }) -) - -/** - * Public table-import resource. Upload-backed imports share their id with an upload session; once - * processing begins the same id is also used by `table_jobs`, so clients never translate ids. - */ -export const tableImports = pgTable( - 'table_imports', - { - id: text('id').primaryKey(), - workspaceId: text('workspace_id') - .notNull() - .references(() => workspace.id, { onDelete: 'cascade' }), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - uploadSessionId: text('upload_session_id').references(() => uploadSessions.id, { - onDelete: 'set null', - }), - sourceFileId: text('source_file_id').references(() => workspaceFiles.id, { - onDelete: 'set null', - }), - /** `'upload'` | `'workspace_file'`. */ - sourceType: text('source_type').notNull(), - /** `'new'` | `'existing'`. */ - targetType: text('target_type').notNull(), - tableId: text('table_id').references(() => userTableDefinitions.id, { onDelete: 'set null' }), - source: jsonb('source').notNull(), - target: jsonb('target').notNull(), - options: jsonb('options').notNull().default({}), - /** Internal lifecycle, including `preparing` between upload completion and job dispatch. */ - status: text('status').notNull(), - rowsProcessed: integer('rows_processed').notNull().default(0), - error: text('error'), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - completedAt: timestamp('completed_at'), - }, - (table) => ({ - workspaceCreatedIdx: index('table_imports_workspace_created_idx').on( - table.workspaceId, - table.createdAt - ), - statusUpdatedIdx: index('table_imports_status_updated_idx').on(table.status, table.updatedAt), - tableIdx: index('table_imports_table_idx').on(table.tableId), - }) -) - /** * Background data-mutation jobs on a user table (CSV import, bulk filtered delete). One row per * job. A detached worker streams progress into `rows_processed` and flips `status` to a terminal From 739bd6d83fc70aedbdf7ac6c2f25773816bfbad4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 17:34:59 -0700 Subject: [PATCH 13/13] fix(api): make table import completion retries idempotent --- .../imports/[importId]/complete/route.ts | 7 ++ .../imports/[importId]/complete/route.test.ts | 115 ++++++++++++++++++ .../imports/[importId]/complete/route.ts | 7 ++ .../table/orchestration/import-resource.ts | 2 +- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 5482fac5e05..4ca1b13974c 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -4,6 +4,7 @@ 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, @@ -29,6 +30,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor 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, 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 index 8ff45ebebd4..4e44d28dafe 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -5,6 +5,7 @@ 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, @@ -49,6 +50,12 @@ export const POST = withRouteHandler( 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, diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 9fb5970e0ad..4e0902dc9db 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -161,7 +161,7 @@ export async function getOwnedTableImport(params: { return record } -async function findOwnedTableImport(params: { +export async function findOwnedTableImport(params: { importId: string workspaceId: string userId: string