From 90f3eed349882545dad08d08a942e62ac3941b51 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 17:10:39 -0700 Subject: [PATCH 01/22] chore(deps): bump nodemailer to 10.0.10 and patch vulnerable undici copies (#8393) --- apps/sim/lib/internal/smtp/client.ts | 6 ++-- apps/sim/lib/internal/smtp/operations.ts | 4 +-- apps/sim/lib/messaging/email/providers/ses.ts | 3 +- apps/sim/package.json | 5 ++- bun.lock | 33 +++++++------------ package.json | 2 +- 6 files changed, 20 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/internal/smtp/client.ts b/apps/sim/lib/internal/smtp/client.ts index e2ea25c3007..286f2239f4a 100644 --- a/apps/sim/lib/internal/smtp/client.ts +++ b/apps/sim/lib/internal/smtp/client.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import nodemailer from 'nodemailer' +import nodemailer, { type SendMailOptions, type Transporter } from 'nodemailer' const logger = createLogger('SmtpClient') @@ -12,7 +12,7 @@ export interface SmtpClientConfig { tls: { rejectUnauthorized: boolean; servername: string } } -function closeTransporter(transporter: nodemailer.Transporter): void { +function closeTransporter(transporter: Transporter): void { try { transporter.close() } catch (error) { @@ -22,7 +22,7 @@ function closeTransporter(transporter: nodemailer.Transporter): void { export async function sendSmtpMessage( config: SmtpClientConfig, - message: nodemailer.SendMailOptions, + message: SendMailOptions, signal?: AbortSignal ): Promise<{ messageId?: string }> { signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/smtp/operations.ts b/apps/sim/lib/internal/smtp/operations.ts index 54eabe86286..aae8ebd4556 100644 --- a/apps/sim/lib/internal/smtp/operations.ts +++ b/apps/sim/lib/internal/smtp/operations.ts @@ -1,5 +1,5 @@ import { toError } from '@sim/utils/errors' -import type nodemailer from 'nodemailer' +import type { SendMailOptions } from 'nodemailer' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { MailAttachmentMaterializationError, @@ -80,7 +80,7 @@ export async function executeSmtpSend(input: SmtpSendInput, context: SmtpOperati const from = input.fromName ? `"${input.fromName}" <${input.from}>` : input.from const contentType = input.contentType || 'text' - const message: nodemailer.SendMailOptions = { + const message: SendMailOptions = { from, to: input.to, subject: input.subject, diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index a5e9b7795a5..cf2eb044003 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -16,8 +16,7 @@ export function createSesProvider(): MailProvider | null { const sesClient = new SESv2Client({ region }) const sesOptions: SESTransport.Options = { - // double-cast-allowed: @types/nodemailer bundles a nested @aws-sdk/client-sesv2 whose nominal class types do not unify with the top-level install - SES: { sesClient, SendEmailCommand } as unknown as SESTransport.Options['SES'], + SES: { sesClient, SendEmailCommand }, } const transporter = nodemailer.createTransport(sesOptions) diff --git a/apps/sim/package.json b/apps/sim/package.json index edb48329506..3385c63efa3 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -215,7 +215,7 @@ "next": "16.3.4", "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", - "nodemailer": "9.1.1", + "nodemailer": "10.0.10", "nuqs": "2.8.9", "officeparser": "5.2.2", "openai": "7.0.0", @@ -251,7 +251,7 @@ "tldts": "7.0.30", "twilio": "5.9.0", "typebox": "1.1.38", - "undici": "7.29.0", + "undici": "7.29.1", "unified": "11.0.5", "word-extractor": "1.0.4", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -278,7 +278,6 @@ "@types/micromatch": "4.0.10", "@types/mssql": "12.3.0", "@types/node": "24.2.1", - "@types/nodemailer": "8.0.1", "@types/opentype.js": "1.3.10", "@types/prismjs": "^1.26.5", "@types/react": "^19", diff --git a/bun.lock b/bun.lock index 317ec696a7b..d8a2f9177c7 100644 --- a/bun.lock +++ b/bun.lock @@ -338,7 +338,7 @@ "next": "16.3.4", "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", - "nodemailer": "9.1.1", + "nodemailer": "10.0.10", "nuqs": "2.8.9", "officeparser": "5.2.2", "openai": "7.0.0", @@ -374,7 +374,7 @@ "tldts": "7.0.30", "twilio": "5.9.0", "typebox": "1.1.38", - "undici": "7.29.0", + "undici": "7.29.1", "unified": "11.0.5", "word-extractor": "1.0.4", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -401,7 +401,6 @@ "@types/micromatch": "4.0.10", "@types/mssql": "12.3.0", "@types/node": "24.2.1", - "@types/nodemailer": "8.0.1", "@types/opentype.js": "1.3.10", "@types/prismjs": "^1.26.5", "@types/react": "^19", @@ -830,7 +829,7 @@ "deepmerge-ts": "8.0.0", "dompurify": "3.4.13", "drizzle-orm": "^0.45.2", - "e2b": "2.45.0", + "e2b": "2.50.0", "engine.io": "6.6.8", "fast-uri": "3.1.5", "hono": "4.12.34", @@ -1152,7 +1151,7 @@ "@browserbasehq/stagehand": ["@browserbasehq/stagehand@3.5.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.10.0", "@google/genai": "^1.22.0", "@modelcontextprotocol/sdk": "^1.29.0", "ai": "^5.0.133", "devtools-protocol": "^0.0.1464554", "fetch-cookie": "^3.1.0", "openai": "^4.104.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "uuid": "^11.1.1", "ws": "^8.21.0", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.73", "@ai-sdk/anthropic": "^2.0.34", "@ai-sdk/azure": "^2.0.54", "@ai-sdk/cerebras": "^1.0.25", "@ai-sdk/deepseek": "^1.0.23", "@ai-sdk/google": "^2.0.53", "@ai-sdk/google-vertex": "^3.0.70", "@ai-sdk/groq": "^2.0.24", "@ai-sdk/mistral": "^2.0.19", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/perplexity": "^2.0.13", "@ai-sdk/togetherai": "^1.0.23", "@ai-sdk/xai": "^2.0.26", "bufferutil": "^4.0.9", "chrome-launcher": "^1.2.0", "ollama-ai-provider-v2": "^1.5.0" }, "peerDependencies": { "patchright-core": "^1.55.2", "playwright-core": "^1.55.1", "puppeteer-core": "^24.43.0", "zod": "^3.25.76 || ^4.2.0" }, "optionalPeers": ["patchright-core", "playwright-core", "puppeteer-core"] }, "sha512-nmKmi37keeZ8naUTJ4l6nfR8sTCDc5gUIn+fKSzMH8Xy4IW/sFNaA14Gl3OQbUWpthr977yGCBj556WSWONg0w=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.15.0", "", {}, "sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA=="], "@bugsnag/cuid": ["@bugsnag/cuid@3.2.2", "", {}, "sha512-7onuYLTMqMmHE9BBPG0YER4nFsU1rB+me1/YIeMusqcLbVbKKuG9u9+BDVDpje5e0llkkrVNOKYwmzM9DRIo7A=="], @@ -1184,9 +1183,9 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@connectrpc/connect": ["@connectrpc/connect@2.1.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA=="], + "@connectrpc/connect": ["@connectrpc/connect@2.2.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ=="], - "@connectrpc/connect-web": ["@connectrpc/connect-web@2.1.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.2" } }, "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg=="], + "@connectrpc/connect-web": ["@connectrpc/connect-web@2.2.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.2.0" } }, "sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -2294,8 +2293,6 @@ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], - "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], @@ -2964,7 +2961,7 @@ "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], - "e2b": ["e2b@2.45.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@connectrpc/connect": "^2.1.2", "@connectrpc/connect-web": "^2.1.2", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.19", "undici": "^7.29.0" }, "optionalDependencies": { "undici8": "npm:undici@8.10.0" } }, "sha512-9DjhV+4xeS8/N7fQf6xz0y1apUS3CN+kOwAADzPlUEGGcgC2g5sPLExHh+GA2V1LcZZIOs387PN1DFpgAGGOhw=="], + "e2b": ["e2b@2.50.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.14.1", "@connectrpc/connect": "^2.2.0", "@connectrpc/connect-web": "^2.2.0", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.22", "undici": "^7.29.1" }, "optionalDependencies": { "undici8": "npm:undici@8.10.2" } }, "sha512-XADlFhXGMvvWGPnyBiah6EFwNFBg1XIvdLInKhjMq/tuhlpN46Btj9BpuxwjvGenVJNZgimzn6Xj0XwttrMKjQ=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -3856,7 +3853,7 @@ "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], - "nodemailer": ["nodemailer@9.1.1", "", {}, "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ=="], + "nodemailer": ["nodemailer@10.0.10", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="], "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], @@ -4578,11 +4575,11 @@ "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - "undici8": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="], + "undici8": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], @@ -5074,8 +5071,6 @@ "@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/readable-stream/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/readdir-glob/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5164,8 +5159,6 @@ "c12/jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], - "c12/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "cacheable-request/mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], @@ -5370,7 +5363,7 @@ "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "node-gyp/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -5710,8 +5703,6 @@ "@types/node-fetch/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/readable-stream/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/readdir-glob/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5752,8 +5743,6 @@ "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "c12/magicast/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/package.json b/package.json index 1a2ce8a6421..d9a38d50b25 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "marked": "18.0.11", "mermaid": "11.16.1", "zod": "4.3.6", - "e2b": "2.45.0", + "e2b": "2.50.0", "@hono/node-server": "1.19.15", "brace-expansion": "5.0.9", "deepmerge-ts": "8.0.0", From 07c3ffe7dbc2993e032b0064b3b756148d08a70a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 18:17:03 -0700 Subject: [PATCH 02/22] refactor: remove dead code and enforce code quality gates (#8395) * refactor: remove dead code and enforce code quality gates * fix(ci): bind script coverage to the executed runner --- .github/workflows/test-build.yml | 2 +- .../copilot/components/user-input/types.ts | 11 - .../components/credential-group-access.tsx | 290 ------- .../credential-group-add-resource-modal.tsx | 110 --- .../components/credential-group-detail.tsx | 371 --------- .../components/credential-group-details.tsx | 414 ---------- ...redential-group-enrollment-connections.tsx | 50 -- .../credential-group-invite-modal.tsx | 122 --- .../hooks/queries/credential-groups.test.tsx | 51 +- apps/sim/hooks/queries/credential-groups.ts | 329 +------- .../queries/utils/credential-group-queries.ts | 9 - apps/sim/hooks/use-member-access.ts | 16 - apps/sim/lib/api/contracts/tools/index.ts | 1 - .../lib/api/contracts/tools/media/image.ts | 4 - .../lib/api/contracts/tools/media/index.ts | 1 - apps/sim/lib/api/contracts/tools/shared.ts | 10 - apps/sim/lib/api/contracts/v1/copilot.ts | 15 - apps/sim/lib/api/contracts/v1/index.ts | 1 - .../application/list-people.ts | 89 --- .../application/operations.ts | 9 - .../sim/lib/internal/cloudwatch/http-route.ts | 54 -- apps/sim/lib/logs/service.ts | 31 - apps/sim/lib/logs/stats-logs.ts | 207 ----- apps/sim/lib/mermaid/validate.ts | 22 - .../execute-managed-mcp-use-case.ts | 15 - .../tools/server/files/doc-recalc.ts | 126 --- apps/sim/lib/permission-groups/queries.ts | 110 --- apps/sim/lib/resources/orchestration/index.ts | 5 - .../orchestration/restore-resource.ts | 217 ------ .../lib/workflows/application/operations.ts | 8 - .../read-workflow-state-references.ts | 72 -- .../workspace-files/application/operations.ts | 24 - .../application/workspace-file-vfs.ts | 487 ------------ .../workspace-files/orchestration/index.ts | 1 - .../workspace-files/orchestration/share.ts | 3 - apps/sim/package.json | 8 +- apps/sim/providers/azure-openai/index.test.ts | 2 - apps/sim/providers/azure-openai/index.ts | 28 +- apps/sim/providers/azure-openai/utils.ts | 49 -- apps/sim/providers/baseten/index.test.ts | 31 +- apps/sim/providers/baseten/index.ts | 711 +---------------- apps/sim/providers/baseten/utils.ts | 51 -- apps/sim/providers/cerebras/index.ts | 12 +- apps/sim/providers/cerebras/utils.ts | 35 - .../conversation-generation-coverage.test.ts | 3 +- apps/sim/providers/deepseek/index.test.ts | 4 - apps/sim/providers/deepseek/index.ts | 57 +- apps/sim/providers/deepseek/utils.ts | 23 - apps/sim/providers/fireworks/index.test.ts | 27 +- apps/sim/providers/fireworks/index.ts | 719 +----------------- apps/sim/providers/fireworks/utils.ts | 52 -- apps/sim/providers/groq/index.test.ts | 4 - apps/sim/providers/groq/index.ts | 57 +- apps/sim/providers/groq/utils.ts | 23 - apps/sim/providers/kimi/index.ts | 47 +- apps/sim/providers/kimi/utils.ts | 23 - apps/sim/providers/litellm/index.test.ts | 7 +- apps/sim/providers/litellm/index.ts | 12 +- apps/sim/providers/litellm/utils.ts | 23 - apps/sim/providers/meta/index.ts | 47 +- apps/sim/providers/meta/utils.ts | 23 - apps/sim/providers/mistral/index.test.ts | 4 +- apps/sim/providers/mistral/index.ts | 12 +- apps/sim/providers/mistral/utils.ts | 23 - apps/sim/providers/nvidia/index.ts | 47 +- apps/sim/providers/nvidia/utils.ts | 23 - apps/sim/providers/ollama-cloud/index.test.ts | 21 +- apps/sim/providers/ollama-cloud/index.ts | 2 - apps/sim/providers/ollama-cloud/utils.ts | 23 - apps/sim/providers/ollama/core.ts | 23 +- apps/sim/providers/ollama/index.test.ts | 59 +- apps/sim/providers/ollama/index.ts | 2 - apps/sim/providers/ollama/utils.ts | 23 - .../openai-compat/chat-completions.ts | 717 +++++++++++++++++ apps/sim/providers/openrouter/index.test.ts | 21 +- apps/sim/providers/openrouter/index.ts | 708 +---------------- apps/sim/providers/openrouter/utils.ts | 35 - apps/sim/providers/sakana/index.ts | 47 +- apps/sim/providers/sakana/utils.ts | 23 - .../providers/settled-tool-streams.test.ts | 61 +- apps/sim/providers/together/index.test.ts | 38 +- apps/sim/providers/together/index.ts | 706 +---------------- apps/sim/providers/together/utils.ts | 51 -- apps/sim/providers/vllm/index.test.ts | 13 +- apps/sim/providers/vllm/index.ts | 19 +- apps/sim/providers/vllm/utils.ts | 37 - apps/sim/providers/xai/index.ts | 24 +- apps/sim/providers/xai/utils.ts | 38 - apps/sim/providers/zai/index.ts | 47 +- apps/sim/providers/zai/utils.ts | 23 - bun.lock | 406 +++++++++- knip.jsonc | 94 +++ package.json | 19 +- scripts/check-monorepo-boundaries.test.ts | 69 ++ scripts/check-monorepo-boundaries.ts | 76 +- scripts/check-openapi.ts | 2 - scripts/check-script-test-coverage.test.ts | 97 +++ scripts/check-script-test-coverage.ts | 35 +- scripts/openapi/documents.test.ts | 2 +- scripts/openapi/vitest.config.ts | 26 - scripts/vitest.config.ts | 13 +- turbo.json | 4 + 102 files changed, 1886 insertions(+), 7192 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-access.tsx delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-detail.tsx delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-details.tsx delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx delete mode 100644 apps/sim/hooks/use-member-access.ts delete mode 100644 apps/sim/lib/api/contracts/tools/media/image.ts delete mode 100644 apps/sim/lib/api/contracts/tools/shared.ts delete mode 100644 apps/sim/lib/api/contracts/v1/copilot.ts delete mode 100644 apps/sim/lib/credential-groups/application/list-people.ts delete mode 100644 apps/sim/lib/internal/cloudwatch/http-route.ts delete mode 100644 apps/sim/lib/logs/service.ts delete mode 100644 apps/sim/lib/logs/stats-logs.ts delete mode 100644 apps/sim/lib/mermaid/validate.ts delete mode 100644 apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts delete mode 100644 apps/sim/lib/mothership/tools/server/files/doc-recalc.ts delete mode 100644 apps/sim/lib/permission-groups/queries.ts delete mode 100644 apps/sim/lib/resources/orchestration/index.ts delete mode 100644 apps/sim/lib/resources/orchestration/restore-resource.ts delete mode 100644 apps/sim/lib/workflows/application/read-workflow-state-references.ts delete mode 100644 apps/sim/lib/workspace-files/application/workspace-file-vfs.ts delete mode 100644 apps/sim/lib/workspace-files/orchestration/share.ts delete mode 100644 apps/sim/providers/baseten/utils.ts delete mode 100644 apps/sim/providers/cerebras/utils.ts delete mode 100644 apps/sim/providers/deepseek/utils.ts delete mode 100644 apps/sim/providers/groq/utils.ts delete mode 100644 apps/sim/providers/kimi/utils.ts delete mode 100644 apps/sim/providers/litellm/utils.ts delete mode 100644 apps/sim/providers/meta/utils.ts delete mode 100644 apps/sim/providers/mistral/utils.ts delete mode 100644 apps/sim/providers/nvidia/utils.ts delete mode 100644 apps/sim/providers/ollama-cloud/utils.ts delete mode 100644 apps/sim/providers/ollama/utils.ts create mode 100644 apps/sim/providers/openai-compat/chat-completions.ts delete mode 100644 apps/sim/providers/sakana/utils.ts delete mode 100644 apps/sim/providers/together/utils.ts delete mode 100644 apps/sim/providers/vllm/utils.ts delete mode 100644 apps/sim/providers/zai/utils.ts create mode 100644 knip.jsonc create mode 100644 scripts/check-monorepo-boundaries.test.ts create mode 100644 scripts/check-script-test-coverage.test.ts delete mode 100644 scripts/openapi/vitest.config.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 4c816306b99..efb2effe479 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -377,7 +377,7 @@ jobs: - name: Install ripgrep run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) - # Runs the setup CLI's Bun tests plus each workspace's Vitest suite, + # Runs the root scripts and each workspace's Vitest suite, # without `--coverage`. - name: Run tests env: diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts deleted file mode 100644 index 5b1110c04b9..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MentionFolderId } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' - -/** - * Shared folder navigation state for the mention menu. - */ -export interface MentionFolderNav { - currentFolder: MentionFolderId | null - isInFolder: boolean - openFolder: (folderId: MentionFolderId, title: string) => void - closeFolder: () => void -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-access.tsx b/apps/sim/ee/credential-groups/components/credential-group-access.tsx deleted file mode 100644 index cf524f5e196..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-access.tsx +++ /dev/null @@ -1,290 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Chip, toast } from '@sim/emcn' -import { Workflow } from '@sim/emcn/icons' -import { getErrorMessage } from '@sim/utils/errors' -import type { CredentialGroupAccessResponse } from '@/lib/api/contracts/credential-groups' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' -import { - useCredentialGroupAccess, - useUpdateCredentialGroupAccess, -} from '@/hooks/queries/credential-groups' -import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' - -interface AccessDraft { - allowedWorkflowIds: string[] - baseline: string - expectedRevision: number - groupId: string -} - -interface UseCredentialGroupAccessEditorProps { - workspaceId: string - groupId: string - enabled: boolean -} - -function normalizeAllowedWorkflowIds(workflowIds: readonly string[]): string[] { - for (const workflowId of workflowIds) { - if (!workflowId || workflowId !== workflowId.trim()) { - throw new Error('Credential Group workflow access requires canonical non-empty workflow IDs') - } - } - if (new Set(workflowIds).size !== workflowIds.length) { - throw new Error('Credential Group workflow access contains duplicate workflows') - } - if (workflowIds.length > CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT) { - throw new Error( - `Credential Group workflow access cannot exceed ${CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT} workflows` - ) - } - return [...workflowIds].sort() -} - -function serializeAllowedWorkflowIds(workflowIds: readonly string[]): string { - return JSON.stringify(normalizeAllowedWorkflowIds(workflowIds)) -} - -export function useCredentialGroupAccessEditor({ - workspaceId, - groupId, - enabled, -}: UseCredentialGroupAccessEditorProps) { - const access = useCredentialGroupAccess(workspaceId, groupId, { enabled }) - const updateAccess = useUpdateCredentialGroupAccess() - const setSettingsNavigationBlocked = useSettingsDirtyStore((state) => state.setNavigationBlocked) - const [draft, setDraft] = useState(null) - - if (draft && draft.groupId !== groupId) { - throw new Error('Credential Group access draft cannot move between resources') - } - - const persistedAllowedWorkflowIds = access.data - ? normalizeAllowedWorkflowIds(access.data.allowedWorkflowIds) - : null - const persistedValue = persistedAllowedWorkflowIds - ? serializeAllowedWorkflowIds(persistedAllowedWorkflowIds) - : '' - const allowedWorkflowIds = draft?.allowedWorkflowIds ?? persistedAllowedWorkflowIds - const revision = draft?.expectedRevision ?? access.data?.revision ?? null - const dirty = draft !== null - const workflowIds = new Set(access.data?.workflows.map((workflow) => workflow.id) ?? []) - const selectionsAvailable = Boolean( - allowedWorkflowIds?.every((workflowId) => workflowIds.has(workflowId)) - ) - - const setAllowedWorkflowIds = (nextWorkflowIds: readonly string[], expectedRevision: number) => { - if (!access.data) throw new Error('Credential Group workflow access is unavailable') - const currentRevision = draft?.expectedRevision ?? access.data.revision - if (expectedRevision !== currentRevision) { - throw new Error('Credential Group workflow access changed while it was being edited') - } - const normalizedWorkflowIds = normalizeAllowedWorkflowIds(nextWorkflowIds) - const nextValue = serializeAllowedWorkflowIds(normalizedWorkflowIds) - updateAccess.reset() - setDraft((current) => { - const baseline = current?.baseline ?? persistedValue - if (nextValue === baseline) return null - return { - allowedWorkflowIds: normalizedWorkflowIds, - baseline, - expectedRevision: current?.expectedRevision ?? access.data.revision, - groupId, - } - }) - } - - const discard = () => { - setDraft(null) - updateAccess.reset() - } - - const save = async () => { - if (!draft) return - setSettingsNavigationBlocked(true) - try { - const availableWorkflowIds = new Set(access.data?.workflows.map((workflow) => workflow.id)) - if (draft.allowedWorkflowIds.some((workflowId) => !availableWorkflowIds.has(workflowId))) { - throw new Error('Remove unavailable workflows before saving access') - } - await updateAccess.mutateAsync({ - workspaceId, - groupId, - body: { - expectedRevision: draft.expectedRevision, - allowedWorkflowIds: draft.allowedWorkflowIds, - }, - }) - setDraft(null) - toast.success('Workflow access saved') - } catch (error) { - toast.error(getErrorMessage(error, 'Could not update workflow access')) - } finally { - setSettingsNavigationBlocked(false) - } - } - - return { - allowedWorkflowIds, - revision, - workflows: access.data?.workflows ?? null, - setAllowedWorkflowIds, - discard, - save, - dirty, - error: updateAccess.error - ? getErrorMessage(updateAccess.error, 'Could not update workflow access') - : null, - isPending: access.isPending && !access.data, - loadError: access.data ? null : access.error, - isReady: Boolean(access.data && selectionsAvailable), - saving: updateAccess.isPending, - } -} - -interface CredentialGroupAccessProps { - allowedWorkflowIds: readonly string[] | null - revision: number | null - workflows: CredentialGroupAccessResponse['workflows'] | null - onAllowedWorkflowIdsChange: (workflowIds: readonly string[], expectedRevision: number) => void - error: string | null - isPending: boolean - loadError: unknown - saving: boolean -} - -export function CredentialGroupAccess({ - allowedWorkflowIds, - revision, - workflows, - onAllowedWorkflowIdsChange, - error, - isPending, - loadError, - saving, -}: CredentialGroupAccessProps) { - const [showAddWorkflow, setShowAddWorkflow] = useState(false) - - if (loadError) { - return ( - - {getErrorMessage(loadError, "Couldn't load workflow access")} - - ) - } - if (isPending) return null - if (!workflows) throw new Error('Credential Group workflow catalog is unavailable') - if (!allowedWorkflowIds) throw new Error('Credential Group workflow access is unavailable') - if (revision === null) throw new Error('Credential Group access revision is unavailable') - - const allowedWorkflowIdSet = new Set(allowedWorkflowIds) - if (allowedWorkflowIdSet.size !== allowedWorkflowIds.length) { - throw new Error('Credential Group workflow access contains duplicate workflows') - } - const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow])) - for (const workflowId of allowedWorkflowIds) { - if (!workflowsById.has(workflowId)) { - throw new Error( - `Credential Group workflow access references unavailable workflow ${workflowId}` - ) - } - } - const allowedWorkflows = workflows.filter((workflow) => allowedWorkflowIdSet.has(workflow.id)) - const availableWorkflows = workflows.filter((workflow) => !allowedWorkflowIdSet.has(workflow.id)) - - const addWorkflow = (workflowId: string) => { - if (!workflowsById.has(workflowId)) throw new Error(`Workflow ${workflowId} is unavailable`) - if (allowedWorkflowIdSet.has(workflowId)) { - throw new Error(`Workflow ${workflowId} already has Credential Group access`) - } - onAllowedWorkflowIdsChange([...allowedWorkflowIds, workflowId], revision) - } - - const removeWorkflow = (workflowId: string) => { - if (!allowedWorkflowIdSet.has(workflowId)) { - throw new Error(`Workflow ${workflowId} does not have Credential Group access`) - } - onAllowedWorkflowIdsChange( - allowedWorkflowIds.filter((allowedWorkflowId) => allowedWorkflowId !== workflowId), - revision - ) - } - - const sectionAction = ( - setShowAddWorkflow(true)} - disabled={ - saving || - availableWorkflows.length === 0 || - allowedWorkflowIds.length >= CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT - } - > - Add workflow - - ) - - return ( - <> - - {error && ( -

- {error} -

- )} - - {allowedWorkflows.length === 0 ? ( - No workflows have access - ) : ( -
- {allowedWorkflows.map((workflow) => ( - } - iconFilled - title={workflow.name} - description='Deployed runs can use every credential in this group' - disabled={saving} - trailing={ - saving ? undefined : ( - removeWorkflow(workflow.id), - }, - ]} - /> - ) - } - /> - ))} -
- )} -
- - {showAddWorkflow && ( - { - addWorkflow(id) - setShowAddWorkflow(false) - }} - onClose={() => setShowAddWorkflow(false)} - /> - )} - - ) -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx deleted file mode 100644 index e19d8efba7b..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - ChipDropdown, - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - ChipSelect, -} from '@sim/emcn' - -interface CredentialGroupAddResourceModalBaseProps { - resources: readonly { id: string; name: string }[] - disabled: boolean - error?: string - onClose: () => void -} - -type CredentialGroupAddResourceModalProps = CredentialGroupAddResourceModalBaseProps & - ( - | { resourceType: 'workflow'; onAdd: (resourceId: string) => void } - | { resourceType: 'workspace'; onAdd: (resourceIds: string[]) => void } - ) - -export function CredentialGroupAddResourceModal(props: CredentialGroupAddResourceModalProps) { - const { resources, resourceType, disabled, error, onClose } = props - const [selectedResourceIds, setSelectedResourceIds] = useState([]) - const label = resourceType === 'workspace' ? 'Workspaces' : 'Workflow' - const title = resourceType === 'workspace' ? 'Add workspaces' : 'Add workflow' - const options = resources.map((resource) => ({ value: resource.id, label: resource.name })) - - const handleAdd = () => { - if (!selectedResourceIds.length) - throw new Error(`Select a ${resourceType} before granting access`) - if (new Set(selectedResourceIds).size !== selectedResourceIds.length) { - throw new Error('Access selection contains duplicate resources') - } - const available = new Set(resources.map((resource) => resource.id)) - for (const id of selectedResourceIds) { - if (!available.has(id)) throw new Error(`Selected ${resourceType} ${id} is unavailable`) - } - if (props.resourceType === 'workspace') props.onAdd(selectedResourceIds) - else props.onAdd(selectedResourceIds[0]) - } - - return ( - !open && !disabled && onClose()} - srTitle={title} - size='sm' - dismissDisabled={disabled} - > - - {title} - - - - {(aria) => - resourceType === 'workspace' ? ( - - ) : ( - setSelectedResourceIds([id])} - placeholder={`Select ${resourceType}`} - searchPlaceholder={`Search ${resourceType}s`} - searchable - aria-label={label} - disabled={disabled} - fullWidth - dropdownWidth='trigger' - align='start' - {...aria} - /> - ) - } - - {error} - - - - ) -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx deleted file mode 100644 index 75bff93c737..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ /dev/null @@ -1,371 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Avatar, Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' -import { Plus } from '@sim/emcn/icons' -import { getErrorMessage } from '@sim/utils/errors' -import { useQueryState } from 'nuqs' -import { saveDiscardActions } from '@/components/settings/save-discard-actions' -import type { CredentialGroupEnrollment } from '@/lib/api/contracts/credential-groups' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' -import { - credentialGroupPeopleSearchParam, - credentialGroupPeopleSearchUrlKeys, - credentialGroupProviderSearchParam, - credentialGroupProviderSearchUrlKeys, - credentialGroupTabParam, - credentialGroupTabUrlKeys, -} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' -import { - CredentialGroupAccess, - useCredentialGroupAccessEditor, -} from '@/ee/credential-groups/components/credential-group-access' -import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' -import { EnrollmentConnections } from '@/ee/credential-groups/components/credential-group-enrollment-connections' -import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' -import { - useCredentialGroupDetail, - useDeleteCredentialGroupEnrollment, - useResendCredentialGroupEnrollment, - useUpdateCredentialGroup, -} from '@/hooks/queries/credential-groups' -import { useWorkspaceCredentials } from '@/hooks/queries/credentials' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' - -interface CredentialGroupDetailProps { - workspaceId: string - groupId: string -} - -type CredentialGroupTab = 'details' | 'people' | 'access' - -const CREDENTIAL_GROUP_TABS = [ - { value: 'details', label: 'Accounts' }, - { value: 'people', label: 'People' }, - { value: 'access', label: 'Workflow access' }, -] as const - -export function CredentialGroupDetail({ workspaceId, groupId }: CredentialGroupDetailProps) { - const detail = useCredentialGroupDetail(workspaceId, groupId) - const slackBots = useWorkspaceCredentials({ - workspaceId, - type: 'service_account', - providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, - }) - const resend = useResendCredentialGroupEnrollment() - const deleteEnrollment = useDeleteCredentialGroupEnrollment() - const updateGroup = useUpdateCredentialGroup() - const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { - ...credentialGroupTabParam.parser, - ...credentialGroupTabUrlKeys, - }) - const accessEditor = useCredentialGroupAccessEditor({ - workspaceId, - groupId, - enabled: activeTab === 'access', - }) - const [providerSearch, setProviderSearchParam] = useQueryState( - credentialGroupProviderSearchParam.key, - { ...credentialGroupProviderSearchParam.parser, ...credentialGroupProviderSearchUrlKeys } - ) - const setProviderSearch = useDebouncedSearchSetter(setProviderSearchParam) - const [peopleSearch, setPeopleSearchParam] = useQueryState(credentialGroupPeopleSearchParam.key, { - ...credentialGroupPeopleSearchParam.parser, - ...credentialGroupPeopleSearchUrlKeys, - }) - const setPeopleSearch = useDebouncedSearchSetter(setPeopleSearchParam) - const [showInvite, setShowInvite] = useState(false) - const [deletingEnrollmentId, setDeletingEnrollmentId] = useState(null) - const credentialGroup = detail.data?.pages[0]?.credentialGroup - const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] - const peopleFilter = peopleSearch.trim().toLowerCase() - /** - * Only the pages already loaded: the enrollment list is cursor-paginated with no - * server-side term, so a match on a later page appears only once it is fetched. - */ - const visibleEnrollments = peopleFilter - ? enrollments.filter((enrollment) => enrollment.email.toLowerCase().includes(peopleFilter)) - : enrollments - /** - * `+` means more people exist than are loaded, so it stays on the total. While - * filtering, the match count is reported against that total rather than replacing - * it — otherwise `People (2+)` reads as a two-person group with more to come. - */ - const loadedTotal = `${enrollments.length}${detail.hasNextPage ? '+' : ''}` - const peopleLabel = peopleFilter - ? `People (${visibleEnrollments.length} of ${loadedTotal})` - : `People (${loadedTotal})` - const deletingEnrollment = deletingEnrollmentId - ? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null) - : null - const configurationReady = - Boolean( - credentialGroup && - (credentialGroup.options.length || - credentialGroup.mcpServers.some( - (server) => server.enabled && server.authType === 'oauth' - )) - ) && - credentialGroup?.options.every( - (option) => - option.provider !== 'slack' || - (option.configurationStatus === 'ready' && - slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) - ) - - const guard = useSettingsUnsavedGuard({ - isDirty: accessEditor.dirty, - navigationBlocked: updateGroup.isPending || accessEditor.saving, - }) - const credentialGroupMutationPending = - updateGroup.isPending || accessEditor.saving || resend.isPending || deleteEnrollment.isPending - - const handleTabChange = (value: string) => { - const nextTab = value as CredentialGroupTab - if (nextTab === activeTab) return - guard.guardBack(() => { - accessEditor.discard() - void setActiveTab(nextTab) - }) - } - - const handleEnable = async () => { - if (!credentialGroup) return - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { status: 'active' }, - }) - toast.success('Connected accounts enabled') - } catch (error) { - toast.error(getErrorMessage(error, 'Could not enable connected accounts')) - } - } - - const actions: SettingsAction[] = credentialGroup - ? [ - ...(credentialGroup.status === 'disabled' - ? [ - { - text: updateGroup.isPending ? 'Enabling...' : 'Enable accounts', - variant: 'primary' as const, - onSelect: () => void handleEnable(), - disabled: credentialGroupMutationPending, - }, - ] - : []), - ...(activeTab === 'people' - ? [ - { - text: 'Request connections', - icon: Plus, - variant: 'primary' as const, - onSelect: () => setShowInvite(true), - disabled: credentialGroup.status !== 'active' || !configurationReady, - }, - ] - : activeTab === 'access' - ? saveDiscardActions({ - dirty: accessEditor.dirty, - saving: accessEditor.saving, - onSave: () => void accessEditor.save(), - onDiscard: accessEditor.discard, - saveDisabled: !accessEditor.isReady, - saveTooltip: !accessEditor.isReady ? 'Workflow access is unavailable' : undefined, - }) - : []), - ] - : [] - - const handleResend = async (enrollment: CredentialGroupEnrollment) => { - try { - await resend.mutateAsync({ workspaceId, groupId, enrollmentId: enrollment.id }) - toast.success(`Invitation resent to ${enrollment.email}`) - } catch (error) { - toast.error(getErrorMessage(error, 'Failed to resend invitation')) - } - } - - const handleDeleteEnrollment = async () => { - if (!deletingEnrollment) return - try { - await deleteEnrollment.mutateAsync({ - workspaceId, - groupId, - enrollmentId: deletingEnrollment.id, - }) - toast.success(`${deletingEnrollment.email} deleted`) - setDeletingEnrollmentId(null) - } catch (error) { - toast.error(getErrorMessage(error, 'Failed to delete person')) - } - } - - return ( - <> - - {detail.error ? ( - - {getErrorMessage(detail.error, "Couldn't load connected accounts")} - - ) : detail.isPending || !credentialGroup ? null : ( - <> - - - {activeTab === 'details' && ( - <> - - - )} - - {activeTab === 'people' && ( - void detail.fetchNextPage()} - disabled={detail.isFetchingNextPage} - > - {detail.isFetchingNextPage ? 'Loading...' : 'Load more'} - - ) : undefined - } - > - {visibleEnrollments.length === 0 ? ( - - {peopleFilter ? 'No people match your search' : 'No people invited yet'} - - ) : ( -
- {visibleEnrollments.map((enrollment) => { - return ( - } - iconVariant='custom' - title={enrollment.email} - description={ - - } - trailing={ - void handleResend(enrollment), - disabled: resend.isPending, - }, - { - label: 'Delete', - destructive: true, - onSelect: () => setDeletingEnrollmentId(enrollment.id), - }, - ]} - /> - } - /> - ) - })} -
- )} -
- )} - - {activeTab === 'access' && ( - - )} - - )} -
- {credentialGroup && ( - - )} - - !open && !deleteEnrollment.isPending && setDeletingEnrollmentId(null) - } - srTitle='Delete person' - title='Delete person' - text={[ - `Delete ${deletingEnrollment?.email ?? 'this person'}?`, - { - text: ' Their invitation link will stop working and the accounts they connected here will be removed.', - error: true, - }, - ]} - dismissLabel='Cancel' - confirm={{ - label: deleteEnrollment.isPending ? 'Deleting...' : 'Delete', - onClick: handleDeleteEnrollment, - disabled: deleteEnrollment.isPending, - }} - /> - - - ) -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx deleted file mode 100644 index 073d0b0d6f5..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ /dev/null @@ -1,414 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipTag, toast } from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' -import type { WorkspaceCredential } from '@/lib/api/contracts' -import type { - CredentialGroup, - CredentialGroupOption, - UpdateCredentialGroupBody, -} from '@/lib/api/contracts/credential-groups' -import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' -import { - MANAGED_MCP_CONNECTOR_IDS, - MANAGED_MCP_CONNECTORS, - type ManagedMcpConnectorId, -} from '@/lib/credential-groups/managed-mcp-connectors' -import { - CREDENTIAL_GROUP_PROVIDER_IDS, - type CredentialGroupProvider, - type CredentialGroupStandardOAuthProvider, - getCredentialGroupProviderService, - getCredentialGroupProviderSupport, - isCredentialGroupStandardOAuthProvider, -} from '@/lib/credential-groups/providers' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { CredentialGroupProviderTile } from '@/ee/credential-groups/components/credential-group-provider-tile' -import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' -import { - useCreateCredentialGroupMcpConnector, - useDeleteCredentialGroupMcpConnector, - useUpdateCredentialGroup, - useWorkspaceAccounts, -} from '@/hooks/queries/credential-groups' -import { useWorkspaceCredentials } from '@/hooks/queries/credentials' - -/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */ -const EMPTY_SLACK_BOTS: WorkspaceCredential[] = [] - -interface CredentialGroupDetailsProps { - credentialGroup: CredentialGroup - workspaceId: string - /** Filters the account types offered below; owned by the panel header's search field. */ - providerSearch: string -} - -function toOptionUpdateInput( - option: CredentialGroupOption -): NonNullable[number] { - const common = { - id: option.id, - label: getCredentialGroupProviderService(option.provider).name, - required: false, - } - if (option.provider !== 'slack') return { ...common, provider: option.provider } - return { - ...common, - provider: 'slack', - slackBotCredentialId: option.slackBotCredentialId, - } -} - -export function CredentialGroupDetails({ - credentialGroup, - workspaceId, - providerSearch, -}: CredentialGroupDetailsProps) { - const updateGroup = useUpdateCredentialGroup() - const createMcpConnector = useCreateCredentialGroupMcpConnector() - const deleteMcpConnector = useDeleteCredentialGroupMcpConnector() - /** - * Reads the same cache entry the workspace settings already populated, so the deployment's configured - * providers arrive without a second request. - */ - const accounts = useWorkspaceAccounts(workspaceId) - const availableProviders = accounts.data?.availableProviders - const slackBots = useWorkspaceCredentials({ - workspaceId, - type: 'service_account', - providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, - }) - const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) - const [removingProvider, setRemovingProvider] = useState(null) - const [removingMcpConnector, setRemovingMcpConnector] = useState( - null - ) - - const isUpdating = - updateGroup.isPending || createMcpConnector.isPending || deleteMcpConnector.isPending - - const updateOptions = async ( - options: NonNullable, - successMessage: string - ) => { - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { options }, - }) - toast.success(successMessage) - return true - } catch (error) { - toast.error(getErrorMessage(error, 'Could not update connected accounts')) - return false - } - } - - const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { - const service = getCredentialGroupProviderService(provider) - const existing = credentialGroup.options.map(toOptionUpdateInput) - const nextOption: NonNullable[number] = { - provider, - label: service.name, - required: false, - } - return updateOptions([...existing, nextOption], `${service.name} added`) - } - - const openSlackSetup = (credentialId?: string) => { - setSlackSetup({ credentialId }) - } - - const handleProviderAction = (provider: CredentialGroupProvider) => { - const support = getCredentialGroupProviderSupport(provider) - if (isCredentialGroupStandardOAuthProvider(provider)) { - void addProvider(provider) - return - } - if (support.configuration === 'slack_custom_bot') { - openSlackSetup() - return - } - throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) - } - - const handleRemoveProvider = async () => { - if (!removingProvider) return - const service = getCredentialGroupProviderService(removingProvider) - const options = credentialGroup.options - .filter((option) => option.provider !== removingProvider) - .map(toOptionUpdateInput) - if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) - } - - const addMcpConnector = async (connectorId: Exclude) => { - try { - await createMcpConnector.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { connectorId }, - }) - toast.success(`${MANAGED_MCP_CONNECTORS[connectorId].name} added`) - } catch (error) { - toast.error(getErrorMessage(error, 'Could not add MCP app')) - } - } - - const handleRemoveMcpConnector = async () => { - if (!removingMcpConnector) return - const connector = MANAGED_MCP_CONNECTORS[removingMcpConnector] - try { - await deleteMcpConnector.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - connectorId: removingMcpConnector, - }) - toast.success(`${connector.name} removed`) - setRemovingMcpConnector(null) - } catch (error) { - toast.error(getErrorMessage(error, 'Could not remove MCP app')) - } - } - - /** - * A provider whose OAuth client this deployment has not configured can never finish an - * enrollment, so it is not offered — but one already on the group stays listed regardless, or - * the row that removes it would disappear along with it. - */ - const configuredProviders = new Set(credentialGroup.options.map((option) => option.provider)) - const offerableProviders = availableProviders ? new Set(availableProviders) : null - const providerQuery = providerSearch.trim().toLowerCase() - const shownProviders = CREDENTIAL_GROUP_PROVIDER_IDS.filter((provider) => { - if ( - !configuredProviders.has(provider) && - offerableProviders && - !offerableProviders.has(provider) - ) { - return false - } - if (!providerQuery) return true - return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) - }) - const shownMcpConnectors = MANAGED_MCP_CONNECTOR_IDS.filter((connectorId) => { - if ( - !credentialGroup.mcpServers.some((server) => server.managedConnectorId === connectorId) && - !accounts.data?.availableMcpConnectors.includes(connectorId) - ) - return false - if (!providerQuery) return true - const connector = MANAGED_MCP_CONNECTORS[connectorId] - return ( - connector.name.toLowerCase().includes(providerQuery) || - connector.description.toLowerCase().includes(providerQuery) - ) - }) - - return ( - <> - - {shownProviders.length === 0 ? ( - - {providerSearch.trim() - ? `No account types found matching "${providerSearch}"` - : 'No account types are available. Configure an OAuth client to offer one.'} - - ) : null} -
- {shownProviders.map((provider) => { - const service = getCredentialGroupProviderService(provider) - const support = getCredentialGroupProviderSupport(provider) - const option = credentialGroup.options.find( - (candidate) => candidate.provider === provider - ) - const ProviderIcon = service.icon - const slackBot = - provider === 'slack' && option?.provider === 'slack' - ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) - : undefined - const slackNeedsSetup = - provider === 'slack' && - option?.provider === 'slack' && - (!slackBot || option.configurationStatus !== 'ready') - const descriptionText = - provider === 'slack' && option - ? slackBot - ? `${slackBot.displayName}${slackNeedsSetup ? ' · Finish member sign-in setup' : ''}` - : slackBots.isPending - ? 'Loading custom Slack app...' - : 'Custom Slack app unavailable' - : support.description - - return ( - } - title={service.name} - description={descriptionText} - badge={ - option && !slackNeedsSetup ? Added : undefined - } - trailing={ - option ? ( -
- {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( - openSlackSetup(slackBot.id)} disabled={isUpdating}> - Continue setup - - ) : null} - - openSlackSetup( - option?.provider === 'slack' - ? option.slackBotCredentialId - : undefined - ), - disabled: isUpdating, - }, - ] - : []), - { - label: 'Remove', - destructive: true, - onSelect: () => setRemovingProvider(provider), - disabled: isUpdating, - }, - ]} - /> -
- ) : ( - handleProviderAction(provider)} - disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} - > - {support.configuration === 'oauth' ? 'Add' : 'Set up'} - - ) - } - /> - ) - })} -
-
- - - {shownMcpConnectors.length === 0 ? ( - - {providerSearch.trim() - ? `No MCP apps found matching "${providerSearch}"` - : 'No MCP apps are available.'} - - ) : null} -
- {shownMcpConnectors.map((connectorId) => { - const connector = MANAGED_MCP_CONNECTORS[connectorId] - const server = credentialGroup.mcpServers.find( - (candidate) => candidate.managedConnectorId === connectorId - ) - const ConnectorIcon = getManagedMcpConnectorIcon(connectorId) - return ( - } - title={server?.name ?? connector.name} - description={ - connectorId === 'databricks' - ? 'Configure Databricks in organization Connected accounts' - : connector.description - } - badge={server ? Added : undefined} - trailing={ - server ? ( - setRemovingMcpConnector(connectorId), - disabled: isUpdating, - }, - ]} - /> - ) : connectorId !== 'databricks' ? ( - void addMcpConnector(connectorId)}> - Add - - ) : undefined - } - /> - ) - })} -
-
- - { - if (!nextOpen) setSlackSetup(null) - }} - bots={slackBots.data ?? EMPTY_SLACK_BOTS} - isLoading={slackBots.isPending} - error={slackBots.error} - initialCredentialId={slackSetup?.credentialId} - initialRequiredScopes={ - credentialGroup.options.find((option) => option.provider === 'slack')?.requiredScopes ?? - (credentialGroup.options.some((option) => option.provider === 'slack') - ? SLACK_MANAGED_USER_SCOPES - : undefined) - } - /> - - !open && !isUpdating && setRemovingProvider(null)} - srTitle='Remove account type' - title={`Remove ${ - removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account' - }`} - text='People will no longer be able to connect this account. Previously connected accounts will no longer be available to Search or workflows.' - dismissLabel='Cancel' - confirm={{ - label: isUpdating ? 'Removing...' : 'Remove', - onClick: handleRemoveProvider, - disabled: isUpdating, - }} - /> - - !open && !isUpdating && setRemovingMcpConnector(null)} - srTitle='Remove MCP app' - title={`Remove ${ - removingMcpConnector ? MANAGED_MCP_CONNECTORS[removingMcpConnector].name : 'MCP app' - }`} - text='People will no longer be able to connect this app. Existing OAuth grants and saved tool metadata will be revoked.' - dismissLabel='Cancel' - confirm={{ - label: isUpdating ? 'Removing...' : 'Remove', - onClick: handleRemoveMcpConnector, - disabled: isUpdating, - }} - /> - - ) -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx b/apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx deleted file mode 100644 index 500a9205461..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { McpIcon } from '@/components/icons' -import type { - CredentialGroupEnrollmentConnection, - CredentialGroupEnrollmentMcpConnection, -} from '@/lib/api/contracts/credential-groups' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' -import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' - -interface EnrollmentConnectionsProps { - connections: CredentialGroupEnrollmentConnection[] - mcpConnections: CredentialGroupEnrollmentMcpConnection[] -} - -interface CredentialProviderIconProps { - provider: CredentialGroupEnrollmentConnection['provider'] -} - -function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { - if (provider === 'gitlab') { - const display = resolveCredentialDisplay({ - type: 'personal_token', - providerId: provider, - displayName: provider, - }) - const Icon = display.icon - return Icon ? : null - } - const ProviderIcon = getCredentialGroupProviderService(provider).icon - return -} - -export function EnrollmentConnections({ connections, mcpConnections }: EnrollmentConnectionsProps) { - const connected = connections.filter((connection) => connection.status === 'active') - const connectedMcp = mcpConnections.filter((connection) => connection.status === 'active') - const count = - connected.reduce((total, connection) => total + connection.count, 0) + connectedMcp.length - const providers = [...new Set(connected.map((connection) => connection.provider))] - - return ( - - {providers.map((provider) => { - return - })} - {connectedMcp.length > 0 ? : null} - - {count} {count === 1 ? 'account' : 'accounts'} connected - - - ) -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx deleted file mode 100644 index 071ca703dbe..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx +++ /dev/null @@ -1,122 +0,0 @@ -'use client' - -import { useCallback, useState } from 'react' -import { - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - toast, -} from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' -import { quickValidateEmail } from '@/lib/messaging/email/validation' -import { useInviteCredentialGroupEnrollments } from '@/hooks/queries/credential-groups' - -interface CredentialGroupInviteModalProps { - open: boolean - onOpenChange: (open: boolean) => void - workspaceId: string - groupId: string -} - -function validateEmail(email: string): string | null { - const result = quickValidateEmail(email) - return result.isValid ? null : (result.reason ?? 'Invalid email') -} - -export function CredentialGroupInviteModal({ - open, - onOpenChange, - workspaceId, - groupId, -}: CredentialGroupInviteModalProps) { - const invite = useInviteCredentialGroupEnrollments() - const [emails, setEmails] = useState([]) - const [deliveryError, setDeliveryError] = useState(null) - const canSubmit = emails.length > 0 && !invite.isPending - - const handleEmailsChange = useCallback((next: string[]) => { - setEmails(next) - setDeliveryError(null) - }, []) - - const handleOpenChange = (nextOpen: boolean) => { - if (invite.isPending) return - onOpenChange(nextOpen) - if (!nextOpen) { - setEmails([]) - setDeliveryError(null) - invite.reset() - } - } - - const handleSubmit = async () => { - if (!canSubmit) return - setDeliveryError(null) - try { - const result = await invite.mutateAsync({ - workspaceId, - groupId, - body: { emails }, - }) - const failures = result.results.filter((item) => !item.success) - if (failures.length === 0) { - toast.success( - result.sentCount === 1 - ? 'Connection request sent' - : `${result.sentCount} connection requests sent` - ) - handleOpenChange(false) - return - } - - setEmails(failures.map((item) => item.email)) - setDeliveryError( - result.sentCount > 0 - ? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => item.email).join(', ')}` - : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` - ) - } catch { - return - } - } - - return ( - - handleOpenChange(false)}> - Request account connections - - - - - {deliveryError ?? - (invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)} - - - handleOpenChange(false)} - cancelDisabled={invite.isPending} - primaryAction={{ - label: invite.isPending ? 'Sending...' : 'Send requests', - onClick: handleSubmit, - disabled: !canSubmit, - }} - /> - - ) -} diff --git a/apps/sim/hooks/queries/credential-groups.test.tsx b/apps/sim/hooks/queries/credential-groups.test.tsx index 955e30da6bc..3d76f22d1ee 100644 --- a/apps/sim/hooks/queries/credential-groups.test.tsx +++ b/apps/sim/hooks/queries/credential-groups.test.tsx @@ -17,10 +17,7 @@ import { vi.mock('@/lib/api/client/request', () => apiClientRequestMock) -import { - useUpdateCredentialGroupAccess, - useWorkspaceAccounts, -} from '@/hooks/queries/credential-groups' +import { useWorkspaceAccounts } from '@/hooks/queries/credential-groups' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' const mockRequestJson = apiClientRequestMockFns.mockRequestJson @@ -30,34 +27,8 @@ const GROUP_ID = 'group-1' const mountedRoots: Root[] = [] -function renderMutation(queryClient: QueryClient) { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - const root = createRoot(container) - mountedRoots.push(root) - let result: ReturnType | undefined - - function Probe() { - result = useUpdateCredentialGroupAccess() - return null - } - - act(() => - root.render( - - - - ) - ) - - return () => { - if (!result) throw new Error('Credential Group access mutation did not render') - return result - } -} - beforeEach(() => { - mockRequestJson.mockResolvedValue({ revision: 4, allowedWorkflowIds: ['workflow-2'] }) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) }) afterEach(() => { @@ -66,24 +37,6 @@ afterEach(() => { }) }) -describe('useUpdateCredentialGroupAccess', () => { - it('fails before the request when the access cache has not been loaded', async () => { - const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) - const getMutation = renderMutation(queryClient) - - await expect( - act(async () => - getMutation().mutateAsync({ - workspaceId: WORKSPACE_ID, - groupId: GROUP_ID, - body: { expectedRevision: 3, allowedWorkflowIds: ['workflow-2'] }, - }) - ) - ).rejects.toThrow('Credential Group access must be loaded before it can be updated') - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) - describe('useWorkspaceAccounts', () => { it('keeps account records scoped to their workspace while the next workspace loads', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 783728fd305..98ade022106 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -1,37 +1,18 @@ 'use client' -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' -import type { ContractBodyInput } from '@/lib/api/contracts' import { - type CredentialGroupAccessResponse, - createCredentialGroupMcpConnectorContract, - deleteCredentialGroupEnrollmentContract, - deleteCredentialGroupMcpConnectorContract, - ensureWorkspaceAccountsContract, - getCredentialGroupAccessContract, - getCredentialGroupContract, - inviteCredentialGroupEnrollmentsContract, - resendCredentialGroupEnrollmentContract, type StartSlackCredentialGroupConfigurationBody, startSlackCredentialGroupConfigurationContract, - updateCredentialGroupAccessContract, - updateCredentialGroupContract, - updateCredentialGroupMcpConnectorContract, } from '@/lib/api/contracts/credential-groups' import { startOrganizationSlackConfigurationContract } from '@/lib/api/contracts/organization-accounts' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' -import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import { - CREDENTIAL_GROUP_ACCESS_STALE_TIME, - CREDENTIAL_GROUP_DETAIL_STALE_TIME, credentialGroupKeys, fetchWorkspaceAccounts, WORKSPACE_ACCOUNTS_STALE_TIME, } from '@/hooks/queries/utils/credential-group-queries' -import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' -import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' export function useWorkspaceAccounts(workspaceId?: string) { return useQuery({ @@ -45,229 +26,6 @@ export function useWorkspaceAccounts(workspaceId?: string) { }) } -export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) { - return useInfiniteQuery({ - queryKey: credentialGroupKeys.detail(workspaceId, groupId), - queryFn: ({ signal, pageParam }) => { - if (!workspaceId || !groupId) - throw new Error('Credential group detail identifiers are required') - return requestJson(getCredentialGroupContract, { - params: { id: workspaceId, groupId }, - query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) }, - signal, - }) - }, - initialPageParam: null as string | null, - getNextPageParam: (lastPage: ContractJsonResponse) => - lastPage.nextCursor ?? undefined, - enabled: Boolean(workspaceId && groupId), - staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, - // An infinite staleTime never goes stale, so the app-wide `retryOnMount: false` - // would cache one transient failure for the life of the QueryClient. - retryOnMount: true, - }) -} - -interface UseCredentialGroupAccessOptions { - enabled?: boolean -} - -export function useCredentialGroupAccess( - workspaceId?: string, - groupId?: string, - { enabled = true }: UseCredentialGroupAccessOptions = {} -) { - return useQuery({ - queryKey: credentialGroupKeys.access(workspaceId, groupId), - queryFn: ({ signal }) => { - if (!workspaceId || !groupId) { - throw new Error('Credential Group access identifiers are required') - } - return requestJson(getCredentialGroupAccessContract, { - params: { id: workspaceId, groupId }, - signal, - }) - }, - enabled: Boolean(workspaceId && groupId && enabled), - staleTime: CREDENTIAL_GROUP_ACCESS_STALE_TIME, - retryOnMount: true, - }) -} - -export function useUpdateCredentialGroupAccess() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - body, - }: { - workspaceId: string - groupId: string - body: ContractBodyInput - }) => - requestJson(updateCredentialGroupAccessContract, { - params: { id: workspaceId, groupId }, - body, - }), - onMutate: async (variables) => { - const queryKey = credentialGroupKeys.access(variables.workspaceId, variables.groupId) - await queryClient.cancelQueries({ queryKey, exact: true }) - const cachedAccess = queryClient.getQueryData(queryKey) - if (!cachedAccess) { - throw new Error('Credential Group access must be loaded before it can be updated') - } - return { queryKey, workflows: cachedAccess.workflows } - }, - onSuccess: (access, _variables, context) => { - if (!context) throw new Error('Credential Group access mutation context is unavailable') - queryClient.setQueryData(context.queryKey, { - ...access, - workflows: context.workflows, - }) - }, - onSettled: (_data, _error, variables) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.access(variables.workspaceId, variables.groupId), - exact: true, - }), - invalidateSelectorQueries(queryClient), - ]), - }) -} - -export function useEnsureWorkspaceAccounts() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ workspaceId }: { workspaceId: string }) => - requestJson(ensureWorkspaceAccountsContract, { params: { id: workspaceId } }), - onSuccess: (_data, variables) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.workspace(variables.workspaceId), - }), - queryClient.invalidateQueries({ - queryKey: mcpKeys.managedCatalogList(variables.workspaceId), - }), - invalidateSelectorQueries(queryClient), - ]), - }) -} - -export function useUpdateCredentialGroup() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - body, - }: { - workspaceId: string - groupId: string - body: ContractBodyInput - }) => - requestJson(updateCredentialGroupContract, { - params: { id: workspaceId, groupId }, - body, - }), - // Returned so `mutateAsync` resolves only once the refetch has landed. Callers - // clear their edit buffer on success, which would otherwise fall back onto the - // pre-save cache and flash the old values. - onSettled: (_data, _error, variables) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.workspace(variables.workspaceId), - }), - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }), - queryClient.invalidateQueries({ - queryKey: mcpKeys.managedCatalogList(variables.workspaceId), - }), - invalidateSelectorQueries(queryClient), - ]), - }) -} - -function invalidateManagedMcpConnectorQueries( - queryClient: ReturnType, - workspaceId: string, - groupId: string -) { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.workspace(workspaceId) }), - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(workspaceId, groupId) }), - queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }), - queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }), - invalidateSelectorQueries(queryClient), - ]) -} - -export function useCreateCredentialGroupMcpConnector() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - body, - }: { - workspaceId: string - groupId: string - body: ContractBodyInput - }) => - requestJson(createCredentialGroupMcpConnectorContract, { - params: { id: workspaceId, groupId }, - body, - }), - onSettled: (_data, _error, variables) => - invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), - }) -} - -export function useUpdateCredentialGroupMcpConnector() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - connectorId, - body, - }: { - workspaceId: string - groupId: string - connectorId: ManagedMcpConnectorId - body: ContractBodyInput - }) => - requestJson(updateCredentialGroupMcpConnectorContract, { - params: { id: workspaceId, groupId, connectorId }, - body, - }), - onSettled: (_data, _error, variables) => - invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), - }) -} - -export function useDeleteCredentialGroupMcpConnector() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - connectorId, - }: { - workspaceId: string - groupId: string - connectorId: ManagedMcpConnectorId - }) => - requestJson(deleteCredentialGroupMcpConnectorContract, { - params: { id: workspaceId, groupId, connectorId }, - }), - onSettled: (_data, _error, variables) => - invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), - }) -} - export function useStartSlackCredentialGroupConfiguration() { return useMutation({ mutationFn: async ({ @@ -302,88 +60,3 @@ export function useStartSlackCredentialGroupConfiguration() { }, }) } - -export function useInviteCredentialGroupEnrollments() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - body, - }: { - workspaceId: string - groupId: string - body: ContractBodyInput - }) => - requestJson(inviteCredentialGroupEnrollmentsContract, { - params: { id: workspaceId, groupId }, - body, - }), - onSettled: (_data, _error, variables) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }), - queryClient.invalidateQueries({ - queryKey: mcpKeys.managedCatalogList(variables.workspaceId), - }), - invalidateSelectorQueries(queryClient), - ]), - }) -} - -export function useResendCredentialGroupEnrollment() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - enrollmentId, - }: { - workspaceId: string - groupId: string - enrollmentId: string - }) => - requestJson(resendCredentialGroupEnrollmentContract, { - params: { id: workspaceId, groupId, enrollmentId }, - }), - onSettled: (_data, _error, variables) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }), - queryClient.invalidateQueries({ - queryKey: mcpKeys.managedCatalogList(variables.workspaceId), - }), - invalidateSelectorQueries(queryClient), - ]), - }) -} - -export function useDeleteCredentialGroupEnrollment() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - workspaceId, - groupId, - enrollmentId, - }: { - workspaceId: string - groupId: string - enrollmentId: string - }) => - requestJson(deleteCredentialGroupEnrollmentContract, { - params: { id: workspaceId, groupId, enrollmentId }, - }), - onSettled: (_data, _error, variables) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }), - queryClient.invalidateQueries({ - queryKey: mcpKeys.managedCatalogList(variables.workspaceId), - }), - invalidateSelectorQueries(queryClient), - ]), - }) -} diff --git a/apps/sim/hooks/queries/utils/credential-group-queries.ts b/apps/sim/hooks/queries/utils/credential-group-queries.ts index c85421f0adc..3f87327ff53 100644 --- a/apps/sim/hooks/queries/utils/credential-group-queries.ts +++ b/apps/sim/hooks/queries/utils/credential-group-queries.ts @@ -2,10 +2,7 @@ import { requestJson } from '@/lib/api/client/request' import type { WorkspaceAccountsSettings } from '@/lib/api/contracts/credential-groups' import { getWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' -export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY export const WORKSPACE_ACCOUNTS_STALE_TIME = 30 * 1000 -export const CREDENTIAL_GROUP_ACCESS_STALE_TIME = 30 * 1000 -const CREDENTIAL_GROUP_ACCESS_QUERY_VERSION = 4 export const credentialGroupKeys = { all: ['workspace-accounts'] as const, @@ -15,12 +12,6 @@ export const credentialGroupKeys = { details: () => [...credentialGroupKeys.all, 'detail'] as const, detail: (workspaceId?: string, groupId?: string) => [...credentialGroupKeys.details(), workspaceId ?? '', groupId ?? ''] as const, - access: (workspaceId?: string, groupId?: string) => - [ - ...credentialGroupKeys.detail(workspaceId, groupId), - 'access', - CREDENTIAL_GROUP_ACCESS_QUERY_VERSION, - ] as const, } /** The workspace account configuration and provider availability share one cache entry. */ diff --git a/apps/sim/hooks/use-member-access.ts b/apps/sim/hooks/use-member-access.ts deleted file mode 100644 index b14ca64cb3b..00000000000 --- a/apps/sim/hooks/use-member-access.ts +++ /dev/null @@ -1,16 +0,0 @@ -'use client' - -import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' - -/** - * Whether per-member knowledge access is on for the routed workspace, as - * `isKnowledgeMemberAccessAvailable` judged it, resolved once into the - * workspace host context. - * - * The single client-side reading of that judgement, so no surface can drift - * into offering a feature the server refuses. Outside a workspace route there - * is no workspace to judge, so it reads false. - */ -export function useMemberAccessAvailable(): boolean { - return useOptionalWorkspaceHostContext()?.features?.knowledgeMemberAccess === true -} diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index ba62844d253..bbd502f18e0 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -9,5 +9,4 @@ export * from './google' export * from './media' export * from './microsoft' export * from './onepassword' -export * from './shared' export * from './workday' diff --git a/apps/sim/lib/api/contracts/tools/media/image.ts b/apps/sim/lib/api/contracts/tools/media/image.ts deleted file mode 100644 index 3d767062cd0..00000000000 --- a/apps/sim/lib/api/contracts/tools/media/image.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { z } from 'zod' -export const imageProxyQuerySchema = z.object({ - url: z.string({ error: 'Missing URL parameter' }).min(1, 'Missing URL parameter'), -}) diff --git a/apps/sim/lib/api/contracts/tools/media/index.ts b/apps/sim/lib/api/contracts/tools/media/index.ts index 17dcec1dbe8..f1bd2f8630a 100644 --- a/apps/sim/lib/api/contracts/tools/media/index.ts +++ b/apps/sim/lib/api/contracts/tools/media/index.ts @@ -1,4 +1,3 @@ export * from '@/lib/api/contracts/tools/media/document-parse' -export * from '@/lib/api/contracts/tools/media/image' export * from '@/lib/api/contracts/tools/media/shared' export * from '@/lib/api/contracts/tools/media/video' diff --git a/apps/sim/lib/api/contracts/tools/shared.ts b/apps/sim/lib/api/contracts/tools/shared.ts deleted file mode 100644 index e22d2ed272a..00000000000 --- a/apps/sim/lib/api/contracts/tools/shared.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from 'zod' - -export const genericToolResponseSchema = z - .object({ - success: z.boolean().optional(), - output: z.unknown().optional(), - error: z.string().optional(), - details: z.array(z.unknown()).optional(), - }) - .passthrough() diff --git a/apps/sim/lib/api/contracts/v1/copilot.ts b/apps/sim/lib/api/contracts/v1/copilot.ts deleted file mode 100644 index 6b2edf9f305..00000000000 --- a/apps/sim/lib/api/contracts/v1/copilot.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' -import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants' - -export const v1CopilotChatBodySchema = z.object({ - message: z.string().min(1, 'message is required'), - workflowId: z.string().optional(), - workflowName: z.string().optional(), - chatId: z.string().optional(), - mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), - model: z.string().optional(), - autoExecuteTools: z.boolean().optional().default(true), - timeout: z.number().int().min(1000).max(3_600_000).optional().default(3_600_000), -}) - -export type V1CopilotChatBody = z.output diff --git a/apps/sim/lib/api/contracts/v1/index.ts b/apps/sim/lib/api/contracts/v1/index.ts index d294a199dcb..d1ffa4fbc8b 100644 --- a/apps/sim/lib/api/contracts/v1/index.ts +++ b/apps/sim/lib/api/contracts/v1/index.ts @@ -1,6 +1,5 @@ export * from '@/lib/api/contracts/v1/admin' export * from '@/lib/api/contracts/v1/audit-logs' -export * from '@/lib/api/contracts/v1/copilot' export * from '@/lib/api/contracts/v1/files' export * from '@/lib/api/contracts/v1/knowledge' export * from '@/lib/api/contracts/v1/logs' diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts deleted file mode 100644 index 6ac494ecf36..00000000000 --- a/apps/sim/lib/credential-groups/application/list-people.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' -import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - credentialGroupDelegationPolicy, - requireCredentialGroupWorkflowActor, -} from '@/lib/credential-groups/application/authorization' -import { - requireCredentialGroupsAvailable, - resolveWorkspaceAccountsContext, -} from '@/lib/credential-groups/application/context' -import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' -import { - CredentialGroupEnrollmentError, - type CredentialGroupEnrollmentStatus, - listCredentialGroupEnrollments, -} from '@/lib/credential-groups/enrollments' - -export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [ - 'invited', - 'delivery_failed', - 'in_progress', - 'completed', - 'revoked', -] as const satisfies readonly CredentialGroupEnrollmentStatus[] - -export interface ListCredentialGroupPeopleInput { - workspaceId: string - limit: number - cursor?: string - email?: string - statuses?: CredentialGroupEnrollmentStatus[] -} - -export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ - operation: credentialGroupOperations.listPeople, - resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) => - resolveWorkspaceAccountsContext(input.workspaceId), - authorizationOptions: { delegation: credentialGroupDelegationPolicy }, - authorizeResource({ principal }) { - requireCredentialGroupWorkflowActor(principal) - }, - execute: async ({ input, context }) => { - if (context.status !== 'active') { - throw new OrchestrationError('conflict', 'Credential group is disabled') - } - if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) { - throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100') - } - const email = input.email ? normalizeEmail(input.email) : undefined - if (email && !isValidEmailSyntax(email)) { - throw new OrchestrationError('validation', 'Email must be a valid address') - } - const statuses = [...new Set(input.statuses ?? [])] - const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) - if (statuses.some((status) => !allowedStatuses.has(status))) { - throw new OrchestrationError('validation', 'People status filter is invalid') - } - await requireCredentialGroupsAvailable(context.workspaceId) - - try { - const page = await listCredentialGroupEnrollments( - context.workspaceId, - context.credentialGroupId, - input.limit, - input.cursor, - { email, statuses: statuses.length > 0 ? statuses : undefined } - ) - return { - people: page.enrollments, - count: page.enrollments.length, - hasMore: page.nextCursor !== null, - nextCursor: page.nextCursor, - } - } catch (error) { - if (error instanceof CredentialGroupEnrollmentError) { - throw new OrchestrationError( - error.status === 400 || error.status === 404 - ? 'validation' - : error.status === 409 - ? 'conflict' - : 'internal', - error.message - ) - } - throw error - } - }, -}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index a24c8e7809f..fd9a51e9777 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -121,15 +121,6 @@ export const credentialGroupOperations = { principalKinds: ['delegated'], delegatedServices: ['executor'], }), - // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them - listPeople: defineWorkspaceOperation({ - id: 'credential_groups.people.list', - minimumRole: 'read', - workspaceApiKey: 'deny', - capability: 'none', - principalKinds: ['delegated'], - delegatedServices: ['executor'], - }), // permission-group-exempt: enrolls an outside person in a credential group, not a member in a workspace, so invitations.send does not name it sendInvite: defineWorkspaceOperation({ id: 'credential_groups.invites.send', diff --git a/apps/sim/lib/internal/cloudwatch/http-route.ts b/apps/sim/lib/internal/cloudwatch/http-route.ts deleted file mode 100644 index fe9b4de4cc2..00000000000 --- a/apps/sim/lib/internal/cloudwatch/http-route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Logger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { checkInternalAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { CloudWatchInputError } from '@/lib/internal/cloudwatch/operations' - -type CloudWatchRequestParseResult = - | { success: true; data: { body: T } } - | { success: false; response: Response } - -interface CloudWatchHttpRouteConfig { - logger: Logger - parse: (request: NextRequest) => Promise> - execute: (input: T, signal?: AbortSignal) => Promise - errorMessage: string - auth?: 'internal' | 'session-or-internal' - logError?: (error: unknown) => void -} - -export function createCloudWatchHttpRoute({ - logger, - parse, - execute, - errorMessage, - auth = 'internal', - logError, -}: CloudWatchHttpRouteConfig) { - return withRouteHandler(async (request: NextRequest) => { - try { - const result = - auth === 'session-or-internal' - ? await checkSessionOrInternalAuth(request) - : await checkInternalAuth(request) - if (!result.success || !result.userId) { - return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parse(request) - if (!parsed.success) return parsed.response - return NextResponse.json(await execute(parsed.data.body, request.signal)) - } catch (error) { - if (error instanceof CloudWatchInputError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } - if (logError) logError(error) - else logger.error(errorMessage, { error: toError(error).message }) - return NextResponse.json( - { error: `${errorMessage}: ${toError(error).message}` }, - { status: 500 } - ) - } - }) -} diff --git a/apps/sim/lib/logs/service.ts b/apps/sim/lib/logs/service.ts deleted file mode 100644 index b15b6d5b860..00000000000 --- a/apps/sim/lib/logs/service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { eq } from 'drizzle-orm' - -/** Minimal log record returned by server-side service lookups. */ -export interface LogRecord { - id: string - workspaceId: string - startedAt: Date - workflowName: string | null -} - -/** - * Fetches a log record by its primary key, joining the workflow name. - * Returns null if no matching record exists. - */ -export async function getLogById(id: string): Promise { - const [record] = await db - .select({ - id: workflowExecutionLogs.id, - workspaceId: workflowExecutionLogs.workspaceId, - startedAt: workflowExecutionLogs.startedAt, - workflowName: workflow.name, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) - - return record ?? null -} diff --git a/apps/sim/lib/logs/stats-logs.ts b/apps/sim/lib/logs/stats-logs.ts deleted file mode 100644 index 6d98e2a5174..00000000000 --- a/apps/sim/lib/logs/stats-logs.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { dbReplica } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, eq, sql } from 'drizzle-orm' -import { assertValidTimezone } from '@/lib/core/utils/timezone' -import { buildFilterConditions } from '@/lib/logs/filters' -import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -/** - * Server-side aggregation over workflow execution logs for the copilot - * `query_logs` stats view: per-workflow, optionally calendar-bucketed counts by - * status, under the same filter set as the list view. Exists so a model never - * has to paginate the list and count client-side. Job (Sim-agent) executions - * are not included — same scope as the Logs dashboard stats. - */ - -export interface StatsLogsParams { - workspaceId: string - level?: string - workflowIds?: string - folderIds?: string - triggers?: string - startDate?: string - endDate?: string - search?: string - workflowName?: string - folderName?: string - /** Calendar bucketing for the per-workflow series; omit for totals only. */ - bucket?: 'day' | 'hour' - /** IANA timezone the buckets are computed in. Defaults to UTC. */ - timezone?: string -} - -export interface LogStatsBucket { - /** Bucket start in the requested timezone (naive local timestamp). */ - start: string - executions: number - byStatus: Record - avgDurationMs: number -} - -export interface WorkflowLogStats { - workflowId: string - workflowName: string - executions: number - byStatus: Record - avgDurationMs: number - buckets?: LogStatsBucket[] -} - -export interface LogStatsResponse { - bucket: 'day' | 'hour' | null - timezone: string - totals: { executions: number; byStatus: Record; avgDurationMs: number } - workflows: WorkflowLogStats[] - /** Set when more workflows matched than are returned (ordered by executions). */ - workflowsTruncated?: boolean -} - -const MAX_WORKFLOWS = 100 - -interface StatsAccumulator { - executions: number - byStatus: Record - durationSumMs: number - durationCount: number -} - -function newAccumulator(): StatsAccumulator { - return { executions: 0, byStatus: {}, durationSumMs: 0, durationCount: 0 } -} - -function accumulate(acc: StatsAccumulator, status: string, row: RawStatsRow): void { - acc.executions += Number(row.executions) - acc.byStatus[status] = (acc.byStatus[status] ?? 0) + Number(row.executions) - acc.durationSumMs += Number(row.durationSumMs) - acc.durationCount += Number(row.durationCount) -} - -function avgOf(acc: StatsAccumulator): number { - return acc.durationCount > 0 ? Math.round(acc.durationSumMs / acc.durationCount) : 0 -} - -interface RawStatsRow { - workflowId: string - workflowName: string - bucketStart: string | null - status: string | null - executions: number - durationSumMs: number - durationCount: number -} - -export async function statsLogs( - params: StatsLogsParams, - userId: string -): Promise { - const timezone = params.timezone ?? 'UTC' - assertValidTimezone(timezone) - const bucket = params.bucket ?? null - - const access = await checkWorkspaceAccess(params.workspaceId, userId) - if (!access.hasAccess) { - return { - bucket, - timezone, - totals: { executions: 0, byStatus: {}, avgDurationMs: 0 }, - workflows: [], - } - } - - const folderIds = params.folderIds - ? await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) - : params.folderIds - const p = { ...params, folderIds } - - const workspaceFilter = eq(workflowExecutionLogs.workspaceId, p.workspaceId) - const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: true }) - const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter - - const bucketExpr = bucket - ? sql< - string | null - >`to_char(date_trunc(${bucket}, ${workflowExecutionLogs.startedAt} AT TIME ZONE ${timezone}), 'YYYY-MM-DD"T"HH24:MI:SS')` - : sql`NULL` - - const rows = (await dbReplica - .select({ - workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, - workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, - bucketStart: bucketExpr.as('bucket_start'), - status: workflowExecutionLogs.status, - executions: sql`COUNT(*)`, - durationSumMs: sql`COALESCE(SUM(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`, - durationCount: sql`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0)`, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(whereCondition) - .groupBy( - sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, - sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, - sql`bucket_start`, - workflowExecutionLogs.status - )) as RawStatsRow[] - - const totals = newAccumulator() - const byWorkflow = new Map< - string, - { workflowName: string; overall: StatsAccumulator; buckets: Map } - >() - - for (const row of rows) { - const status = row.status ?? 'unknown' - accumulate(totals, status, row) - let wf = byWorkflow.get(row.workflowId) - if (!wf) { - wf = { workflowName: row.workflowName, overall: newAccumulator(), buckets: new Map() } - byWorkflow.set(row.workflowId, wf) - } - accumulate(wf.overall, status, row) - if (bucket && row.bucketStart) { - let bucketAcc = wf.buckets.get(row.bucketStart) - if (!bucketAcc) { - bucketAcc = newAccumulator() - wf.buckets.set(row.bucketStart, bucketAcc) - } - accumulate(bucketAcc, status, row) - } - } - - const workflows: WorkflowLogStats[] = Array.from(byWorkflow.entries()) - .map(([workflowId, wf]) => ({ - workflowId, - workflowName: wf.workflowName, - executions: wf.overall.executions, - byStatus: wf.overall.byStatus, - avgDurationMs: avgOf(wf.overall), - ...(bucket - ? { - buckets: Array.from(wf.buckets.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([start, acc]) => ({ - start, - executions: acc.executions, - byStatus: acc.byStatus, - avgDurationMs: avgOf(acc), - })), - } - : {}), - })) - .sort((a, b) => b.executions - a.executions) - - const truncated = workflows.length > MAX_WORKFLOWS - - return { - bucket, - timezone, - totals: { - executions: totals.executions, - byStatus: totals.byStatus, - avgDurationMs: avgOf(totals), - }, - workflows: truncated ? workflows.slice(0, MAX_WORKFLOWS) : workflows, - ...(truncated ? { workflowsTruncated: true } : {}), - } -} diff --git a/apps/sim/lib/mermaid/validate.ts b/apps/sim/lib/mermaid/validate.ts deleted file mode 100644 index 86f41678131..00000000000 --- a/apps/sim/lib/mermaid/validate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { toError } from '@sim/utils/errors' - -export interface MermaidValidationResult { - ok: boolean - error?: string - errorName?: string -} - -export async function validateMermaidSource(source: string): Promise { - try { - const { default: mermaid } = await import('mermaid') - await mermaid.parse(source) - return { ok: true } - } catch (error) { - const err = toError(error) - return { - ok: false, - error: err.message || 'Invalid Mermaid diagram', - errorName: err.name || 'MermaidParseError', - } - } -} diff --git a/apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts b/apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts deleted file mode 100644 index dcb229c76c9..00000000000 --- a/apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' -import { credentialOperations } from '@/lib/credentials/application/operations' -import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' - -export const executeCopilotManagedMcpUseCase = createCopilotApplicationAdapter({ - domain: 'managed MCP', - delegation: { - audience: MANAGED_MCP_DELEGATION_AUDIENCE, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, - }, - operations: { use: credentialOperations.useManagedMcp }, - projectResourceScope: (input: { credentialId: string }) => ({ credentialId: input.credentialId }), -}) diff --git a/apps/sim/lib/mothership/tools/server/files/doc-recalc.ts b/apps/sim/lib/mothership/tools/server/files/doc-recalc.ts deleted file mode 100644 index 72464b37226..00000000000 --- a/apps/sim/lib/mothership/tools/server/files/doc-recalc.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { Principal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' -import { CodeLanguage } from '@/lib/execution/languages' -import { executeInSandbox } from '@/lib/execution/remote-sandbox' -import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' -import { compileDoc } from './doc-compile' - -const logger = createLogger('CopilotDocRecalc') - -const RECALC_TIMEOUT_MS = 150_000 -const MAX_REPORTED_ERRORS = 50 - -export interface XlsxCellError { - sheet: string - cell: string - error: string -} - -export interface XlsxRecalcResult { - ok: boolean - errors: XlsxCellError[] -} - -/** - * Scans an .xlsx workbook for spilled error values (#REF!/#DIV/0!/etc.) — the - * spreadsheet equivalent of the visual QA loop. - * - * Precondition: the binary must already be recalculated (cached values present). - * compileDoc bakes a LibreOffice recalc into every xlsx artifact, so the input - * here always has cached values — meaning we can read them with openpyxl - * (data_only) directly and skip a second LibreOffice cold-start. Throws on - * sandbox/infra failure. - */ -export async function recalcXlsx(args: { - binary: Buffer - workspaceId: string -}): Promise { - const script = ` -import json, openpyxl - -ERR = {"#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#N/A", "#NULL!", "#NUM!"} - -# Input is already recalculated by compileDoc, so cached values are present. -wb = openpyxl.load_workbook("/home/user/input.xlsx", data_only=True) -errors = [] -for ws in wb.worksheets: - for row in ws.iter_rows(): - for c in row: - if isinstance(c.value, str) and c.value.strip() in ERR: - errors.append({"sheet": ws.title, "cell": c.coordinate, "error": c.value.strip()}) - -print("__SIM_RESULT__=" + json.dumps({"ok": len(errors) == 0, "errors": errors[:${MAX_REPORTED_ERRORS}]})) -`.trim() - - const result = await executeInSandbox({ - code: script, - language: CodeLanguage.Python, - timeoutMs: RECALC_TIMEOUT_MS, - sandboxKind: 'doc', - sandboxFiles: [ - { - path: '/home/user/input.xlsx', - content: args.binary.toString('base64'), - encoding: 'base64', - }, - ], - }) - - if (result.error) { - throw new Error(`Spreadsheet recalc failed: ${result.error}`) - } - const payload = result.result as XlsxRecalcResult | null - if (!payload || typeof payload.ok !== 'boolean') { - logger.warn('Recalc returned no structured result', { workspaceId: args.workspaceId }) - return { ok: true, errors: [] } - } - return { ok: payload.ok, errors: Array.isArray(payload.errors) ? payload.errors : [] } -} - -/** Single-line summary of the first few formula errors, for the compiled-check result. */ -export function formatXlsxErrors(errors: XlsxCellError[]): string { - return `${errors.length} formula error(s): ${errors - .slice(0, 5) - .map((e) => `${e.sheet}!${e.cell}=${e.error}`) - .join(', ')}` -} - -export interface CompiledCheckResult { - ok: boolean - error?: string - errors?: XlsxCellError[] -} - -/** - * Compiles a generated doc (and, for xlsx, recalc-scans its formulas) to verify - * it builds — the shared body behind the /compiled-check route and the VFS - * compiled-check read. Returns { ok: false } only for a DocCompileUserError (the - * agent's script is wrong); infra failures (E2B/S3) rethrow so callers surface a - * 5xx instead of telling the agent to fix its script. - */ -export async function runE2BCompiledCheck(args: { - source: string - fileName: string - workspaceId: string - ext: string - principal: Principal -}): Promise { - try { - const compiled = await compileDoc({ - source: args.source, - fileName: args.fileName, - workspaceId: args.workspaceId, - filePrincipal: args.principal, - }) - if (args.ext === 'xlsx') { - const recalc = await recalcXlsx({ binary: compiled.buffer, workspaceId: args.workspaceId }) - return recalc.ok - ? { ok: true } - : { ok: false, error: formatXlsxErrors(recalc.errors), errors: recalc.errors } - } - return { ok: true } - } catch (err) { - if (err instanceof DocCompileUserError) return { ok: false, error: err.message } - throw err - } -} diff --git a/apps/sim/lib/permission-groups/queries.ts b/apps/sim/lib/permission-groups/queries.ts deleted file mode 100644 index f61ef57e394..00000000000 --- a/apps/sim/lib/permission-groups/queries.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { db } from '@sim/db' -import { - permissionGroup, - permissionGroupMember, - permissionGroupWorkspace, - workspace, -} from '@sim/db/schema' -import { asc, count, desc, eq, inArray } from 'drizzle-orm' -import { - type ActivePermissionGroupRestriction, - getActivePermissionGroupRestrictions, -} from '@/lib/permission-groups/features' -import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' - -/** A workspace reference (id + display name). */ -export interface OrgWorkspaceRef { - id: string - name: string -} - -/** - * List an organization's workspaces ({id, name}), ordered by name. - * - * Lifted from the permission-groups route utils so non-route surfaces (the - * copilot VFS) can read the org's workspace map without importing app/api. - */ -export async function listOrganizationWorkspaceRefs( - organizationId: string -): Promise { - return db - .select({ id: workspace.id, name: workspace.name }) - .from(workspace) - .where(eq(workspace.organizationId, organizationId)) - .orderBy(asc(workspace.name)) -} - -export interface PermissionGroupRosterEntry { - id: string - name: string - description: string | null - isDefault: boolean - memberCount: number - workspaces: OrgWorkspaceRef[] - activeRestrictions: ActivePermissionGroupRestriction[] -} - -/** - * The org-admin roster: every permission group with its member count, the - * workspaces it targets, and the restrictions its config actually activates. - * The same joins the settings surface runs, without the route envelope. - */ -export async function listPermissionGroupRoster( - organizationId: string -): Promise { - const groups = await db - .select({ - id: permissionGroup.id, - name: permissionGroup.name, - description: permissionGroup.description, - config: permissionGroup.config, - isDefault: permissionGroup.isDefault, - }) - .from(permissionGroup) - .where(eq(permissionGroup.organizationId, organizationId)) - .orderBy(desc(permissionGroup.createdAt)) - - const groupIds = groups.map((group) => group.id) - const memberCounts = groupIds.length - ? await db - .select({ - permissionGroupId: permissionGroupMember.permissionGroupId, - count: count(), - }) - .from(permissionGroupMember) - .where(inArray(permissionGroupMember.permissionGroupId, groupIds)) - .groupBy(permissionGroupMember.permissionGroupId) - : [] - const countByGroupId = new Map(memberCounts.map((row) => [row.permissionGroupId, row.count])) - - const workspaceRows = groupIds.length - ? await db - .select({ - groupId: permissionGroupWorkspace.permissionGroupId, - id: workspace.id, - name: workspace.name, - }) - .from(permissionGroupWorkspace) - .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) - .where(inArray(permissionGroupWorkspace.permissionGroupId, groupIds)) - .orderBy(asc(workspace.name)) - : [] - const workspacesByGroupId = new Map() - for (const row of workspaceRows) { - const list = workspacesByGroupId.get(row.groupId) ?? [] - list.push({ id: row.id, name: row.name }) - workspacesByGroupId.set(row.groupId, list) - } - - return groups.map((group) => ({ - id: group.id, - name: group.name, - description: group.description, - isDefault: group.isDefault, - memberCount: countByGroupId.get(group.id) ?? 0, - workspaces: workspacesByGroupId.get(group.id) ?? [], - activeRestrictions: getActivePermissionGroupRestrictions( - (group.config as PermissionGroupConfig | null) ?? null - ), - })) -} diff --git a/apps/sim/lib/resources/orchestration/index.ts b/apps/sim/lib/resources/orchestration/index.ts deleted file mode 100644 index 5b328d7bff7..00000000000 --- a/apps/sim/lib/resources/orchestration/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type PerformRestoreResourceParams, - performRestoreResource, - type RestorableResourceType, -} from './restore-resource' diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts deleted file mode 100644 index e007b531348..00000000000 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import { restoreFolder } from '@/lib/folders/orchestration' -import { - getRestorableKnowledgeBase, - performRestoreKnowledgeBase, -} from '@/lib/knowledge/orchestration' -import type { MothershipResource } from '@/lib/mothership/resources/types' -import type { ToolExecutionResult } from '@/lib/mothership/tool-executor/types' -import { performRestoreTable } from '@/lib/table/orchestration' -import { getTableById } from '@/lib/table/service' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRestoreWorkflow } from '@/lib/workflows/orchestration' -import { getWorkflowById } from '@/lib/workflows/utils' -import { - performRestoreWorkspaceFile, - performRestoreWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('RestoreResourceOrchestration') - -export type RestorableResourceType = - | 'workflow' - | 'table' - | 'file' - | 'knowledgebase' - | 'folder' - | 'file_folder' - | 'knowledge_folder' - | 'table_folder' - -/** - * Folder trees the generic engine restores, keyed by the restorable type that names them. - * `'folder'` stays the workflow tree so the existing tool contract does not shift meaning; - * the other trees get their own names, mirroring how `file_folder` already sits beside it. - * - * `knowledge_folder` and `table_folder` are accepted here and by the tool handler, but the - * model cannot emit them yet: the `restore_resource` parameter enum lives in the copilot - * service's `contracts/tool-catalog-v1.json`, which is a different repository and is mirrored - * into `lib/mothership/generated/**` by `scripts/sync-tool-catalog.ts`. Widening it there makes - * these reachable with no further change on this side. - */ -type RestorableFolderType = 'folder' | 'knowledge_folder' | 'table_folder' - -/** - * Deliberately a total `Record` over the folder types, not a `Partial` one: adding a tree to - * `RestorableFolderType` without a mapping has to fail the build *here*, at the mapping. A - * `Partial` still compiles with the tree missing — the lookup widens to - * `FolderResourceType | undefined`, so the error moves to the `restoreFolder` call site, and - * suppressing it there leaves the cascade resolving an undefined folder config. - */ -const FOLDER_RESOURCE_TYPE_BY_RESTORABLE: Record = { - folder: 'workflow', - knowledge_folder: 'knowledge_base', - table_folder: 'table', -} - -export interface PerformRestoreResourceParams { - type: RestorableResourceType - id: string - userId: string - workspaceId: string - requestId?: string -} - -async function hasWriteAccess( - userId: string, - callerWorkspaceId: string, - resourceWorkspaceId: string | null | undefined -): Promise { - if (!resourceWorkspaceId || resourceWorkspaceId !== callerWorkspaceId) return false - const permission = await getUserEntityPermissions(userId, 'workspace', resourceWorkspaceId) - return permission === 'write' || permission === 'admin' -} - -function success( - output: Record, - resources?: MothershipResource[] -): ToolExecutionResult { - return { success: true, output, resources } -} - -export async function performRestoreResource( - params: PerformRestoreResourceParams -): Promise { - const { type, id, userId, workspaceId } = params - const requestId = params.requestId ?? generateId().slice(0, 8) - - try { - switch (type) { - case 'workflow': { - const existing = await getWorkflowById(id, { includeArchived: true }) - if (!existing || !(await hasWriteAccess(userId, workspaceId, existing.workspaceId))) { - return { success: false, error: 'Workflow not found' } - } - - const result = await performRestoreWorkflow({ workflowId: id, userId, requestId }) - if (!result.success || !result.workflow) { - return { success: false, error: result.error || 'Failed to restore workflow' } - } - - logger.info('Workflow restored via restore_resource', { workflowId: id }) - return success({ type, id, name: result.workflow.name }, [ - { type: 'workflow', id, title: result.workflow.name || id }, - ]) - } - - case 'table': { - const existing = await getTableById(id, { includeArchived: true }) - if (!existing || !(await hasWriteAccess(userId, workspaceId, existing.workspaceId))) { - return { success: false, error: 'Table not found' } - } - - const result = await performRestoreTable({ tableId: id, userId, requestId }) - if (!result.success || !result.table) { - return { success: false, error: result.error || 'Failed to restore table' } - } - - logger.info('Table restored via restore_resource', { tableId: id, name: result.table.name }) - return success({ type, id, name: result.table.name }, [ - { type: 'table', id, title: result.table.name }, - ]) - } - - case 'file': { - if (!(await hasWriteAccess(userId, workspaceId, workspaceId))) { - return { success: false, error: 'File not found' } - } - - const result = await performRestoreWorkspaceFile({ workspaceId, fileId: id, userId }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to restore file' } - } - - const file = await getWorkspaceFile(workspaceId, id) - const fileName = file?.name || id - logger.info('File restored via restore_resource', { fileId: id, name: fileName }) - return success({ type, id, name: fileName }, [{ type: 'file', id, title: fileName }]) - } - - case 'knowledgebase': { - const existing = await getRestorableKnowledgeBase(id) - if (!existing || !(await hasWriteAccess(userId, workspaceId, existing.workspaceId))) { - return { success: false, error: 'Knowledge base not found' } - } - - const result = await performRestoreKnowledgeBase({ - knowledgeBaseId: id, - userId, - source: 'agent', - requestId, - }) - if (!result.success) { - return { success: false, error: result.error } - } - - logger.info('Knowledge base restored via restore_resource', { knowledgeBaseId: id }) - return success({ type, id, name: result.knowledgeBase.name }, [ - { type: 'knowledgebase', id, title: result.knowledgeBase.name }, - ]) - } - - case 'folder': - case 'knowledge_folder': - case 'table_folder': { - if (!(await hasWriteAccess(userId, workspaceId, workspaceId))) { - return { success: false, error: 'Folder not found' } - } - - const result = await restoreFolder({ - folderId: id, - workspaceId, - userId, - resourceType: FOLDER_RESOURCE_TYPE_BY_RESTORABLE[type], - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to restore folder' } - } - - logger.info('Folder restored via restore_resource', { folderId: id, type }) - return success({ type, id, restoredItems: result.restoredItems }) - } - - case 'file_folder': { - if (!(await hasWriteAccess(userId, workspaceId, workspaceId))) { - return { success: false, error: 'File folder not found' } - } - - const result = await performRestoreWorkspaceFileFolder({ - workspaceId, - folderId: id, - userId, - }) - if (!result.success || !result.folder || !result.restoredItems) { - return { success: false, error: result.error || 'Failed to restore file folder' } - } - - logger.info('File folder restored via restore_resource', { - folderId: id, - restoredItems: result.restoredItems, - }) - return success({ - type, - id, - name: result.folder.name, - restoredItems: result.restoredItems, - }) - } - } - } catch (error) { - logger.error('Failed to restore resource via restore_resource', { type, id, error }) - return { success: false, error: toError(error).message } - } -} diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 7ddf0466452..14aed5e74ce 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -432,14 +432,6 @@ export const workflowOperations = { capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), - // permission-group-exempt: comparing references across two versions reads workflow content the caller may already open - compareReferences: defineWorkspaceOperation({ - id: 'workflows.versions.compare_references', - minimumRole: 'read', - workspaceApiKey: 'deny', - capability: 'none', - ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, - }), // permission-group-exempt: an export returns the graph its reader can already open; logs.export withholds execution logs, not definitions export: defineWorkspaceOperation({ id: 'workflows.export', diff --git a/apps/sim/lib/workflows/application/read-workflow-state-references.ts b/apps/sim/lib/workflows/application/read-workflow-state-references.ts deleted file mode 100644 index 6a33cf01f98..00000000000 --- a/apps/sim/lib/workflows/application/read-workflow-state-references.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Principal } from '@sim/auth/principal' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' -import { workflowOperations } from '@/lib/workflows/application/operations' -import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' -import { - getWorkflowDeploymentVersion, - loadWorkflowFromNormalizedTables, -} from '@/lib/workflows/persistence/utils' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -export type WorkflowStateReference = number | 'live' | 'draft' - -export interface ResolvedWorkflowStateReference { - state: WorkflowState - ref: string - version?: number - isActive?: boolean - createdAt?: string -} - -export interface ReadWorkflowStateReferencesInput { - workflowId: string - assertedWorkspaceId?: string - references: [WorkflowStateReference, WorkflowStateReference] -} - -async function loadReference( - workflowId: string, - reference: WorkflowStateReference -): Promise { - if (reference === 'draft') { - const state = await loadWorkflowFromNormalizedTables(workflowId) - if (!state) throw new OrchestrationError('not_found', 'Workflow has no draft state') - return { state: state as WorkflowState, ref: 'draft' } - } - - const row = await getWorkflowDeploymentVersion( - workflowId, - reference === 'live' ? 'active' : reference - ) - if (!row?.state) throw new OrchestrationError('not_found', 'Deployment version not found') - return { - state: row.state as WorkflowState, - ref: reference === 'live' ? 'live' : String(reference), - version: row.version, - isActive: row.isActive, - createdAt: row.createdAt?.toISOString(), - } -} - -export const readWorkflowStateReferences = defineAuthorizedWorkflowUseCase({ - operation: workflowOperations.compareReferences, - resolveContext: ({ - principal, - input, - }: { - principal: Principal - input: ReadWorkflowStateReferencesInput - }) => - resolveActiveWorkflowApplicationContext({ - workflowId: input.workflowId, - assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), - }), - async execute({ input, context }) { - const [first, second] = await Promise.all( - input.references.map((reference) => loadReference(context.workflowId, reference)) - ) - return { references: [first, second] as const } - }, -}) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index ef15c23e038..b7d0ebd70f6 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -140,30 +140,6 @@ export const fileOperations = { capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), - createVfsFolders: defineWorkspaceOperation({ - id: 'files.vfs.folders.create', - minimumRole: 'write', - workspaceApiKey: 'deny', - capability: 'files.use', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], - }), - relocateVfsItems: defineWorkspaceOperation({ - id: 'files.vfs.relocate', - minimumRole: 'write', - workspaceApiKey: 'deny', - capability: 'files.use', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], - }), - deleteVfsItems: defineWorkspaceOperation({ - id: 'files.vfs.delete', - minimumRole: 'write', - workspaceApiKey: 'deny', - capability: 'files.use', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], - }), delete: defineWorkspaceOperation({ id: 'files.delete', oauthScope: 'api:write', diff --git a/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts b/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts deleted file mode 100644 index be5e143d7fb..00000000000 --- a/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' -import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' -import { buildFolderPath } from '@/lib/folders/paths' -import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { - bulkArchiveWorkspaceFileItems, - createWorkspaceFileFolderAtPath, - findWorkspaceFileFolderIdByPath, - getWorkspaceFileByName, - loadWorkspaceFileOperationContext, - moveRenameWorkspaceFile, - relocateWorkspaceFileFolderByPath, - WorkspaceFileFolderConflictError, -} from '@/lib/uploads/contexts/workspace' -import { VfsPathLimitError, validateVfsPathSegments } from '@/lib/vfs/limits' -import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' -import { fileOperations } from '@/lib/workspace-files/application/operations' - -const MAX_FILE_VFS_ITEMS = 100 - -export interface WorkspaceFileVfsPathReference { - source: string - segments: string[] -} - -export interface WorkspaceFileVfsDestination { - segments: string[] - trailingSlash: boolean -} - -export interface WorkspaceFileVfsOutcome { - source: string - targetSegments?: string[] - resourceType: 'file' | 'folder' - resourceId?: string - error?: string -} - -interface CreatedFolder { - id: string - name: string - path: string -} - -interface RelocatedFile { - id: string - name: string - moved: boolean - renamed: boolean -} - -interface RelocatedFolder { - id: string - name: string - sourcePath: string - destinationPath: string -} - -function normalizeReferences( - references: readonly WorkspaceFileVfsPathReference[] -): WorkspaceFileVfsPathReference[] { - if (references.length > MAX_FILE_VFS_ITEMS) { - throw new OrchestrationError( - 'validation', - `File VFS commands cannot exceed ${MAX_FILE_VFS_ITEMS} items` - ) - } - const unique = new Map() - for (const reference of references) { - try { - validateVfsPathSegments(reference.segments) - } catch (error) { - if (error instanceof VfsPathLimitError) { - throw new OrchestrationError('validation', error.message) - } - throw error - } - const key = buildFolderPath(reference.segments) - if (!unique.has(key)) unique.set(key, reference) - } - if (unique.size === 0) throw new OrchestrationError('validation', 'At least one path is required') - return [...unique.values()] -} - -/** - * The per-item message for a failure the caller can act on, or a rethrow for a fault - * that should fail the whole command. - * - * Every caller-fixable failure here carries an orchestration class — the folder and - * move conflicts classify themselves as `conflict`, and `FolderPathError` as - * `validation` — so the classified branch covers them all. Anything unclassified, or - * classified `internal`, is a fault and propagates. - */ -function expectedOutcomeMessage(error: unknown): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - throw error -} - -async function ensureFolderPath(params: { - workspaceId: string - userId: string - segments: readonly string[] - createdFolders: CreatedFolder[] -}): Promise { - let folderId: string | null = null - for (let index = 0; index < params.segments.length; index += 1) { - const segments = params.segments.slice(0, index + 1) - const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, [...segments]) - if (existing) { - folderId = existing - continue - } - const path = buildFolderPath(segments) - try { - const created = await createWorkspaceFileFolderAtPath({ - workspaceId: params.workspaceId, - userId: params.userId, - path, - }) - folderId = created.folder.id - params.createdFolders.push({ - id: created.folder.id, - name: created.folder.name, - path: created.path, - }) - } catch (error) { - if (error instanceof WorkspaceFileFolderConflictError) { - const concurrentlyCreated = await findWorkspaceFileFolderIdByPath(params.workspaceId, [ - ...segments, - ]) - if (concurrentlyCreated) { - folderId = concurrentlyCreated - continue - } - } - throw error - } - } - return folderId -} - -async function resolveSource( - workspaceId: string, - reference: WorkspaceFileVfsPathReference -): Promise< - | { source: string; file: NonNullable>> } - | { source: string; folderId: string } - | { source: string; error: string } -> { - if (reference.segments.length === 0) { - return { source: reference.source, error: 'Source must name a file or folder under files/' } - } - const parentSegments = reference.segments.slice(0, -1) - const folderId = - parentSegments.length === 0 - ? null - : await findWorkspaceFileFolderIdByPath(workspaceId, parentSegments) - if (parentSegments.length === 0 || folderId) { - const file = await getWorkspaceFileByName(workspaceId, reference.segments.at(-1) as string, { - folderId, - }) - if (file) return { source: reference.source, file } - } - const sourceFolderId = await findWorkspaceFileFolderIdByPath(workspaceId, reference.segments) - return sourceFolderId - ? { source: reference.source, folderId: sourceFolderId } - : { source: reference.source, error: `Not found: ${reference.source}` } -} - -function createdFolderAudits(folders: readonly CreatedFolder[]) { - return folders.map((folder) => ({ - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Created file folder "${folder.path}"`, - metadata: { path: folder.path, folderResourceType: 'file' }, - })) -} - -export interface CreateWorkspaceFileVfsFoldersInput { - workspaceId: string - paths: WorkspaceFileVfsPathReference[] -} - -export const createWorkspaceFileVfsFolders = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.createVfsFolders, - resolveContext: ({ input }: { input: CreateWorkspaceFileVfsFoldersInput }) => - loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { - if (!context) throw new OrchestrationError('not_found', 'Workspace not found') - return context - }), - async execute({ principal, input, context }) { - const paths = normalizeReferences(input.paths) - const userId = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }).attributedUserId - const createdFolders: CreatedFolder[] = [] - const outcomes: WorkspaceFileVfsOutcome[] = [] - for (const path of paths) { - if (path.segments.length === 0) { - outcomes.push({ - source: path.source, - resourceType: 'folder', - error: 'Path must include at least one folder segment', - }) - continue - } - try { - const folderId = await ensureFolderPath({ - workspaceId: context.workspaceId, - userId, - segments: path.segments, - createdFolders, - }) - outcomes.push({ - source: path.source, - targetSegments: path.segments, - resourceType: 'folder', - resourceId: folderId ?? undefined, - }) - } catch (error) { - outcomes.push({ - source: path.source, - resourceType: 'folder', - error: expectedOutcomeMessage(error), - }) - } - } - return { outcomes, createdFolders } - }, - projectAudit: ({ result }) => createdFolderAudits(result.createdFolders), - afterSuccess: ({ context, result }) => - result.createdFolders.length > 0 ? notifyWorkspaceFilesChanged(context.workspaceId) : undefined, -}) - -export interface RelocateWorkspaceFileVfsItemsInput { - workspaceId: string - sources: WorkspaceFileVfsPathReference[] - destination: WorkspaceFileVfsDestination -} - -export const relocateWorkspaceFileVfsItems = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.relocateVfsItems, - resolveContext: ({ input }: { input: RelocateWorkspaceFileVfsItemsInput }) => - loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { - if (!context) throw new OrchestrationError('not_found', 'Workspace not found') - return context - }), - async execute({ principal, input, context }) { - const sources = normalizeReferences(input.sources) - try { - validateVfsPathSegments(input.destination.segments) - } catch (error) { - if (error instanceof VfsPathLimitError) { - throw new OrchestrationError('validation', error.message) - } - throw error - } - const references = [] - for (const source of sources) { - references.push(await resolveSource(context.workspaceId, source)) - } - const userId = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }).attributedUserId - const destinationPath = buildFolderPath(input.destination.segments) - const existingDestination = await findWorkspaceFileFolderIdByPath( - context.workspaceId, - input.destination.segments - ) - const dirMode = - input.destination.segments.length === 0 || - input.destination.trailingSlash || - existingDestination !== null - if (!dirMode && sources.length > 1) { - throw new OrchestrationError( - 'validation', - `With multiple sources the destination must be a folder. "${destinationPath}" does not exist — end it with "/" to create it.` - ) - } - const folderSegments = dirMode - ? input.destination.segments - : input.destination.segments.slice(0, -1) - const leafName = dirMode ? undefined : input.destination.segments.at(-1) - const createdFolders: CreatedFolder[] = [] - let targetFolderPromise: Promise | undefined - const targetFolderId = () => - (targetFolderPromise ??= ensureFolderPath({ - workspaceId: context.workspaceId, - userId, - segments: folderSegments, - createdFolders, - })) - const outcomes: WorkspaceFileVfsOutcome[] = [] - const relocatedFiles: RelocatedFile[] = [] - const relocatedFolders: RelocatedFolder[] = [] - - for (const reference of references) { - if ('error' in reference) { - outcomes.push({ source: reference.source, resourceType: 'file', error: reference.error }) - continue - } - try { - const targetId = await targetFolderId() - if ('file' in reference) { - const name = leafName ?? reference.file.name - const result = await moveRenameWorkspaceFile({ - workspaceId: context.workspaceId, - fileId: reference.file.id, - targetFolderId: targetId, - newName: name, - }) - relocatedFiles.push({ - id: result.file.id, - name: result.file.name, - moved: result.moved, - renamed: result.renamed, - }) - outcomes.push({ - source: reference.source, - targetSegments: [...folderSegments, result.file.name], - resourceType: 'file', - resourceId: result.file.id, - }) - continue - } - if (targetId === reference.folderId) { - outcomes.push({ - source: reference.source, - resourceType: 'folder', - error: 'Cannot move a folder into itself', - }) - continue - } - const sourcePath = buildFolderPath( - sources.find((source) => source.source === reference.source)?.segments ?? [] - ) - const name = - leafName ?? sources.find((source) => source.source === reference.source)?.segments.at(-1) - if (!name) throw new OrchestrationError('validation', 'Folder name is required') - const nextPath = buildFolderPath([...folderSegments, name]) - const result = await relocateWorkspaceFileFolderByPath({ - workspaceId: context.workspaceId, - path: sourcePath, - destinationPath: nextPath, - }) - relocatedFolders.push({ - id: result.folder.id, - name: result.folder.name, - sourcePath, - destinationPath: result.path, - }) - outcomes.push({ - source: reference.source, - targetSegments: [...folderSegments, result.folder.name], - resourceType: 'folder', - resourceId: result.folder.id, - }) - } catch (error) { - outcomes.push({ - source: reference.source, - resourceType: 'file' in reference ? 'file' : 'folder', - error: expectedOutcomeMessage(error), - }) - } - } - return { outcomes, createdFolders, relocatedFiles, relocatedFolders } - }, - projectAudit: ({ result }) => [ - ...createdFolderAudits(result.createdFolders), - ...result.relocatedFiles - .filter((file) => file.moved || file.renamed) - .map((file) => ({ - action: file.moved ? AuditAction.FILE_MOVED : AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.name, - description: `Relocated file "${file.name}"`, - metadata: { moved: file.moved, renamed: file.renamed }, - })), - ...result.relocatedFolders.map((folder) => ({ - action: AuditAction.FOLDER_MOVED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Moved file folder to "${folder.destinationPath}"`, - metadata: { sourcePath: folder.sourcePath, destinationPath: folder.destinationPath }, - })), - ], - afterSuccess: ({ context, result }) => - result.createdFolders.length > 0 || - result.relocatedFiles.some((file) => file.moved || file.renamed) || - result.relocatedFolders.length > 0 - ? notifyWorkspaceFilesChanged(context.workspaceId) - : undefined, -}) - -export interface DeleteWorkspaceFileVfsItemsInput { - workspaceId: string - paths: WorkspaceFileVfsPathReference[] -} - -export const deleteWorkspaceFileVfsItems = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.deleteVfsItems, - resolveContext: ({ input }: { input: DeleteWorkspaceFileVfsItemsInput }) => - loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { - if (!context) throw new OrchestrationError('not_found', 'Workspace not found') - return context - }), - async execute({ input, context }) { - const paths = normalizeReferences(input.paths) - const references = [] - for (const path of paths) { - references.push(await resolveSource(context.workspaceId, path)) - } - const outcomes: WorkspaceFileVfsOutcome[] = [] - const deletedFiles: Array<{ id: string; name: string }> = [] - const deletedFolders: Array<{ id: string; path: string }> = [] - for (const reference of references) { - if ('error' in reference) { - outcomes.push({ source: reference.source, resourceType: 'file', error: reference.error }) - continue - } - try { - if ('file' in reference) { - const archived = await bulkArchiveWorkspaceFileItems({ - workspaceId: context.workspaceId, - fileIds: [reference.file.id], - }) - if (!archived.fileIds.includes(reference.file.id)) { - throw new OrchestrationError('not_found', 'File not found') - } - deletedFiles.push({ id: reference.file.id, name: reference.file.name }) - outcomes.push({ - source: reference.source, - resourceType: 'file', - resourceId: reference.file.id, - }) - continue - } - const archived = await bulkArchiveWorkspaceFileItems({ - workspaceId: context.workspaceId, - folderIds: [reference.folderId], - }) - if (!archived.folderIds.includes(reference.folderId)) { - throw new OrchestrationError('not_found', 'Folder not found') - } - deletedFolders.push({ id: reference.folderId, path: reference.source }) - outcomes.push({ - source: reference.source, - resourceType: 'folder', - resourceId: reference.folderId, - }) - } catch (error) { - outcomes.push({ - source: reference.source, - resourceType: 'file' in reference ? 'file' : 'folder', - error: expectedOutcomeMessage(error), - }) - } - } - return { outcomes, deletedFiles, deletedFolders } - }, - projectAudit: ({ result }) => [ - ...result.deletedFiles.map((file) => ({ - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.name, - description: `Archived file "${file.name}"`, - })), - ...result.deletedFolders.map((folder) => ({ - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - description: `Archived file folder "${folder.path}"`, - metadata: { path: folder.path }, - })), - ], - afterSuccess: ({ context, result }) => - result.deletedFiles.length > 0 || result.deletedFolders.length > 0 - ? notifyWorkspaceFilesChanged(context.workspaceId) - : undefined, -}) diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 889d5e6ebbb..ac07eebc424 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -35,4 +35,3 @@ export { performRestoreWorkspaceFileFolder, performUpdateWorkspaceFileFolder, } from './file-folder-lifecycle' -export {} from './share' diff --git a/apps/sim/lib/workspace-files/orchestration/share.ts b/apps/sim/lib/workspace-files/orchestration/share.ts deleted file mode 100644 index 394a6a5e3fc..00000000000 --- a/apps/sim/lib/workspace-files/orchestration/share.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createLogger } from '@sim/logger' - -const logger = createLogger('WorkspaceFileShareOrchestration') diff --git a/apps/sim/package.json b/apps/sim/package.json index 3385c63efa3..c26813ebfbd 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -70,9 +70,9 @@ "@better-auth/sso": "1.6.27", "@better-auth/stripe": "1.6.27", "@browserbasehq/stagehand": "^3.2.1", - "@calcom/embed-react": "1.5.3", "@c15t/nextjs": "2.2.1", "@c15t/scripts": "2.2.0", + "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@daytona/sdk": "0.207.0", "@e2b/code-interpreter": "2.7.1", @@ -140,10 +140,12 @@ "@tiptap/extension-code-block": "3.30.5", "@tiptap/extension-collaboration": "3.30.5", "@tiptap/extension-collaboration-caret": "3.30.5", + "@tiptap/extension-document": "3.30.5", + "@tiptap/extension-hard-break": "3.30.5", "@tiptap/extension-image": "3.30.5", "@tiptap/extension-list": "3.30.5", - "@tiptap/extension-placeholder": "3.30.5", "@tiptap/extension-paragraph": "3.30.5", + "@tiptap/extension-placeholder": "3.30.5", "@tiptap/extension-table": "3.30.5", "@tiptap/markdown": "3.30.5", "@tiptap/pm": "3.30.5", @@ -262,6 +264,7 @@ }, "devDependencies": { "@next/env": "16.3.4", + "@opentelemetry/context-async-hooks": "2.10.0", "@sim/testing": "workspace:*", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", @@ -270,6 +273,7 @@ "@trigger.dev/build": "4.5.16", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", + "@types/hast": "3.0.4", "@types/heic-convert": "2.1.1", "@types/html-to-text": "9.0.4", "@types/js-yaml": "4.0.9", diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 4c79a0da1b6..d68dbc3a97d 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -38,8 +38,6 @@ vi.mock('@/providers/azure-openai/utils', () => ({ extractBaseUrl: vi.fn((url: string) => url), extractDeploymentFromUrl: vi.fn(() => null), extractApiVersionFromUrl: vi.fn(() => null), - createReadableStreamFromAzureOpenAIStream: vi.fn(), - checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index e4092d00163..4d922b52237 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -19,8 +19,6 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { prepareProviderAttachments } from '@/providers/attachments' import { - checkForForcedToolUsage, - createReadableStreamFromAzureOpenAIStream, extractApiVersionFromUrl, extractBaseUrl, extractDeploymentFromUrl, @@ -42,6 +40,7 @@ import { import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -59,6 +58,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + checkForForcedToolUsageOpenAI, isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, @@ -241,9 +241,10 @@ async function executeChatCompletionsRequest( initialCost: { input: 0, output: 0, total: 0 }, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAzureOpenAIStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'Azure OpenAI', + request, + onComplete: ({ content, usage }) => { output.content = content output.tokens = { input: usage.prompt_tokens, @@ -264,8 +265,7 @@ async function executeChatCompletionsRequest( finalizeTiming() }, - request - ), + }), }) return streamingResult @@ -321,12 +321,13 @@ async function executeChatCompletionsRequest( { model: request.model, provider: 'azure_openai' } ) - const firstCheckResult = checkForForcedToolUsage( + const firstCheckResult = checkForForcedToolUsageOpenAI( currentResponse, originalToolChoice ?? 'auto', - logger, + 'Azure OpenAI', forcedTools, - usedForcedTools + usedForcedTools, + logger ) hasUsedForcedTool = firstCheckResult.hasUsedForcedTool usedForcedTools = firstCheckResult.usedForcedTools @@ -543,12 +544,13 @@ async function executeChatCompletionsRequest( ) } - const nextCheckResult = checkForForcedToolUsage( + const nextCheckResult = checkForForcedToolUsageOpenAI( currentResponse, nextPayload.tool_choice ?? 'auto', - logger, + 'Azure OpenAI', forcedTools, - usedForcedTools + usedForcedTools, + logger ) hasUsedForcedTool = nextCheckResult.hasUsedForcedTool usedForcedTools = nextCheckResult.usedForcedTools diff --git a/apps/sim/providers/azure-openai/utils.ts b/apps/sim/providers/azure-openai/utils.ts index 437233a7f77..997683be263 100644 --- a/apps/sim/providers/azure-openai/utils.ts +++ b/apps/sim/providers/azure-openai/utils.ts @@ -1,52 +1,3 @@ -import type { Logger } from '@sim/logger' -import type OpenAI from 'openai' -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import type { Stream } from 'openai/streaming' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' - -/** - * Creates an agent-events stream from an Azure OpenAI streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromAzureOpenAIStream( - azureOpenAIStream: Stream, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(azureOpenAIStream, { - request, - providerName: 'Azure OpenAI', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - -/** - * Checks if a forced tool was used in an Azure OpenAI response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ -export function checkForForcedToolUsage( - response: OpenAI.Chat.Completions.ChatCompletion, - toolChoice: string | { type: string; function?: { name: string }; name?: string }, - _logger: Logger, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI( - response, - toolChoice, - 'Azure OpenAI', - forcedTools, - usedForcedTools, - _logger - ) -} - /** * Determines if an Azure OpenAI endpoint URL is for the chat completions API. * Returns true for URLs containing /chat/completions pattern. diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index 2cf31ff5f25..808252f51f6 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -8,10 +8,6 @@ import { toolsMock, toolsMockFns } from '@sim/testing/mocks/tools.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { StreamingExecution } from '@/executor/types' -const { mockSupportsNativeStructuredOutputs } = vi.hoisted(() => ({ - mockSupportsNativeStructuredOutputs: vi.fn(), -})) - vi.mock('openai', () => openaiMock) vi.mock('@/providers', () => providersMock) @@ -20,14 +16,6 @@ vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) -vi.mock('@/providers/baseten/utils', () => ({ - supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn( - () => new ReadableStream({ start: (controller) => controller.close() }) - ), - checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), -})) - vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) vi.mock('@/providers/utils', () => providersUtilsMock) @@ -83,7 +71,6 @@ const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0] describe('basetenProvider', () => { beforeEach(() => { - mockSupportsNativeStructuredOutputs.mockResolvedValue(true) mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({ tools, toolChoice: 'auto', @@ -120,7 +107,7 @@ describe('basetenProvider', () => { }) it('streams directly when there are no tools', async () => { - mockCreate.mockResolvedValueOnce({}) + mockCreate.mockResolvedValueOnce((async function* () {})()) const result = await basetenProvider.executeRequest({ ...baseRequest, stream: true }) @@ -144,22 +131,6 @@ describe('basetenProvider', () => { expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict') }) - it('falls back to json_object with prompt instructions when native is unsupported', async () => { - mockSupportsNativeStructuredOutputs.mockResolvedValue(false) - mockCreate.mockResolvedValueOnce(textResponse('{}')) - - await basetenProvider.executeRequest({ - ...baseRequest, - responseFormat: { name: 'my_schema', schema: { type: 'object' } }, - }) - - expect(lastCallBody().response_format).toEqual({ type: 'json_object' }) - expect(lastCallBody().messages.at(-1)).toEqual({ - role: 'user', - content: 'SCHEMA_INSTRUCTIONS', - }) - }) - it('defers response_format to a final call when tools are active', async () => { mockCreate .mockResolvedValueOnce(textResponse('intermediate')) diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index 6f2af6683d3..bebbf0e4975 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -1,83 +1,33 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' -import { MAX_TOOL_ITERATIONS } from '@/providers' -import { formatMessagesForProvider } from '@/providers/attachments' -import { - checkForForcedToolUsage, - createReadableStreamFromOpenAIStream, - supportsNativeStructuredOutputs, -} from '@/providers/baseten/utils' -import { - isConversationContextError, - prepareConversationGeneration, -} from '@/providers/conversation-generation' -import { - captureProviderConversationStep, - recordProviderConversationToolError, -} from '@/providers/conversation-history' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' -import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' -import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' -import { executeProviderTool } from '@/providers/runtime-context' -import { createSettledAgentEventStream } from '@/providers/stream-events' -import { createStreamingExecution } from '@/providers/streaming-execution' -import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' -import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' -import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' -import { openAICompatTransport } from '@/providers/transport' -import type { - FunctionCallResponse, - Message, - ProviderConfig, - ProviderRequest, - ProviderResponse, - TimeSegment, -} from '@/providers/types' -import { ProviderError } from '@/providers/types' import { - calculateCost, - generateSchemaInstructions, - isFunctionToolCall, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' + type ChatCompletionPayload, + executeChatCompletionRequest, +} from '@/providers/openai-compat/chat-completions' +import { openAICompatTransport } from '@/providers/transport' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' const logger = createLogger('BasetenProvider') -/** - * Applies structured output configuration to a payload based on model capabilities. - * Uses native json_schema for supported models, falls back to json_object with prompt instructions. - */ +/** Baseten accepts native JSON schemas. */ async function applyResponseFormat( - targetPayload: any, - messages: any[], - responseFormat: any, + targetPayload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable, model: string -): Promise { - const useNative = await supportsNativeStructuredOutputs(model) - - if (useNative) { - logger.info('Using native structured outputs for Baseten model', { model }) - targetPayload.response_format = { - type: 'json_schema', - json_schema: { - name: responseFormat.name || 'response_schema', - schema: responseFormat.schema || responseFormat, - }, - } - return messages +): Promise { + logger.info('Using native structured outputs for Baseten model', { model }) + targetPayload.response_format = { + type: 'json_schema', + json_schema: { + name: responseFormat.name || 'response_schema', + schema: responseFormat.schema || responseFormat, + }, } - - logger.info('Using json_object mode with prompt instructions for Baseten model', { model }) - const schema = responseFormat.schema || responseFormat - const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name) - targetPayload.response_format = { type: 'json_object' } - return [...messages, { role: 'user', content: schemaInstructions }] + return messages } export const basetenProvider: ProviderConfig = { @@ -113,618 +63,15 @@ export const basetenProvider: ProviderConfig = { stream: !!request.stream, }) - const allMessages: Message[] = [] - - if (request.systemPrompt) { - allMessages.push({ role: 'system', content: request.systemPrompt }) - } - - if (request.context) { - allMessages.push({ role: 'user', content: request.context }) - } - - if (request.messages) { - allMessages.push(...request.messages) - } - const formattedMessages = formatMessagesForProvider(allMessages, 'baseten') as Message[] - - const tools = request.tools?.length - ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) - : undefined - - const payload: any = { - model: requestedModel, - messages: formattedMessages, - } - - if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens != null) payload.max_tokens = request.maxTokens - - let preparedTools: ReturnType | null = null - let hasActiveTools = false - if (tools?.length) { - preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'baseten') - const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { - payload.tools = filteredTools - payload.tool_choice = toolChoice - hasActiveTools = true - } - } - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - if (request.responseFormat && !hasActiveTools) { - payload.messages = await applyResponseFormat( - payload, - payload.messages, - request.responseFormat, - requestedModel - ) - } - - if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', streamingParams), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { kind: 'simple', segmentName: request.model }, - initialTokens: { input: 0, output: 0, total: 0 }, - initialCost: { input: 0, output: 0, total: 0 }, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromOpenAIStream( - streamResponse, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - finalizeTiming() - }, - request - ), - }) - - return streamingResult - } - - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', payload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const firstResponseTime = Date.now() - initialCallTime - - let content = currentResponse.choices[0]?.message?.content || '' - const tokens = { - input: currentResponse.usage?.prompt_tokens || 0, - output: currentResponse.usage?.completion_tokens || 0, - total: currentResponse.usage?.total_tokens || 0, - } - const toolCalls: FunctionCallResponse[] = [] - const toolResults: Record[] = [] - const currentMessages = [...formattedMessages] - let iterationCount = 0 - let modelTime = firstResponseTime - let toolsTime = 0 - let hasUsedForcedTool = false - const timeSegments: TimeSegment[] = [ - { - type: 'model', - name: request.model, - startTime: initialCallTime, - endTime: initialCallTime + firstResponseTime, - duration: firstResponseTime, - }, - ] - - const forcedToolResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = forcedToolResult.hasUsedForcedTool - usedForcedTools = forcedToolResult.usedForcedTools - - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - - const toolCallsInResponse = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - toolCallsInResponse, - { model: request.model, provider: 'baseten' } - ) - - if (!toolCallsInResponse || toolCallsInResponse.length === 0) { - break - } - - const toolsStartTime = Date.now() - - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { - const toolCallStartTime = Date.now() - const toolName = toolCall.function.name - - try { - const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) - const tool = request.tools?.find((t) => t.id === toolName) - - if (!tool) { - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - `Tool "${toolName}" is not available` - ) - const toolCallEndTime = Date.now() - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: `Tool "${toolName}" is not available`, - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - - const { toolParams, executionParams } = prepareToolExecution( - tool, - toolArgs, - request, - toolCall.id - ) - const { rawResponse, modelResponse } = await executeProviderTool( - toolName, - executionParams, - { - signal: request.abortSignal, - } - ) - const toolCallEndTime = Date.now() - - return { - toolCall, - toolName, - toolParams, - result: rawResponse, - modelResult: modelResponse, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - throw error - } - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - getErrorMessage(error, 'Tool execution failed') - ) - const toolCallEndTime = Date.now() - logger.error('Error processing tool call (Baseten):', { - error: toError(error).message, - toolName, - }) - - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: getErrorMessage(error, 'Tool execution failed'), - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - }) - - const executionResults = await Promise.all(toolExecutionPromises) - const assistantMessage = currentResponse.choices[0]?.message - if (assistantMessage) { - currentMessages.push( - createOpenAICompatAssistantHistory({ - message: assistantMessage, - toolCalls: toolCallsInResponse, - reasoningFields: ['reasoning_content'], - }) - ) - } - - for (const executionResult of executionResults) { - const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - executionResult - const modelResult = - 'modelResult' in executionResult ? (executionResult.modelResult ?? result) : result - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolCall.id, - }) - - let resultContent: unknown - if (result.success) { - if (isRecordLike(result.output)) { - toolResults.push(result.output) - } - resultContent = result.output ?? null - } else { - resultContent = { - error: true, - message: result.error || 'Tool execution failed', - tool: toolName, - } - } - const modelResultContent = modelResult.success - ? (modelResult.output ?? null) - : { - error: true, - message: modelResult.error || 'Tool execution failed', - tool: toolName, - } - - toolCalls.push({ - name: toolName, - arguments: toolParams, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - duration: duration, - result: resultContent, - success: result.success, - }) - - currentMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: JSON.stringify(modelResultContent), - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload = { - ...payload, - messages: currentMessages, - } - - if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - if (remainingTools.length > 0) { - nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } } - } else { - nextPayload.tool_choice = 'auto' - } - } - - const nextModelStartTime = Date.now() - currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', nextPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const nextForcedToolResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool - usedForcedTools = nextForcedToolResult.usedForcedTools - const nextModelEndTime = Date.now() - const thisModelTime = nextModelEndTime - nextModelStartTime - timeSegments.push({ - type: 'model', - name: request.model, - startTime: nextModelStartTime, - endTime: nextModelEndTime, - duration: thisModelTime, - }) - modelTime += thisModelTime - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - if (currentResponse.usage) { - tokens.input += currentResponse.usage.prompt_tokens || 0 - tokens.output += currentResponse.usage.completion_tokens || 0 - tokens.total += currentResponse.usage.total_tokens || 0 - } - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { - model: request.model, - provider: 'baseten', - }) - - if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { - const finalPayload: any = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - } - - if (request.responseFormat) { - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - } - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final answer after tool iteration limit', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'baseten' } - ) - } - } - - if (request.responseFormat && hasActiveTools) { - const finalPayload: any = { - model: payload.model, - messages: [...currentMessages], - } - if (payload.temperature !== undefined) { - finalPayload.temperature = payload.temperature - } - if (payload.max_tokens !== undefined) { - finalPayload.max_tokens = payload.max_tokens - } - - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final structured response', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'baseten' } - ) - } - - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) - const toolCost = sumToolCosts(toolResults) - const finalCost = { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: toolCost || undefined, - total: accumulatedCost.total + toolCost, - } - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: finalCost, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => { - output.content = content - output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } - output.cost = finalCost - finalizeTiming() - return createSettledAgentEventStream(content) - }, - }) - - return streamingResult - } - - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - return { - content, - model: requestedModel, - tokens, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - toolResults: toolResults.length > 0 ? toolResults : undefined, - timing: { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments: timeSegments, - }, - } - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - const errorDetails: Record = { - error: toError(error).message, - duration: totalDuration, - } - if (error && typeof error === 'object') { - const err = error as any - if (err.status) errorDetails.status = err.status - if (err.code) errorDetails.code = err.code - if (err.type) errorDetails.type = err.type - if (err.error?.message) errorDetails.providerMessage = err.error.message - if (err.error?.metadata) errorDetails.metadata = err.error.metadata - } - - logger.error('Error in Baseten request:', errorDetails) - if ( - isAbortError(error) || - request.abortSignal?.aborted || - isConversationContextError(error) - ) { - throw error - } - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } + return executeChatCompletionRequest(request, { + providerId: 'baseten', + providerName: 'Baseten', + client, + requestedModel, + reportedModel: requestedModel, + logger, + applyResponseFormat, + reasoningFields: ['reasoning_content'], + }) }, } diff --git a/apps/sim/providers/baseten/utils.ts b/apps/sim/providers/baseten/utils.ts deleted file mode 100644 index 5be9a773f45..00000000000 --- a/apps/sim/providers/baseten/utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' - -/** - * Checks if a model supports native structured outputs (json_schema). - * Baseten Model APIs support structured outputs across their OpenAI-compatible inference API. - */ -export async function supportsNativeStructuredOutputs(_modelId: string): Promise { - return true -} - -/** - * Creates an agent-events stream from a Baseten streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromOpenAIStream( - openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(openaiStream, { - request, - providerName: 'Baseten', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - -/** - * Checks if a forced tool was used in a Baseten response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ -export function checkForForcedToolUsage( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI( - response, - toolChoice, - 'Baseten', - forcedTools, - usedForcedTools - ) -} diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 6acf7b4dbc1..17de3ce5578 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -6,7 +6,6 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import type { CerebrasResponse } from '@/providers/cerebras/types' -import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { isConversationContextError, prepareConversationGeneration, @@ -18,6 +17,7 @@ import { import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -154,9 +154,10 @@ export const cerebrasProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromCerebrasStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'Cerebras', + request, + onComplete: ({ content, usage }) => { output.content = content output.tokens = { input: usage.prompt_tokens, @@ -175,8 +176,7 @@ export const cerebrasProvider: ProviderConfig = { total: costResult.total, } }, - request - ), + }), }) return streamingResult diff --git a/apps/sim/providers/cerebras/utils.ts b/apps/sim/providers/cerebras/utils.ts deleted file mode 100644 index 9c4a2e1c00f..00000000000 --- a/apps/sim/providers/cerebras/utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -interface CerebrasChunk { - choices?: Array<{ - delta?: { - content?: string - } - }> - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - } -} - -/** - * Creates an agent-events stream from a Cerebras streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromCerebrasStream( - cerebrasStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(cerebrasStream as any, { - request, - providerName: 'Cerebras', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/conversation-generation-coverage.test.ts b/apps/sim/providers/conversation-generation-coverage.test.ts index 37ef9075e02..6d7072d39e2 100644 --- a/apps/sim/providers/conversation-generation-coverage.test.ts +++ b/apps/sim/providers/conversation-generation-coverage.test.ts @@ -119,6 +119,7 @@ describe('provider generation context coverage', () => { visit(source) } expect(uncovered).toEqual([]) - expect(guarded).toBeGreaterThan(90) + /** Guard discovery must be nonempty; consolidation can reduce the number of SDK sends. */ + expect(guarded).toBeGreaterThan(0) }) }) diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index 131c5ab1732..add253f8db3 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -17,10 +17,6 @@ vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) -vi.mock('@/providers/deepseek/utils', () => ({ - createReadableStreamFromDeepseekStream: vi.fn(), -})) - vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ createOpenAICompatStreamingToolLoopStream: vi.fn(), })) diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index d36f4864932..a0149482777 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -13,9 +13,9 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' -import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { executeProviderTool } from '@/providers/runtime-context' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -240,37 +240,40 @@ export const deepseekProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromDeepseekStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage, thinking) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + { + providerName: 'Deepseek', + request, + onComplete: ({ content, usage, thinking }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } - if (thinking) { - const segment = output.providerTiming?.timeSegments?.[0] - if (segment) { - segment.thinkingContent = thinking + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } } - } - finalizeTiming() - }, - request + finalizeTiming() + }, + } ), }) diff --git a/apps/sim/providers/deepseek/utils.ts b/apps/sim/providers/deepseek/utils.ts deleted file mode 100644 index 195d887a159..00000000000 --- a/apps/sim/providers/deepseek/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a DeepSeek streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromDeepseekStream( - deepseekStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(deepseekStream, { - request, - providerName: 'Deepseek', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index b955472bf24..62c8068059a 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -8,8 +8,7 @@ import { toolsMock, toolsMockFns } from '@sim/testing/mocks/tools.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { StreamingExecution } from '@/executor/types' -const { mockSupportsNativeStructuredOutputs, mockResolveFireworksWireModel } = vi.hoisted(() => ({ - mockSupportsNativeStructuredOutputs: vi.fn(), +const { mockResolveFireworksWireModel } = vi.hoisted(() => ({ mockResolveFireworksWireModel: vi.fn(), })) @@ -22,11 +21,6 @@ vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) vi.mock('@/providers/fireworks/utils', () => ({ - supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn( - () => new ReadableStream({ start: (controller) => controller.close() }) - ), - checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), resolveFireworksWireModel: mockResolveFireworksWireModel, })) @@ -85,7 +79,6 @@ const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0] describe('fireworksProvider', () => { beforeEach(() => { - mockSupportsNativeStructuredOutputs.mockResolvedValue(true) mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({ tools, toolChoice: 'auto', @@ -143,7 +136,7 @@ describe('fireworksProvider', () => { }) it('streams directly when there are no tools', async () => { - mockCreate.mockResolvedValueOnce({}) + mockCreate.mockResolvedValueOnce((async function* () {})()) const result = await fireworksProvider.executeRequest({ ...baseRequest, stream: true }) @@ -167,22 +160,6 @@ describe('fireworksProvider', () => { expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict') }) - it('falls back to json_object with prompt instructions when native is unsupported', async () => { - mockSupportsNativeStructuredOutputs.mockResolvedValue(false) - mockCreate.mockResolvedValueOnce(textResponse('{}')) - - await fireworksProvider.executeRequest({ - ...baseRequest, - responseFormat: { name: 'my_schema', schema: { type: 'object' } }, - }) - - expect(lastCallBody().response_format).toEqual({ type: 'json_object' }) - expect(lastCallBody().messages.at(-1)).toEqual({ - role: 'user', - content: 'SCHEMA_INSTRUCTIONS', - }) - }) - it('defers response_format to a final call when tools are active', async () => { mockCreate .mockResolvedValueOnce(textResponse('intermediate')) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 35943dd1ac7..866b9e7640c 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -1,84 +1,34 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' -import { MAX_TOOL_ITERATIONS } from '@/providers' -import { formatMessagesForProvider } from '@/providers/attachments' -import { - isConversationContextError, - prepareConversationGeneration, -} from '@/providers/conversation-generation' -import { - captureProviderConversationStep, - recordProviderConversationToolError, -} from '@/providers/conversation-history' -import { - checkForForcedToolUsage, - createReadableStreamFromOpenAIStream, - resolveFireworksWireModel, - supportsNativeStructuredOutputs, -} from '@/providers/fireworks/utils' +import { resolveFireworksWireModel } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' -import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' -import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' -import { executeProviderTool } from '@/providers/runtime-context' -import { createSettledAgentEventStream } from '@/providers/stream-events' -import { createStreamingExecution } from '@/providers/streaming-execution' -import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' -import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' -import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' -import { openAICompatTransport } from '@/providers/transport' -import type { - FunctionCallResponse, - Message, - ProviderConfig, - ProviderRequest, - ProviderResponse, - TimeSegment, -} from '@/providers/types' -import { ProviderError } from '@/providers/types' import { - calculateCost, - generateSchemaInstructions, - isFunctionToolCall, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' + type ChatCompletionPayload, + executeChatCompletionRequest, +} from '@/providers/openai-compat/chat-completions' +import { openAICompatTransport } from '@/providers/transport' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' const logger = createLogger('FireworksProvider') -/** - * Applies structured output configuration to a payload based on model capabilities. - * Uses native json_schema for supported models, falls back to json_object with prompt instructions. - */ +/** Fireworks accepts native JSON schemas. */ async function applyResponseFormat( - targetPayload: any, - messages: any[], - responseFormat: any, + targetPayload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable, model: string -): Promise { - const useNative = await supportsNativeStructuredOutputs(model) - - if (useNative) { - logger.info('Using native structured outputs for Fireworks model', { model }) - targetPayload.response_format = { - type: 'json_schema', - json_schema: { - name: responseFormat.name || 'response_schema', - schema: responseFormat.schema || responseFormat, - }, - } - return messages +): Promise { + logger.info('Using native structured outputs for Fireworks model', { model }) + targetPayload.response_format = { + type: 'json_schema', + json_schema: { + name: responseFormat.name || 'response_schema', + schema: responseFormat.schema || responseFormat, + }, } - - logger.info('Using json_object mode with prompt instructions for Fireworks model', { model }) - const schema = responseFormat.schema || responseFormat - const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name) - targetPayload.response_format = { type: 'json_object' } - return [...messages, { role: 'user', content: schemaInstructions }] + return messages } export const fireworksProvider: ProviderConfig = { @@ -114,624 +64,15 @@ export const fireworksProvider: ProviderConfig = { stream: !!request.stream, }) - const allMessages: Message[] = [] - - if (request.systemPrompt) { - allMessages.push({ role: 'system', content: request.systemPrompt }) - } - - if (request.context) { - allMessages.push({ role: 'user', content: request.context }) - } - - if (request.messages) { - allMessages.push(...request.messages) - } - const formattedMessages = formatMessagesForProvider(allMessages, 'fireworks') as Message[] - - const tools = request.tools?.length - ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) - : undefined - - const payload: any = { - model: requestedModel, - messages: formattedMessages, - } - - if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens != null) payload.max_tokens = request.maxTokens - - let preparedTools: ReturnType | null = null - let hasActiveTools = false - if (tools?.length) { - preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'fireworks') - const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { - payload.tools = filteredTools - payload.tool_choice = toolChoice - hasActiveTools = true - } - } - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - if (request.responseFormat && !hasActiveTools) { - payload.messages = await applyResponseFormat( - payload, - payload.messages, - request.responseFormat, - requestedModel - ) - } - - if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', streamingParams), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - // Echo the catalog id, never the wire name: it is the billing and - // logging identity (cost policy, ledger row, trace span, model - // breakdown) the way every other Sim-hosted provider reports it. - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { kind: 'simple', segmentName: request.model }, - initialTokens: { input: 0, output: 0, total: 0 }, - initialCost: { input: 0, output: 0, total: 0 }, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromOpenAIStream( - streamResponse, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - // Pricing keys on the catalog id (fireworks/), not the wire - // name — static hosted entries price; dynamic ids stay unpriced. - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - finalizeTiming() - }, - request - ), - }) - - return streamingResult - } - - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', payload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const firstResponseTime = Date.now() - initialCallTime - - let content = currentResponse.choices[0]?.message?.content || '' - const tokens = { - input: currentResponse.usage?.prompt_tokens || 0, - output: currentResponse.usage?.completion_tokens || 0, - total: currentResponse.usage?.total_tokens || 0, - } - const toolCalls: FunctionCallResponse[] = [] - const toolResults: Record[] = [] - const currentMessages = [...formattedMessages] - let iterationCount = 0 - let modelTime = firstResponseTime - let toolsTime = 0 - let hasUsedForcedTool = false - const timeSegments: TimeSegment[] = [ - { - type: 'model', - name: request.model, - startTime: initialCallTime, - endTime: initialCallTime + firstResponseTime, - duration: firstResponseTime, - }, - ] - - const forcedToolResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = forcedToolResult.hasUsedForcedTool - usedForcedTools = forcedToolResult.usedForcedTools - - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - - const toolCallsInResponse = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - toolCallsInResponse, - { model: request.model, provider: 'fireworks' } - ) - - if (!toolCallsInResponse || toolCallsInResponse.length === 0) { - break - } - - const toolsStartTime = Date.now() - - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { - const toolCallStartTime = Date.now() - const toolName = toolCall.function.name - - try { - const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) - const tool = request.tools?.find((t) => t.id === toolName) - - if (!tool) { - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - `Tool "${toolName}" is not available` - ) - const toolCallEndTime = Date.now() - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: `Tool "${toolName}" is not available`, - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - - const { toolParams, executionParams } = prepareToolExecution( - tool, - toolArgs, - request, - toolCall.id - ) - const { rawResponse, modelResponse } = await executeProviderTool( - toolName, - executionParams, - { - signal: request.abortSignal, - } - ) - const toolCallEndTime = Date.now() - - return { - toolCall, - toolName, - toolParams, - result: rawResponse, - modelResult: modelResponse, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - throw error - } - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - getErrorMessage(error, 'Tool execution failed') - ) - const toolCallEndTime = Date.now() - logger.error('Error processing tool call (Fireworks):', { - error: toError(error).message, - toolName, - }) - - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: getErrorMessage(error, 'Tool execution failed'), - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - }) - - const executionResults = await Promise.all(toolExecutionPromises) - const assistantMessage = currentResponse.choices[0]?.message - if (assistantMessage) { - currentMessages.push( - createOpenAICompatAssistantHistory({ - message: assistantMessage, - toolCalls: toolCallsInResponse, - reasoningFields: ['reasoning_content'], - }) - ) - } - - for (const executionResult of executionResults) { - const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - executionResult - const modelResult = - 'modelResult' in executionResult ? (executionResult.modelResult ?? result) : result - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolCall.id, - }) - - let resultContent: unknown - if (result.success) { - if (isRecordLike(result.output)) { - toolResults.push(result.output) - } - resultContent = result.output ?? null - } else { - resultContent = { - error: true, - message: result.error || 'Tool execution failed', - tool: toolName, - } - } - const modelResultContent = modelResult.success - ? (modelResult.output ?? null) - : { - error: true, - message: modelResult.error || 'Tool execution failed', - tool: toolName, - } - - toolCalls.push({ - name: toolName, - arguments: toolParams, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - duration: duration, - result: resultContent, - success: result.success, - }) - - currentMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: JSON.stringify(modelResultContent), - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload = { - ...payload, - messages: currentMessages, - } - - if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - if (remainingTools.length > 0) { - nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } } - } else { - nextPayload.tool_choice = 'auto' - } - } - - const nextModelStartTime = Date.now() - currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', nextPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const nextForcedToolResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool - usedForcedTools = nextForcedToolResult.usedForcedTools - const nextModelEndTime = Date.now() - const thisModelTime = nextModelEndTime - nextModelStartTime - timeSegments.push({ - type: 'model', - name: request.model, - startTime: nextModelStartTime, - endTime: nextModelEndTime, - duration: thisModelTime, - }) - modelTime += thisModelTime - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - if (currentResponse.usage) { - tokens.input += currentResponse.usage.prompt_tokens || 0 - tokens.output += currentResponse.usage.completion_tokens || 0 - tokens.total += currentResponse.usage.total_tokens || 0 - } - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { - model: request.model, - provider: 'fireworks', - }) - - if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { - const finalPayload: any = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - } - - if (request.responseFormat) { - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - } - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final answer after tool iteration limit', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'fireworks' } - ) - } - } - - if (request.responseFormat && hasActiveTools) { - const finalPayload: any = { - model: payload.model, - messages: [...currentMessages], - } - if (payload.temperature !== undefined) { - finalPayload.temperature = payload.temperature - } - if (payload.max_tokens !== undefined) { - finalPayload.max_tokens = payload.max_tokens - } - - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final structured response', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'fireworks' } - ) - } - - if (request.stream) { - // Pricing keys on the catalog id (fireworks/), not the wire name. - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - const toolCost = sumToolCosts(toolResults) - const finalCost = { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: toolCost || undefined, - total: accumulatedCost.total + toolCost, - } - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: finalCost, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => { - output.content = content - output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } - output.cost = finalCost - finalizeTiming() - return createSettledAgentEventStream(content) - }, - }) - - return streamingResult - } - - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - return { - content, - model: request.model, - tokens, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - toolResults: toolResults.length > 0 ? toolResults : undefined, - timing: { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments: timeSegments, - }, - } - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - const errorDetails: Record = { - error: toError(error).message, - duration: totalDuration, - } - if (error && typeof error === 'object') { - const err = error as any - if (err.status) errorDetails.status = err.status - if (err.code) errorDetails.code = err.code - if (err.type) errorDetails.type = err.type - if (err.error?.message) errorDetails.providerMessage = err.error.message - if (err.error?.metadata) errorDetails.metadata = err.error.metadata - } - - logger.error('Error in Fireworks request:', errorDetails) - if ( - isAbortError(error) || - request.abortSignal?.aborted || - isConversationContextError(error) - ) { - throw error - } - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } + return executeChatCompletionRequest(request, { + providerId: 'fireworks', + providerName: 'Fireworks', + client, + requestedModel, + reportedModel: request.model, + logger, + applyResponseFormat, + reasoningFields: ['reasoning_content'], + }) }, } diff --git a/apps/sim/providers/fireworks/utils.ts b/apps/sim/providers/fireworks/utils.ts index 7f2baa1e8e5..a31ac50ba73 100644 --- a/apps/sim/providers/fireworks/utils.ts +++ b/apps/sim/providers/fireworks/utils.ts @@ -1,10 +1,3 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' - /** * Wire model names for the static hosted Fireworks catalog entries (the * sim-auto pool). Catalog ids are the short `fireworks/` form; the @@ -24,48 +17,3 @@ const FIREWORKS_WIRE_NAMES: Record = { export function resolveFireworksWireModel(strippedModel: string): string { return FIREWORKS_WIRE_NAMES[strippedModel] ?? strippedModel } - -/** - * Checks if a model supports native structured outputs (json_schema). - * Fireworks AI supports structured outputs across their inference API. - */ -export async function supportsNativeStructuredOutputs(_modelId: string): Promise { - return true -} - -/** - * Creates an agent-events stream from a Fireworks streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromOpenAIStream( - openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(openaiStream, { - request, - providerName: 'Fireworks', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - -/** - * Checks if a forced tool was used in a Fireworks response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ -export function checkForForcedToolUsage( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI( - response, - toolChoice, - 'Fireworks', - forcedTools, - usedForcedTools - ) -} diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index 5fc51626daf..8a6c7ae1a35 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -32,10 +32,6 @@ vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) -vi.mock('@/providers/groq/utils', () => ({ - createReadableStreamFromGroqStream: vi.fn(), -})) - vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ createOpenAICompatStreamingToolLoopStream: vi.fn(), })) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 386d898fc39..4d27d293896 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -19,9 +19,9 @@ import { recordProviderConversationToolError, recordProviderConversationUsage, } from '@/providers/conversation-history' -import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { executeProviderTool } from '@/providers/runtime-context' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -240,37 +240,40 @@ export const groqProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromGroqStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes streamResponse as unknown as AsyncIterable, - (content, usage, thinking) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + { + providerName: 'Groq', + request, + onComplete: ({ content, usage, thinking }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } - if (thinking) { - const segment = output.providerTiming?.timeSegments?.[0] - if (segment) { - segment.thinkingContent = thinking + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } } - } - finalizeTiming() - }, - request + finalizeTiming() + }, + } ), }) diff --git a/apps/sim/providers/groq/utils.ts b/apps/sim/providers/groq/utils.ts deleted file mode 100644 index a79be371119..00000000000 --- a/apps/sim/providers/groq/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a Groq streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromGroqStream( - groqStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(groqStream, { - request, - providerName: 'Groq', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index c2e1b1654b2..1aa9eb5ea68 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -14,7 +14,6 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' -import { createReadableStreamFromKimiStream } from '@/providers/kimi/utils' import { getModelCapabilities, getProviderDefaultModel, @@ -22,6 +21,7 @@ import { } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -239,29 +239,32 @@ export const kimiProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromKimiStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }, - request + { + providerName: 'Kimi', + request, + onComplete: ({ content, usage }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }, + } ), }) diff --git a/apps/sim/providers/kimi/utils.ts b/apps/sim/providers/kimi/utils.ts deleted file mode 100644 index c9f0608f59c..00000000000 --- a/apps/sim/providers/kimi/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a Kimi (Moonshot AI) streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromKimiStream( - kimiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(kimiStream, { - request, - providerName: 'Kimi', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 6ff2e1486ef..ab59ae2b786 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -30,12 +30,6 @@ vi.mock('@/providers/attachments', () => providersAttachmentsMock) vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) -vi.mock('@/providers/litellm/utils', () => ({ - createReadableStreamFromLiteLLMStream: vi.fn( - () => new ReadableStream({ start: (c) => c.close() }) - ), -})) - vi.mock('@/providers/utils', () => providersUtilsMock) import { litellmProvider } from '@/providers/litellm' @@ -347,6 +341,7 @@ describe('litellmProvider.executeRequest', () => { }) it('returns a streaming execution when streaming without active tools', async () => { + mockCreate.mockResolvedValueOnce((async function* () {})()) const result = await run({ stream: true }) expect(firstPayload().stream).toBe(true) diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 3b30bc2c7f7..f6183065bbc 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -15,10 +15,10 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' -import { createReadableStreamFromLiteLLMStream } from '@/providers/litellm/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -237,9 +237,10 @@ export const litellmProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromLiteLLMStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'LiteLLM', + request, + onComplete: ({ content, usage }) => { let cleanContent = content if (cleanContent && request.responseFormat) { cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() @@ -265,8 +266,7 @@ export const litellmProvider: ProviderConfig = { finalizeTiming() }, - request - ), + }), }) return streamingResult diff --git a/apps/sim/providers/litellm/utils.ts b/apps/sim/providers/litellm/utils.ts deleted file mode 100644 index cdb9f801b15..00000000000 --- a/apps/sim/providers/litellm/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a LiteLLM streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromLiteLLMStream( - litellmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(litellmStream, { - request, - providerName: 'LiteLLM', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index 8078cc0411f..6f1f496e648 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -14,9 +14,9 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' -import { createReadableStreamFromMetaStream } from '@/providers/meta/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -190,29 +190,32 @@ export const metaProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromMetaStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }, - request + { + providerName: 'Meta', + request, + onComplete: ({ content, usage }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }, + } ), }) diff --git a/apps/sim/providers/meta/utils.ts b/apps/sim/providers/meta/utils.ts deleted file mode 100644 index b98dfd2a9d2..00000000000 --- a/apps/sim/providers/meta/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a Meta Model API streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromMetaStream( - metaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(metaStream, { - request, - providerName: 'Meta', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts index b38a25cd93f..06c1ae688cc 100644 --- a/apps/sim/providers/mistral/index.test.ts +++ b/apps/sim/providers/mistral/index.test.ts @@ -12,9 +12,7 @@ import type { ProviderToolConfig } from '@/providers/types' vi.mock('openai', () => openaiMock) vi.mock('@/providers', () => providersMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) -vi.mock('@/providers/mistral/utils', () => ({ - createReadableStreamFromMistralStream: vi.fn(), -})) + vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) vi.mock('@/providers/utils', () => providersUtilsMock) diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 721c291bd4c..f1fdf44eaa0 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -14,9 +14,9 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' -import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -177,9 +177,10 @@ export const mistralProvider: ProviderConfig = { initialCost: { input: 0, output: 0, total: 0 }, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromMistralStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'Mistral', + request, + onComplete: ({ content, usage }) => { output.content = content output.tokens = { input: usage.prompt_tokens, @@ -200,8 +201,7 @@ export const mistralProvider: ProviderConfig = { finalizeTiming() }, - request - ), + }), }) return streamingResult diff --git a/apps/sim/providers/mistral/utils.ts b/apps/sim/providers/mistral/utils.ts deleted file mode 100644 index c015a75979b..00000000000 --- a/apps/sim/providers/mistral/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a Mistral streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromMistralStream( - mistralStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(mistralStream, { - request, - providerName: 'Mistral', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index ac0dac14b56..a5d384b94d7 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -19,9 +19,9 @@ import { getProviderDefaultModel, getProviderModels, } from '@/providers/models' -import { createReadableStreamFromNvidiaStream } from '@/providers/nvidia/utils' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -190,29 +190,32 @@ export const nvidiaProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromNvidiaStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }, - request + { + providerName: 'NVIDIA', + request, + onComplete: ({ content, usage }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }, + } ), }) diff --git a/apps/sim/providers/nvidia/utils.ts b/apps/sim/providers/nvidia/utils.ts deleted file mode 100644 index 7504906d20a..00000000000 --- a/apps/sim/providers/nvidia/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from an NVIDIA NIM streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromNvidiaStream( - nvidiaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(nvidiaStream, { - request, - providerName: 'NVIDIA', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index e1a1906a3ee..fe6cccc4e67 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -9,30 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' type StreamUsage = { prompt_tokens: number; completion_tokens: number; total_tokens: number } -const { streamOnComplete } = vi.hoisted(() => ({ - streamOnComplete: { - current: undefined as undefined | ((content: string, usage: StreamUsage) => void), - }, -})) - vi.mock('openai', () => openaiMock) vi.mock('@/providers', () => providersMock) vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) -vi.mock('@/providers/ollama-cloud/utils', () => ({ - createReadableStreamFromOllamaCloudStream: ( - _stream: unknown, - onComplete: (content: string, usage: StreamUsage) => void - ) => { - streamOnComplete.current = onComplete - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) - }, -})) + vi.mock('@/providers/utils', () => providersUtilsMock) vi.mock('@/tools', () => toolsMock) @@ -81,7 +63,6 @@ const baseRequest: ProviderRequest = { describe('ollamaCloudProvider.executeRequest', () => { beforeEach(() => { - streamOnComplete.current = undefined mockCreate.mockResolvedValue(completion({ content: 'hello' })) mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) diff --git a/apps/sim/providers/ollama-cloud/index.ts b/apps/sim/providers/ollama-cloud/index.ts index 89a3679e3c4..350769082aa 100644 --- a/apps/sim/providers/ollama-cloud/index.ts +++ b/apps/sim/providers/ollama-cloud/index.ts @@ -4,7 +4,6 @@ import type { StreamingExecution } from '@/executor/types' import { inheritConversationGenerationContext } from '@/providers/conversation-generation' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeOllamaProviderRequest } from '@/providers/ollama/core' -import { createReadableStreamFromOllamaCloudStream } from '@/providers/ollama-cloud/utils' import { openAICompatTransport } from '@/providers/transport' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' @@ -42,7 +41,6 @@ export const ollamaCloudProvider: ProviderConfig = { apiKey, baseURL: OLLAMA_CLOUD_BASE_URL, }), - createStream: createReadableStreamFromOllamaCloudStream, logger, } ) diff --git a/apps/sim/providers/ollama-cloud/utils.ts b/apps/sim/providers/ollama-cloud/utils.ts deleted file mode 100644 index 3fc67692ca5..00000000000 --- a/apps/sim/providers/ollama-cloud/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from an Ollama Cloud streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromOllamaCloudStream( - ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(ollamaStream, { - request, - providerName: 'Ollama Cloud', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 48499d60b24..a9f072b1dda 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -2,11 +2,7 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { - ChatCompletionChunk, - ChatCompletionCreateParamsStreaming, -} from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' +import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -20,8 +16,8 @@ import { } from '@/providers/conversation-history' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' -import type { AgentStreamEvent } from '@/providers/stream-events' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' @@ -67,11 +63,6 @@ export interface OllamaCoreConfig { providerLabel: string /** Builds the OpenAI-compatible client (base URL + credentials per provider). */ createClient: () => OpenAI - createStream: ( - stream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest - ) => ReadableStream logger: Logger } @@ -199,9 +190,10 @@ export async function executeOllamaProviderRequest( initialCost: { input: 0, output: 0, total: 0 }, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - config.createStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: providerLabel, + request, + onComplete: ({ content, usage }) => { output.content = content if (content && request.responseFormat) { @@ -227,8 +219,7 @@ export async function executeOllamaProviderRequest( finalizeTiming() }, - request - ), + }), }) return streamingResult diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index f252365f052..a6138702770 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -8,30 +8,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' type StreamUsage = { prompt_tokens: number; completion_tokens: number; total_tokens: number } -const { streamOnComplete } = vi.hoisted(() => ({ - streamOnComplete: { - current: undefined as undefined | ((content: string, usage: StreamUsage) => void), - }, -})) - vi.mock('openai', () => openaiMock) vi.mock('@/providers', () => providersMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) -vi.mock('@/providers/ollama/utils', () => ({ - createReadableStreamFromOllamaStream: ( - _stream: unknown, - onComplete: (content: string, usage: StreamUsage) => void - ) => { - streamOnComplete.current = onComplete - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) - }, -})) + vi.mock('@/providers/utils', () => providersUtilsMock) vi.mock('@/tools', () => toolsMock) vi.mock('@/stores/providers', () => ({ @@ -114,7 +96,6 @@ const baseRequest: ProviderRequest = { describe('ollamaProvider.executeRequest', () => { beforeEach(() => { - streamOnComplete.current = undefined mockCreate.mockResolvedValue(completion({ content: 'hello' })) mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) @@ -361,6 +342,19 @@ describe('ollamaProvider.executeRequest', () => { }) it('streams content and usage when no tools are used', async () => { + mockCreate.mockResolvedValueOnce( + (async function* () { + yield { + choices: [{ delta: { content: 'streamed text' } }], + usage: { + prompt_tokens: 4, + completion_tokens: 6, + total_tokens: 10, + }, + } + })() + ) + const result = (await ollamaProvider.executeRequest({ ...baseRequest, stream: true, @@ -369,27 +363,32 @@ describe('ollamaProvider.executeRequest', () => { expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) - streamOnComplete.current?.('streamed text', { - prompt_tokens: 4, - completion_tokens: 6, - total_tokens: 10, - }) + await readAgentEvents(result.stream as ReadableStream) expect(result.execution.output.content).toBe('streamed text') expect(result.execution.output.tokens).toMatchObject({ input: 4, output: 6, total: 10 }) }) it('strips ```json fences from streamed content when responseFormat is set', async () => { + mockCreate.mockResolvedValueOnce( + (async function* () { + yield { + choices: [{ delta: { content: '```json\n{"a":1}\n```' } }], + usage: { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + }, + } + })() + ) + const result = (await ollamaProvider.executeRequest({ ...baseRequest, stream: true, responseFormat: { name: 'r', schema: { type: 'object' }, strict: true }, })) as unknown as StreamingResult - streamOnComplete.current?.('```json\n{"a":1}\n```', { - prompt_tokens: 1, - completion_tokens: 2, - total_tokens: 3, - }) + await readAgentEvents(result.stream as ReadableStream) expect(result.execution.output.content).toBe('{"a":1}') }) diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index cbe67e9cc83..c15f79870a7 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -6,7 +6,6 @@ import type { StreamingExecution } from '@/executor/types' import { inheritConversationGenerationContext } from '@/providers/conversation-generation' import { executeOllamaProviderRequest } from '@/providers/ollama/core' import type { ModelsObject } from '@/providers/ollama/types' -import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils' import { openAICompatTransport } from '@/providers/transport' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' import { useProvidersStore } from '@/stores/providers' @@ -63,7 +62,6 @@ export const ollamaProvider: ProviderConfig = { apiKey: 'empty', baseURL: `${OLLAMA_HOST}/v1`, }), - createStream: createReadableStreamFromOllamaStream, logger, } ) diff --git a/apps/sim/providers/ollama/utils.ts b/apps/sim/providers/ollama/utils.ts deleted file mode 100644 index c0ce270bc8b..00000000000 --- a/apps/sim/providers/ollama/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from an Ollama streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromOllamaStream( - ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(ollamaStream, { - request, - providerName: 'Ollama', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/openai-compat/chat-completions.ts b/apps/sim/providers/openai-compat/chat-completions.ts new file mode 100644 index 00000000000..bd6cdba930e --- /dev/null +++ b/apps/sim/providers/openai-compat/chat-completions.ts @@ -0,0 +1,717 @@ +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type OpenAI from 'openai' +import type { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionToolChoiceOption, +} from 'openai/resources/chat/completions' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { + isConversationContextError, + prepareConversationGeneration, +} from '@/providers/conversation-generation' +import { + captureProviderConversationStep, + recordProviderConversationToolError, + recordProviderConversationUsage, +} from '@/providers/conversation-history' +import { + createOpenAICompatAssistantHistory, + type OpenAICompatReasoningField, +} from '@/providers/openai-compat/assistant-history' +import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import { executeProviderTool } from '@/providers/runtime-context' +import { createSettledAgentEventStream } from '@/providers/stream-events' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + FunctionCallResponse, + Message, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + checkForForcedToolUsageOpenAI, + isFunctionToolCall, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, +} from '@/providers/utils' + +export type ChatCompletionPayload = ChatCompletionCreateParamsNonStreaming & { + provider?: { require_parameters?: boolean } +} + +interface ChatCompletionExecutionConfig { + providerId: 'baseten' | 'together' | 'fireworks' | 'openrouter' + providerName: string + client: OpenAI + requestedModel: string + /** Fireworks sends a wire model but retains its catalog identity for billing and output. */ + reportedModel: string + logger: Logger + applyResponseFormat: ( + payload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable, + model: string + ) => Promise + reasoningFields: readonly OpenAICompatReasoningField[] + preserveReasoningDetails?: boolean + /** Together and OpenRouter account for the pending turn when their tool loop reaches its cap. */ + recordPendingUsage?: boolean +} + +/** Runs the shared Chat Completions request, tool loop and settled-stream lifecycle. */ +export async function executeChatCompletionRequest( + request: ProviderRequest, + config: ChatCompletionExecutionConfig +): Promise { + const { + client, + requestedModel, + reportedModel, + logger, + applyResponseFormat, + providerId, + providerName, + } = config + const allMessages: Message[] = [] + + if (request.systemPrompt) { + allMessages.push({ role: 'system', content: request.systemPrompt }) + } + + if (request.context) { + allMessages.push({ role: 'user', content: request.context }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider( + allMessages, + providerId + ) as ChatCompletionMessageParam[] + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + + const payload: ChatCompletionPayload = { + model: requestedModel, + messages: formattedMessages, + } + + if (request.temperature !== undefined) payload.temperature = request.temperature + if (request.maxTokens != null) payload.max_tokens = request.maxTokens + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, providerId) + const { tools: filteredTools, toolChoice } = preparedTools + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + payload.tool_choice = toolChoice as ChatCompletionToolChoiceOption + hasActiveTools = true + } + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + if (request.responseFormat && !hasActiveTools) { + payload.messages = await applyResponseFormat( + payload, + payload.messages, + request.responseFormat, + requestedModel + ) + } + + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + const streamingParams: ChatCompletionCreateParamsStreaming = { + ...payload, + stream: true, + stream_options: { include_usage: true }, + } + const streamResponse = await client.chat.completions.create( + await prepareConversationGeneration(request, 'chat-completions', streamingParams), + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = createStreamingExecution({ + model: reportedModel, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatibleAgentEventStream(streamResponse, { + request, + providerName, + onComplete: ({ content, usage }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + reportedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + finalizeTiming() + }, + }), + }) + + return streamingResult + } + + const initialCallTime = Date.now() + const originalToolChoice = payload.tool_choice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + let currentResponse = await client.chat.completions.create( + await prepareConversationGeneration(request, 'chat-completions', payload), + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + if (!currentResponse.choices[0]?.message?.tool_calls?.length) { + await captureProviderConversationStep( + request, + 'chat-completions', + currentResponse.choices[0]?.message, + getChatCompletionConversationUsage(currentResponse.usage) + ) + } + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls: FunctionCallResponse[] = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + let modelTime = firstResponseTime + let toolsTime = 0 + let hasUsedForcedTool = false + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + const forcedToolResult = checkForForcedToolUsageOpenAI( + currentResponse, + originalToolChoice ?? 'auto', + providerName, + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = forcedToolResult.hasUsedForcedTool + usedForcedTools = forcedToolResult.usedForcedTools + + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: providerId } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + const toolsStartTime = Date.now() + + await captureProviderConversationStep( + request, + 'chat-completions', + currentResponse.choices[0]?.message, + getChatCompletionConversationUsage(currentResponse.usage) + ) + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) + const tool = request.tools?.find((t) => t.id === toolName) + + if (!tool) { + await recordProviderConversationToolError( + request, + toolCall.id, + toolName, + `Tool "${toolName}" is not available` + ) + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request, + toolCall.id + ) + const { rawResponse, modelResponse } = await executeProviderTool( + toolName, + executionParams, + { + signal: request.abortSignal, + } + ) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result: rawResponse, + modelResult: modelResponse, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + await recordProviderConversationToolError( + request, + toolCall.id, + toolName, + getErrorMessage(error, 'Tool execution failed') + ) + const toolCallEndTime = Date.now() + logger.error(`Error processing tool call (${providerName}):`, { + error: toError(error).message, + toolName, + }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + const assistantHistory = createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: config.reasoningFields, + }) + const extendedMessage = assistantMessage as ChatCompletionMessage & { + reasoning_details?: unknown[] + } + if (config.preserveReasoningDetails && Array.isArray(extendedMessage.reasoning_details)) { + assistantHistory.reasoning_details = extendedMessage.reasoning_details + } + currentMessages.push(assistantHistory) + } + + for (const executionResult of executionResults) { + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + executionResult + const modelResult = + 'modelResult' in executionResult ? (executionResult.modelResult ?? result) : result + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + const modelResultContent = modelResult.success + ? (modelResult.output ?? null) + : { + error: true, + message: modelResult.error || 'Tool execution failed', + tool: toolName, + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(modelResultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + if (remainingTools.length > 0) { + nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } } + } else { + nextPayload.tool_choice = 'auto' + } + } + + const nextModelStartTime = Date.now() + currentResponse = await client.chat.completions.create( + await prepareConversationGeneration(request, 'chat-completions', nextPayload), + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + if (!currentResponse.choices[0]?.message?.tool_calls?.length) { + await captureProviderConversationStep( + request, + 'chat-completions', + currentResponse.choices[0]?.message, + getChatCompletionConversationUsage(currentResponse.usage) + ) + } + const nextForcedToolResult = checkForForcedToolUsageOpenAI( + currentResponse, + nextPayload.tool_choice ?? 'auto', + providerName, + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool + usedForcedTools = nextForcedToolResult.usedForcedTools + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + modelTime += thisModelTime + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + if (config.recordPendingUsage && currentResponse.choices[0]?.message?.tool_calls?.length) { + await recordProviderConversationUsage( + request, + getChatCompletionConversationUsage(currentResponse.usage) + ) + } + const pendingToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: providerId, + }) + + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: ChatCompletionPayload = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } + + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } + + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + await prepareConversationGeneration(request, 'chat-completions', finalPayload), + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + if (!finalResponse.choices[0]?.message?.tool_calls?.length) { + await captureProviderConversationStep( + request, + 'chat-completions', + finalResponse.choices[0]?.message, + getChatCompletionConversationUsage(finalResponse.usage) + ) + } + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration + + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), + { model: request.model, provider: providerId } + ) + } + } + + if (request.responseFormat && hasActiveTools) { + const finalPayload: ChatCompletionPayload = { + model: payload.model, + messages: [...currentMessages], + } + if (payload.temperature !== undefined) { + finalPayload.temperature = payload.temperature + } + if (payload.max_tokens !== undefined) { + finalPayload.max_tokens = payload.max_tokens + } + + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + await prepareConversationGeneration(request, 'chat-completions', finalPayload), + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + if (!finalResponse.choices[0]?.message?.tool_calls?.length) { + await captureProviderConversationStep( + request, + 'chat-completions', + finalResponse.choices[0]?.message, + getChatCompletionConversationUsage(finalResponse.usage) + ) + } + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime + + timeSegments.push({ + type: 'model', + name: 'Final structured response', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration + + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), + { model: request.model, provider: providerId } + ) + } + + if (request.stream) { + const accumulatedCost = calculateCost(reportedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: reportedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: reportedModel, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + const errorDetails: Record = { + error: toError(error).message, + duration: totalDuration, + } + if (isRecordLike(error)) { + const err = error + if (err.status) errorDetails.status = err.status + if (err.code) errorDetails.code = err.code + if (err.type) errorDetails.type = err.type + if (isRecordLike(err.error)) { + if (err.error.message) errorDetails.providerMessage = err.error.message + if (err.error.metadata) errorDetails.metadata = err.error.metadata + } + } + + logger.error(`Error in ${providerName} request:`, errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted || isConversationContextError(error)) { + throw error + } + + throw new ProviderError(toError(error).message, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } +} diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index fe0c54c47aa..3d992e5c3cd 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -14,14 +14,10 @@ import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/pr import { toolsMock, toolsMockFns } from '@sim/testing/mocks/tools.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCapabilities, mockSupportsNative, mockCheckForced, mockCreateStream } = vi.hoisted( - () => ({ - mockCapabilities: vi.fn(), - mockSupportsNative: vi.fn(), - mockCheckForced: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), - mockCreateStream: vi.fn(), - }) -) +const { mockCapabilities, mockSupportsNative } = vi.hoisted(() => ({ + mockCapabilities: vi.fn(), + mockSupportsNative: vi.fn(), +})) vi.mock('openai', () => openaiMock) @@ -38,8 +34,6 @@ vi.mock('@/providers/attachments', () => providersAttachmentsMock) vi.mock('@/providers/openrouter/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNative, getOpenRouterModelCapabilities: mockCapabilities, - createReadableStreamFromOpenAIStream: mockCreateStream, - checkForForcedToolUsage: mockCheckForced, })) vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) @@ -51,6 +45,7 @@ import { openRouterProvider } from '@/providers/openrouter/index' import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' +const mockCheckForced = providersUtilsMockFns.mockCheckForForcedToolUsageOpenAI const mockCreate = openaiMockFns.mockChatCompletionsCreate providersMock.MAX_TOOL_ITERATIONS = 10 providersModelsMockFns.mockGetMaxOutputTokensForModel.mockReturnValue(100) @@ -137,9 +132,6 @@ describe('openRouterProvider.executeRequest', () => { mockCreate.mockReset() mockExecuteTool.mockReset() mockSupportsNative.mockResolvedValue(false) - mockCreateStream.mockReturnValue( - new ReadableStream({ start: (controller) => controller.close() }) - ) }) it.each([ @@ -479,14 +471,13 @@ describe('openRouterProvider.executeRequest', () => { }) it('streams directly when there are no tools and sends usage opt-in', async () => { - mockCreate.mockResolvedValueOnce({}) + mockCreate.mockResolvedValueOnce((async function* () {})()) const res = await openRouterProvider.executeRequest({ ...baseRequest, stream: true }) const payload = mockCreate.mock.calls[0][0] expect(payload.stream).toBe(true) expect(payload.stream_options).toEqual({ include_usage: true }) - expect(mockCreateStream).toHaveBeenCalledTimes(1) expect(res).toHaveProperty('stream') expect(res).toHaveProperty('execution.output.model', 'anthropic/claude-3.5-sonnet') }) diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index c7cac5afc79..c5d47cbb383 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -1,81 +1,34 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { - ChatCompletionCreateParamsStreaming, - ChatCompletionMessage, -} from 'openai/resources/chat/completions' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' -import { MAX_TOOL_ITERATIONS } from '@/providers' -import { formatMessagesForProvider } from '@/providers/attachments' -import { - bindConversationGenerationContextWindow, - isConversationContextError, - prepareConversationGeneration, -} from '@/providers/conversation-generation' -import { - captureProviderConversationStep, - getConversationRequestContext, - recordProviderConversationToolError, - recordProviderConversationUsage, -} from '@/providers/conversation-history' +import { bindConversationGenerationContextWindow } from '@/providers/conversation-generation' +import { getConversationRequestContext } from '@/providers/conversation-history' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { - createOpenAICompatAssistantHistory, - type OpenAICompatAssistantHistoryMessage, -} from '@/providers/openai-compat/assistant-history' -import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' -import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' + type ChatCompletionPayload, + executeChatCompletionRequest, +} from '@/providers/openai-compat/chat-completions' import { - checkForForcedToolUsage, - createReadableStreamFromOpenAIStream, getOpenRouterModelCapabilities, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' -import { executeProviderTool } from '@/providers/runtime-context' -import { createSettledAgentEventStream } from '@/providers/stream-events' -import { createStreamingExecution } from '@/providers/streaming-execution' -import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' -import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' -import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import { openAICompatTransport } from '@/providers/transport' -import type { - FunctionCallResponse, - Message, - ProviderConfig, - ProviderRequest, - ProviderResponse, - TimeSegment, -} from '@/providers/types' -import { ProviderError } from '@/providers/types' -import { - calculateCost, - generateSchemaInstructions, - isFunctionToolCall, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' +import { generateSchemaInstructions } from '@/providers/utils' const logger = createLogger('OpenRouterProvider') -type OpenRouterAssistantMessage = ChatCompletionMessage & { - reasoning?: string - reasoning_content?: string - reasoning_details?: OpenRouterReasoningDetail[] -} - /** * Applies structured output configuration to a payload based on model capabilities. * Uses json_schema with require_parameters for supported models, falls back to json_object with prompt instructions. */ async function applyResponseFormat( - targetPayload: any, - messages: any[], - responseFormat: any, + targetPayload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable, model: string -): Promise { +): Promise { const useNative = await supportsNativeStructuredOutputs(model) if (useNative) { @@ -138,630 +91,17 @@ export const openRouterProvider: ProviderConfig = { stream: !!request.stream, }) - const allMessages: Message[] = [] - - if (request.systemPrompt) { - allMessages.push({ role: 'system', content: request.systemPrompt }) - } - - if (request.context) { - allMessages.push({ role: 'user', content: request.context }) - } - - if (request.messages) { - allMessages.push(...request.messages) - } - const formattedMessages = formatMessagesForProvider(allMessages, 'openrouter') as Message[] - - const tools = request.tools?.length - ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) - : undefined - - const payload: any = { - model: requestedModel, - messages: formattedMessages, - } - - if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens != null) payload.max_tokens = request.maxTokens - - let preparedTools: ReturnType | null = null - let hasActiveTools = false - if (tools?.length) { - preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openrouter') - const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { - payload.tools = filteredTools - payload.tool_choice = toolChoice - hasActiveTools = true - } - } - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - if (request.responseFormat && !hasActiveTools) { - payload.messages = await applyResponseFormat( - payload, - payload.messages, - request.responseFormat, - requestedModel - ) - } - - if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', streamingParams), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { kind: 'simple', segmentName: request.model }, - initialTokens: { input: 0, output: 0, total: 0 }, - initialCost: { input: 0, output: 0, total: 0 }, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromOpenAIStream( - streamResponse, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - finalizeTiming() - }, - request - ), - }) - - return streamingResult - } - - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', payload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const firstResponseTime = Date.now() - initialCallTime - - let content = currentResponse.choices[0]?.message?.content || '' - const tokens = { - input: currentResponse.usage?.prompt_tokens || 0, - output: currentResponse.usage?.completion_tokens || 0, - total: currentResponse.usage?.total_tokens || 0, - } - const toolCalls: FunctionCallResponse[] = [] - const toolResults: Record[] = [] - const currentMessages = [...formattedMessages] - let iterationCount = 0 - let modelTime = firstResponseTime - let toolsTime = 0 - let hasUsedForcedTool = false - const timeSegments: TimeSegment[] = [ - { - type: 'model', - name: request.model, - startTime: initialCallTime, - endTime: initialCallTime + firstResponseTime, - duration: firstResponseTime, - }, - ] - - const forcedToolResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = forcedToolResult.hasUsedForcedTool - usedForcedTools = forcedToolResult.usedForcedTools - - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - - const toolCallsInResponse = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - toolCallsInResponse, - { model: request.model, provider: 'openrouter' } - ) - - if (!toolCallsInResponse || toolCallsInResponse.length === 0) { - break - } - - const toolsStartTime = Date.now() - - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { - const toolCallStartTime = Date.now() - const toolName = toolCall.function.name - - try { - const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) - const tool = request.tools?.find((t) => t.id === toolName) - - if (!tool) { - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - `Tool "${toolName}" is not available` - ) - const toolCallEndTime = Date.now() - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: `Tool "${toolName}" is not available`, - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - - const { toolParams, executionParams } = prepareToolExecution( - tool, - toolArgs, - request, - toolCall.id - ) - const { rawResponse, modelResponse } = await executeProviderTool( - toolName, - executionParams, - { - signal: request.abortSignal, - } - ) - const toolCallEndTime = Date.now() - - return { - toolCall, - toolName, - toolParams, - result: rawResponse, - modelResult: modelResponse, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - throw error - } - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - getErrorMessage(error, 'Tool execution failed') - ) - const toolCallEndTime = Date.now() - logger.error('Error processing tool call (OpenRouter):', { - error: toError(error).message, - toolName, - }) - - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: getErrorMessage(error, 'Tool execution failed'), - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - }) - - const executionResults = await Promise.all(toolExecutionPromises) - const assistantMessage = currentResponse.choices[0]?.message as - | OpenRouterAssistantMessage - | undefined - if (assistantMessage) { - const assistantHistory: OpenAICompatAssistantHistoryMessage & { - reasoning_details?: OpenRouterReasoningDetail[] - } = createOpenAICompatAssistantHistory({ - message: assistantMessage, - toolCalls: toolCallsInResponse, - reasoningFields: ['reasoning', 'reasoning_content'], - }) - if (Array.isArray(assistantMessage.reasoning_details)) { - assistantHistory.reasoning_details = assistantMessage.reasoning_details - } - currentMessages.push(assistantHistory) - } - - for (const executionResult of executionResults) { - const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - executionResult - const modelResult = - 'modelResult' in executionResult ? (executionResult.modelResult ?? result) : result - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolCall.id, - }) - - let resultContent: unknown - if (result.success) { - if (isRecordLike(result.output)) { - toolResults.push(result.output) - } - resultContent = result.output ?? null - } else { - resultContent = { - error: true, - message: result.error || 'Tool execution failed', - tool: toolName, - } - } - const modelResultContent = modelResult.success - ? (modelResult.output ?? null) - : { - error: true, - message: modelResult.error || 'Tool execution failed', - tool: toolName, - } - - toolCalls.push({ - name: toolName, - arguments: toolParams, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - duration: duration, - result: resultContent, - success: result.success, - }) - - currentMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: JSON.stringify(modelResultContent), - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload = { - ...payload, - messages: currentMessages, - } - - if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - if (remainingTools.length > 0) { - nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } } - } else { - nextPayload.tool_choice = 'auto' - } - } - - const nextModelStartTime = Date.now() - currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', nextPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const nextForcedToolResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool - usedForcedTools = nextForcedToolResult.usedForcedTools - const nextModelEndTime = Date.now() - const thisModelTime = nextModelEndTime - nextModelStartTime - timeSegments.push({ - type: 'model', - name: request.model, - startTime: nextModelStartTime, - endTime: nextModelEndTime, - duration: thisModelTime, - }) - modelTime += thisModelTime - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - if (currentResponse.usage) { - tokens.input += currentResponse.usage.prompt_tokens || 0 - tokens.output += currentResponse.usage.completion_tokens || 0 - tokens.total += currentResponse.usage.total_tokens || 0 - } - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - if (currentResponse.choices[0]?.message?.tool_calls?.length) { - await recordProviderConversationUsage( - request, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const pendingToolCalls = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { - model: request.model, - provider: 'openrouter', - }) - - if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { - const finalPayload: any = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - } - - if (request.responseFormat) { - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - } - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final answer after tool iteration limit', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'openrouter' } - ) - } - } - - if (request.responseFormat && hasActiveTools) { - const finalPayload: any = { - model: payload.model, - messages: [...currentMessages], - } - if (payload.temperature !== undefined) { - finalPayload.temperature = payload.temperature - } - if (payload.max_tokens !== undefined) { - finalPayload.max_tokens = payload.max_tokens - } - - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final structured response', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'openrouter' } - ) - } - - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) - const toolCost = sumToolCosts(toolResults) - const finalCost = { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: toolCost || undefined, - total: accumulatedCost.total + toolCost, - } - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: finalCost, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => { - output.content = content - output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } - output.cost = finalCost - finalizeTiming() - return createSettledAgentEventStream(content) - }, - }) - - return streamingResult - } - - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - return { - content, - model: requestedModel, - tokens, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - toolResults: toolResults.length > 0 ? toolResults : undefined, - timing: { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments: timeSegments, - }, - } - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - const errorDetails: Record = { - error: toError(error).message, - duration: totalDuration, - } - if (error && typeof error === 'object') { - const err = error as any - if (err.status) errorDetails.status = err.status - if (err.code) errorDetails.code = err.code - if (err.type) errorDetails.type = err.type - if (err.error?.message) errorDetails.providerMessage = err.error.message - if (err.error?.metadata) errorDetails.metadata = err.error.metadata - } - - logger.error('Error in OpenRouter request:', errorDetails) - if ( - isAbortError(error) || - request.abortSignal?.aborted || - isConversationContextError(error) - ) { - throw error - } - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } + return executeChatCompletionRequest(request, { + providerId: 'openrouter', + providerName: 'OpenRouter', + client, + requestedModel, + reportedModel: requestedModel, + logger, + applyResponseFormat, + reasoningFields: ['reasoning', 'reasoning_content'], + preserveReasoningDetails: true, + recordPendingUsage: true, + }) }, } diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 09438491ad0..fd65eff65ef 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -1,11 +1,5 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' const logger = createLogger('OpenRouterUtils') @@ -116,32 +110,3 @@ export async function supportsNativeStructuredOutputs(modelId: string): Promise< const capabilities = await getOpenRouterModelCapabilities(modelId) return capabilities?.supportsStructuredOutputs ?? false } - -export function createReadableStreamFromOpenAIStream( - openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(openaiStream, { - request, - providerName: 'OpenRouter', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - -export function checkForForcedToolUsage( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI( - response, - toolChoice, - 'OpenRouter', - forcedTools, - usedForcedTools - ) -} diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 66268f0f97c..78b1aeaca6d 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -17,8 +17,8 @@ import { import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' -import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' @@ -170,29 +170,32 @@ export const sakanaProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromSakanaStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }, - request + { + providerName: 'Sakana', + request, + onComplete: ({ content, usage }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }, + } ), }) diff --git a/apps/sim/providers/sakana/utils.ts b/apps/sim/providers/sakana/utils.ts deleted file mode 100644 index 8fdf4f1662c..00000000000 --- a/apps/sim/providers/sakana/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a Sakana AI streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromSakanaStream( - sakanaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(sakanaStream, { - request, - providerName: 'Sakana', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index 9d8ddad50eb..0b7af49907e 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -70,63 +70,14 @@ vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) vi.mock('@/providers/utils', () => providersUtilsMock) -vi.mock('@/providers/baseten/utils', () => ({ - checkForForcedToolUsage: vi.fn(() => ({ - hasUsedForcedTool: false, - usedForcedTools: [], - })), - createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), - supportsNativeStructuredOutputs: vi.fn(() => true), -})) vi.mock('@/providers/fireworks/utils', () => ({ - checkForForcedToolUsage: vi.fn(() => ({ - hasUsedForcedTool: false, - usedForcedTools: [], - })), - createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), - supportsNativeStructuredOutputs: vi.fn(() => true), resolveFireworksWireModel: vi.fn((stripped: string) => stripped), })) vi.mock('@/providers/openrouter/utils', () => ({ - checkForForcedToolUsage: vi.fn(() => ({ - hasUsedForcedTool: false, - usedForcedTools: [], - })), - createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), supportsNativeStructuredOutputs: vi.fn(() => true), })) -vi.mock('@/providers/together/utils', () => ({ - checkForForcedToolUsage: vi.fn(() => ({ - hasUsedForcedTool: false, - usedForcedTools: [], - })), - createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), - supportsNativeStructuredOutputs: vi.fn(() => true), -})) -vi.mock('@/providers/cerebras/utils', () => ({ - createReadableStreamFromCerebrasStream: vi.fn(() => createEmptyStream()), -})) -vi.mock('@/providers/kimi/utils', () => ({ - createReadableStreamFromKimiStream: vi.fn(() => createEmptyStream()), -})) -vi.mock('@/providers/meta/utils', () => ({ - createReadableStreamFromMetaStream: vi.fn(() => createEmptyStream()), -})) -vi.mock('@/providers/nvidia/utils', () => ({ - createReadableStreamFromNvidiaStream: vi.fn(() => createEmptyStream()), -})) -vi.mock('@/providers/sakana/utils', () => ({ - createReadableStreamFromSakanaStream: vi.fn(() => createEmptyStream()), -})) -vi.mock('@/providers/zai/utils', () => ({ - createReadableStreamFromZaiStream: vi.fn(() => createEmptyStream()), -})) + vi.mock('@/providers/xai/utils', () => ({ - checkForForcedToolUsage: vi.fn(() => ({ - hasUsedForcedTool: false, - usedForcedTools: [], - })), - createReadableStreamFromXAIStream: vi.fn(() => createEmptyStream()), createResponseFormatPayload: vi.fn(() => ({})), })) @@ -252,7 +203,7 @@ const STRUCTURED_OUTPUT_PROVIDERS = [ name: 'Together', provider: togetherProvider, model: 'together/test-model', - responseFormatType: 'json_schema', + responseFormatType: 'json_object', disablesTools: 'omit', }, { @@ -285,14 +236,6 @@ const STRUCTURED_OUTPUT_PROVIDERS = [ }, ] as const -function createEmptyStream(): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) -} - function toolCall(id: string, argumentsJson = '{}'): ToolCall { return { id, diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index c1fe575e094..cfd86f0af85 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -12,10 +12,6 @@ import { toolsMock, toolsMockFns } from '@sim/testing/mocks/tools.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { StreamingExecution } from '@/executor/types' -const { mockSupportsNativeStructuredOutputs } = vi.hoisted(() => ({ - mockSupportsNativeStructuredOutputs: vi.fn(), -})) - vi.mock('openai', () => openaiMock) vi.mock('@/providers/conversation-history', () => providersConversationHistoryMock) @@ -26,14 +22,6 @@ vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) -vi.mock('@/providers/together/utils', () => ({ - supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn( - () => new ReadableStream({ start: (controller) => controller.close() }) - ), - checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), -})) - vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) vi.mock('@/providers/utils', () => providersUtilsMock) @@ -87,7 +75,6 @@ const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0] describe('togetherProvider', () => { beforeEach(() => { - mockSupportsNativeStructuredOutputs.mockResolvedValue(true) mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({ tools, toolChoice: 'auto', @@ -153,7 +140,7 @@ describe('togetherProvider', () => { ) it('streams directly when there are no tools', async () => { - mockCreate.mockResolvedValueOnce({}) + mockCreate.mockResolvedValueOnce((async function* () {})()) const result = await togetherProvider.executeRequest({ ...baseRequest, stream: true }) @@ -170,23 +157,7 @@ describe('togetherProvider', () => { expect(callBody(0).model).toBe('Org/Custom-Model') }) - it('sends a json_schema response_format with no strict field', async () => { - mockCreate.mockResolvedValueOnce(textResponse('{}')) - - await togetherProvider.executeRequest({ - ...baseRequest, - responseFormat: { name: 'my_schema', schema: { type: 'object' }, strict: true }, - }) - - expect(lastCallBody().response_format).toEqual({ - type: 'json_schema', - json_schema: { name: 'my_schema', schema: { type: 'object' } }, - }) - expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict') - }) - - it('falls back to json_object with prompt instructions when native is unsupported', async () => { - mockSupportsNativeStructuredOutputs.mockResolvedValue(false) + it('uses json_object with prompt instructions for structured output', async () => { mockCreate.mockResolvedValueOnce(textResponse('{}')) await togetherProvider.executeRequest({ @@ -215,10 +186,7 @@ describe('togetherProvider', () => { expect(mockCreate).toHaveBeenCalledTimes(2) expect(callBody(0).response_format).toBeUndefined() expect(callBody(0).tools).toBeDefined() - expect(callBody(1).response_format).toEqual({ - type: 'json_schema', - json_schema: { name: 'my_schema', schema: { type: 'object' } }, - }) + expect(callBody(1).response_format).toEqual({ type: 'json_object' }) expect(callBody(1).tools).toBeUndefined() }) diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 1eaae834f93..a3746e57a19 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -1,79 +1,25 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' -import { MAX_TOOL_ITERATIONS } from '@/providers' -import { formatMessagesForProvider } from '@/providers/attachments' -import { - isConversationContextError, - prepareConversationGeneration, -} from '@/providers/conversation-generation' -import { - captureProviderConversationStep, - recordProviderConversationToolError, - recordProviderConversationUsage, -} from '@/providers/conversation-history' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' -import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' -import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' -import { executeProviderTool } from '@/providers/runtime-context' -import { createSettledAgentEventStream } from '@/providers/stream-events' -import { createStreamingExecution } from '@/providers/streaming-execution' -import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { - checkForForcedToolUsage, - createReadableStreamFromOpenAIStream, - supportsNativeStructuredOutputs, -} from '@/providers/together/utils' -import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' -import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' + type ChatCompletionPayload, + executeChatCompletionRequest, +} from '@/providers/openai-compat/chat-completions' import { openAICompatTransport } from '@/providers/transport' -import type { - FunctionCallResponse, - Message, - ProviderConfig, - ProviderRequest, - ProviderResponse, - TimeSegment, -} from '@/providers/types' -import { ProviderError } from '@/providers/types' -import { - calculateCost, - generateSchemaInstructions, - isFunctionToolCall, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' +import { generateSchemaInstructions } from '@/providers/utils' const logger = createLogger('TogetherProvider') -/** - * Applies structured output configuration to a payload based on model capabilities. - * Uses native json_schema for supported models, falls back to json_object with prompt instructions. - */ +/** Together uses JSON-object mode because native schema support varies across its models. */ async function applyResponseFormat( - targetPayload: any, - messages: any[], - responseFormat: any, + targetPayload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable, model: string -): Promise { - const useNative = await supportsNativeStructuredOutputs(model) - - if (useNative) { - logger.info('Using native structured outputs for Together model', { model }) - targetPayload.response_format = { - type: 'json_schema', - json_schema: { - name: responseFormat.name || 'response_schema', - schema: responseFormat.schema || responseFormat, - }, - } - return messages - } - +): Promise { logger.info('Using json_object mode with prompt instructions for Together model', { model }) const schema = responseFormat.schema || responseFormat const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name) @@ -114,624 +60,16 @@ export const togetherProvider: ProviderConfig = { stream: !!request.stream, }) - const allMessages: Message[] = [] - - if (request.systemPrompt) { - allMessages.push({ role: 'system', content: request.systemPrompt }) - } - - if (request.context) { - allMessages.push({ role: 'user', content: request.context }) - } - - if (request.messages) { - allMessages.push(...request.messages) - } - const formattedMessages = formatMessagesForProvider(allMessages, 'together') as Message[] - - const tools = request.tools?.length - ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) - : undefined - - const payload: any = { - model: requestedModel, - messages: formattedMessages, - } - - if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens != null) payload.max_tokens = request.maxTokens - - let preparedTools: ReturnType | null = null - let hasActiveTools = false - if (tools?.length) { - preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'together') - const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { - payload.tools = filteredTools - payload.tool_choice = toolChoice - hasActiveTools = true - } - } - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - if (request.responseFormat && !hasActiveTools) { - payload.messages = await applyResponseFormat( - payload, - payload.messages, - request.responseFormat, - requestedModel - ) - } - - if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', streamingParams), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { kind: 'simple', segmentName: request.model }, - initialTokens: { input: 0, output: 0, total: 0 }, - initialCost: { input: 0, output: 0, total: 0 }, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromOpenAIStream( - streamResponse, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - finalizeTiming() - }, - request - ), - }) - - return streamingResult - } - - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', payload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const firstResponseTime = Date.now() - initialCallTime - - let content = currentResponse.choices[0]?.message?.content || '' - const tokens = { - input: currentResponse.usage?.prompt_tokens || 0, - output: currentResponse.usage?.completion_tokens || 0, - total: currentResponse.usage?.total_tokens || 0, - } - const toolCalls: FunctionCallResponse[] = [] - const toolResults: Record[] = [] - const currentMessages = [...formattedMessages] - let iterationCount = 0 - let modelTime = firstResponseTime - let toolsTime = 0 - let hasUsedForcedTool = false - const timeSegments: TimeSegment[] = [ - { - type: 'model', - name: request.model, - startTime: initialCallTime, - endTime: initialCallTime + firstResponseTime, - duration: firstResponseTime, - }, - ] - - const forcedToolResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = forcedToolResult.hasUsedForcedTool - usedForcedTools = forcedToolResult.usedForcedTools - - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - - const toolCallsInResponse = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - toolCallsInResponse, - { model: request.model, provider: 'together' } - ) - - if (!toolCallsInResponse || toolCallsInResponse.length === 0) { - break - } - - const toolsStartTime = Date.now() - - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { - const toolCallStartTime = Date.now() - const toolName = toolCall.function.name - - try { - const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) - const tool = request.tools?.find((t) => t.id === toolName) - - if (!tool) { - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - `Tool "${toolName}" is not available` - ) - const toolCallEndTime = Date.now() - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: `Tool "${toolName}" is not available`, - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - - const { toolParams, executionParams } = prepareToolExecution( - tool, - toolArgs, - request, - toolCall.id - ) - const { rawResponse, modelResponse } = await executeProviderTool( - toolName, - executionParams, - { - signal: request.abortSignal, - } - ) - const toolCallEndTime = Date.now() - - return { - toolCall, - toolName, - toolParams, - result: rawResponse, - modelResult: modelResponse, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - throw error - } - await recordProviderConversationToolError( - request, - toolCall.id, - toolName, - getErrorMessage(error, 'Tool execution failed') - ) - const toolCallEndTime = Date.now() - logger.error('Error processing tool call (Together):', { - error: toError(error).message, - toolName, - }) - - return { - toolCall, - toolName, - toolParams: {}, - result: { - success: false, - output: undefined, - error: getErrorMessage(error, 'Tool execution failed'), - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - }) - - const executionResults = await Promise.all(toolExecutionPromises) - const assistantMessage = currentResponse.choices[0]?.message - if (assistantMessage) { - currentMessages.push( - createOpenAICompatAssistantHistory({ - message: assistantMessage, - toolCalls: toolCallsInResponse, - reasoningFields: ['reasoning', 'reasoning_content'], - }) - ) - } - - for (const executionResult of executionResults) { - const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - executionResult - const modelResult = - 'modelResult' in executionResult ? (executionResult.modelResult ?? result) : result - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolCall.id, - }) - - let resultContent: unknown - if (result.success) { - if (isRecordLike(result.output)) { - toolResults.push(result.output) - } - resultContent = result.output ?? null - } else { - resultContent = { - error: true, - message: result.error || 'Tool execution failed', - tool: toolName, - } - } - const modelResultContent = modelResult.success - ? (modelResult.output ?? null) - : { - error: true, - message: modelResult.error || 'Tool execution failed', - tool: toolName, - } - - toolCalls.push({ - name: toolName, - arguments: toolParams, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - duration: duration, - result: resultContent, - success: result.success, - }) - - currentMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: JSON.stringify(modelResultContent), - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload = { - ...payload, - messages: currentMessages, - } - - if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - if (remainingTools.length > 0) { - nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } } - } else { - nextPayload.tool_choice = 'auto' - } - } - - const nextModelStartTime = Date.now() - currentResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', nextPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!currentResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - currentResponse.choices[0]?.message, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const nextForcedToolResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool - usedForcedTools = nextForcedToolResult.usedForcedTools - const nextModelEndTime = Date.now() - const thisModelTime = nextModelEndTime - nextModelStartTime - timeSegments.push({ - type: 'model', - name: request.model, - startTime: nextModelStartTime, - endTime: nextModelEndTime, - duration: thisModelTime, - }) - modelTime += thisModelTime - if (currentResponse.choices[0]?.message?.content) { - content = currentResponse.choices[0].message.content - } - if (currentResponse.usage) { - tokens.input += currentResponse.usage.prompt_tokens || 0 - tokens.output += currentResponse.usage.completion_tokens || 0 - tokens.total += currentResponse.usage.total_tokens || 0 - } - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - if (currentResponse.choices[0]?.message?.tool_calls?.length) { - await recordProviderConversationUsage( - request, - getChatCompletionConversationUsage(currentResponse.usage) - ) - } - const pendingToolCalls = - currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { - model: request.model, - provider: 'together', - }) - - if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { - const finalPayload: any = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - } - - if (request.responseFormat) { - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - } - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final answer after tool iteration limit', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'together' } - ) - } - } - - if (request.responseFormat && hasActiveTools) { - const finalPayload: any = { - model: payload.model, - messages: [...currentMessages], - } - if (payload.temperature !== undefined) { - finalPayload.temperature = payload.temperature - } - if (payload.max_tokens !== undefined) { - finalPayload.max_tokens = payload.max_tokens - } - - finalPayload.messages = await applyResponseFormat( - finalPayload, - finalPayload.messages, - request.responseFormat, - requestedModel - ) - - const finalStartTime = Date.now() - const finalResponse = await client.chat.completions.create( - await prepareConversationGeneration(request, 'chat-completions', finalPayload), - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - if (!finalResponse.choices[0]?.message?.tool_calls?.length) { - await captureProviderConversationStep( - request, - 'chat-completions', - finalResponse.choices[0]?.message, - getChatCompletionConversationUsage(finalResponse.usage) - ) - } - const finalEndTime = Date.now() - const finalDuration = finalEndTime - finalStartTime - - timeSegments.push({ - type: 'model', - name: 'Final structured response', - startTime: finalStartTime, - endTime: finalEndTime, - duration: finalDuration, - }) - modelTime += finalDuration - - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content - } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 - } - - enrichLastModelSegmentFromChatCompletions( - timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), - { model: request.model, provider: 'together' } - ) - } - - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) - const toolCost = sumToolCosts(toolResults) - const finalCost = { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: toolCost || undefined, - total: accumulatedCost.total + toolCost, - } - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: finalCost, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => { - output.content = content - output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } - output.cost = finalCost - finalizeTiming() - return createSettledAgentEventStream(content) - }, - }) - - return streamingResult - } - - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - return { - content, - model: requestedModel, - tokens, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - toolResults: toolResults.length > 0 ? toolResults : undefined, - timing: { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: timeSegments.filter((segment) => segment.type === 'model').length, - timeSegments: timeSegments, - }, - } - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - const errorDetails: Record = { - error: toError(error).message, - duration: totalDuration, - } - if (error && typeof error === 'object') { - const err = error as any - if (err.status) errorDetails.status = err.status - if (err.code) errorDetails.code = err.code - if (err.type) errorDetails.type = err.type - if (err.error?.message) errorDetails.providerMessage = err.error.message - if (err.error?.metadata) errorDetails.metadata = err.error.metadata - } - - logger.error('Error in Together request:', errorDetails) - if ( - isAbortError(error) || - request.abortSignal?.aborted || - isConversationContextError(error) - ) { - throw error - } - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } + return executeChatCompletionRequest(request, { + providerId: 'together', + providerName: 'Together', + client, + requestedModel, + reportedModel: requestedModel, + logger, + applyResponseFormat, + reasoningFields: ['reasoning', 'reasoning_content'], + recordPendingUsage: true, + }) }, } diff --git a/apps/sim/providers/together/utils.ts b/apps/sim/providers/together/utils.ts deleted file mode 100644 index 31205a051f6..00000000000 --- a/apps/sim/providers/together/utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' - -/** - * Together gates native `json_schema` per-model, so we use the broadly supported - * JSON-object mode for all models to avoid 400s. See https://docs.together.ai/docs/json-mode. - */ -export async function supportsNativeStructuredOutputs(_modelId: string): Promise { - return false -} - -/** - * Creates an agent-events stream from a Together AI streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromOpenAIStream( - openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(openaiStream, { - request, - providerName: 'Together', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - -/** - * Checks if a forced tool was used in a Together AI response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ -export function checkForForcedToolUsage( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI( - response, - toolChoice, - 'Together', - forcedTools, - usedForcedTools - ) -} diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 658e8a83d86..7547e754cd3 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -16,9 +16,7 @@ import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/pr import { toolsMock, toolsMockFns } from '@sim/testing/mocks/tools.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckForced, mockCreateStream, pinnedFetchFn } = vi.hoisted(() => ({ - mockCheckForced: vi.fn(), - mockCreateStream: vi.fn(), +const { pinnedFetchFn } = vi.hoisted(() => ({ pinnedFetchFn: vi.fn(), })) @@ -31,10 +29,7 @@ vi.mock('@/providers/models', () => providersModelsMock) vi.mock('@/providers/attachments', () => providersAttachmentsMock) vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) vi.mock('@/providers/utils', () => providersUtilsMock) -vi.mock('@/providers/vllm/utils', () => ({ - checkForForcedToolUsage: mockCheckForced, - createReadableStreamFromVLLMStream: mockCreateStream, -})) + vi.mock('@/tools', () => toolsMock) vi.mock('@/stores/providers', () => ({ useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) }, @@ -45,6 +40,7 @@ import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderToolConfig } from '@/providers/types' import { vllmProvider } from '@/providers/vllm/index' +const mockCheckForced = providersUtilsMockFns.mockCheckForForcedToolUsageOpenAI const mockCreate = openaiMockFns.mockChatCompletionsCreate const mockCapture = providersConversationHistoryMockFns.mockCaptureProviderConversationStep const mockRecordUsage = providersConversationHistoryMockFns.mockRecordProviderConversationUsage @@ -119,7 +115,6 @@ describe('vllmProvider', () => { hasFilteredTools: false, }) mockCheckForced.mockReturnValue({ hasUsedForcedTool: false, usedForcedTools: [] }) - mockCreateStream.mockReturnValue(new ReadableStream({ start: (c) => c.close() })) mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) mockCreatePinnedFetch.mockReturnValue(pinnedFetchFn) @@ -445,7 +440,7 @@ describe('vllmProvider', () => { }) it('streams directly when there are no tools, requesting usage in the stream', async () => { - mockCreate.mockResolvedValueOnce({}) + mockCreate.mockResolvedValueOnce((async function* () {})()) const result = await vllmProvider.executeRequest({ model: 'vllm/llama-3', diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index cf25670ae0b..a861d8017dd 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -22,6 +22,7 @@ import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -39,12 +40,12 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + checkForForcedToolUsageOpenAI, isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, } from '@/providers/utils' -import { checkForForcedToolUsage, createReadableStreamFromVLLMStream } from '@/providers/vllm/utils' import { useProvidersStore } from '@/stores/providers' const logger = createLogger('VLLMProvider') @@ -262,9 +263,10 @@ export const vllmProvider: ProviderConfig = { initialCost: { input: 0, output: 0, total: 0 }, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromVLLMStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'vLLM', + request, + onComplete: ({ content, usage }) => { let cleanContent = content if (cleanContent && request.responseFormat) { cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() @@ -290,8 +292,7 @@ export const vllmProvider: ProviderConfig = { finalizeTiming() }, - request - ), + }), }) return streamingResult @@ -349,9 +350,10 @@ export const vllmProvider: ProviderConfig = { ] if (originalToolChoice) { - const forcedResult = checkForForcedToolUsage( + const forcedResult = checkForForcedToolUsageOpenAI( currentResponse, originalToolChoice, + 'vLLM', forcedTools, usedForcedTools ) @@ -582,9 +584,10 @@ export const vllmProvider: ProviderConfig = { } if (nextPayload.tool_choice && typeof nextPayload.tool_choice === 'object') { - const forcedResult = checkForForcedToolUsage( + const forcedResult = checkForForcedToolUsageOpenAI( currentResponse, nextPayload.tool_choice, + 'vLLM', forcedTools, usedForcedTools ) diff --git a/apps/sim/providers/vllm/utils.ts b/apps/sim/providers/vllm/utils.ts deleted file mode 100644 index ecf9cb8e586..00000000000 --- a/apps/sim/providers/vllm/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' - -/** - * Creates an agent-events stream from a vLLM streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromVLLMStream( - vllmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(vllmStream, { - request, - providerName: 'vLLM', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - -/** - * Checks if a forced tool was used in a vLLM response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ -export function checkForForcedToolUsage( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI(response, toolChoice, 'vLLM', forcedTools, usedForcedTools) -} diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 45580049949..8b78631978a 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -18,6 +18,7 @@ import { import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -35,16 +36,13 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + checkForForcedToolUsageOpenAI, isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, } from '@/providers/utils' -import { - checkForForcedToolUsage, - createReadableStreamFromXAIStream, - createResponseFormatPayload, -} from '@/providers/xai/utils' +import { createResponseFormatPayload } from '@/providers/xai/utils' const logger = createLogger('XAIProvider') @@ -166,9 +164,10 @@ export const xAIProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromXAIStream( - streamResponse, - (content, usage) => { + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'xAI', + request, + onComplete: ({ content, usage }) => { output.content = content output.tokens = { input: usage.prompt_tokens, @@ -187,8 +186,7 @@ export const xAIProvider: ProviderConfig = { total: costResult.total, } }, - request - ), + }), }) return streamingResult @@ -257,9 +255,10 @@ export const xAIProvider: ProviderConfig = { }, ] if (originalToolChoice) { - const result = checkForForcedToolUsage( + const result = checkForForcedToolUsageOpenAI( currentResponse, originalToolChoice, + 'xAI', forcedTools, usedForcedTools ) @@ -521,9 +520,10 @@ export const xAIProvider: ProviderConfig = { ) } if (nextPayload.tool_choice && typeof nextPayload.tool_choice === 'object') { - const result = checkForForcedToolUsage( + const result = checkForForcedToolUsageOpenAI( currentResponse, nextPayload.tool_choice, + 'xAI', forcedTools, usedForcedTools ) diff --git a/apps/sim/providers/xai/utils.ts b/apps/sim/providers/xai/utils.ts index 5b4f04e6384..0241ae07829 100644 --- a/apps/sim/providers/xai/utils.ts +++ b/apps/sim/providers/xai/utils.ts @@ -1,28 +1,3 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' -import { checkForForcedToolUsageOpenAI } from '@/providers/utils' - -/** - * Creates an agent-events stream from an xAI streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromXAIStream( - xaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(xaiStream, { - request, - providerName: 'xAI', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} - /** * Creates a response format payload for xAI requests with JSON schema. */ @@ -50,16 +25,3 @@ export function createResponseFormatPayload( return payload } - -/** - * Checks if a forced tool was used in an xAI response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ -export function checkForForcedToolUsage( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, - forcedTools: string[], - usedForcedTools: string[] -): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { - return checkForForcedToolUsageOpenAI(response, toolChoice, 'xAI', forcedTools, usedForcedTools) -} diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index efa06a39a9a..328195c2f48 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -17,6 +17,7 @@ import { import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { getChatCompletionConversationUsage } from '@/providers/openai-compat/conversation-usage' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -38,7 +39,6 @@ import { prepareToolsWithUsageControl, sumToolCosts, } from '@/providers/utils' -import { createReadableStreamFromZaiStream } from '@/providers/zai/utils' const logger = createLogger('ZaiProvider') @@ -202,29 +202,32 @@ export const zaiProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromZaiStream( + createOpenAICompatibleAgentEventStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }, - request + { + providerName: 'Z.ai', + request, + onComplete: ({ content, usage }) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }, + } ), }) diff --git a/apps/sim/providers/zai/utils.ts b/apps/sim/providers/zai/utils.ts deleted file mode 100644 index 6b40b6fbeff..00000000000 --- a/apps/sim/providers/zai/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' -import type { ProviderRequest } from '@/providers/types' - -/** - * Creates an agent-events stream from a Z.ai streaming response. - * Uses the shared OpenAI-compatible agent event streaming utility. - */ -export function createReadableStreamFromZaiStream( - zaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void, - request?: ProviderRequest -): ReadableStream { - return createOpenAICompatibleAgentEventStream(zaiStream, { - request, - providerName: 'Z.ai', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -} diff --git a/bun.lock b/bun.lock index d8a2f9177c7..1a944bffe0e 100644 --- a/bun.lock +++ b/bun.lock @@ -21,8 +21,10 @@ "gray-matter": "4.0.3", "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", + "knip": "6.37.0", "lint-staged": "16.0.0", "opentype.js": "1.3.4", + "pdf-lib": "1.17.1", "react": "19.2.4", "remark-gfm": "4.0.1", "remark-parse": "11.0.0", @@ -263,6 +265,8 @@ "@tiptap/extension-code-block": "3.30.5", "@tiptap/extension-collaboration": "3.30.5", "@tiptap/extension-collaboration-caret": "3.30.5", + "@tiptap/extension-document": "3.30.5", + "@tiptap/extension-hard-break": "3.30.5", "@tiptap/extension-image": "3.30.5", "@tiptap/extension-list": "3.30.5", "@tiptap/extension-paragraph": "3.30.5", @@ -385,6 +389,7 @@ }, "devDependencies": { "@next/env": "16.3.4", + "@opentelemetry/context-async-hooks": "2.10.0", "@sim/testing": "workspace:*", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", @@ -393,6 +398,7 @@ "@trigger.dev/build": "4.5.16", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", + "@types/hast": "3.0.4", "@types/heic-convert": "2.1.1", "@types/html-to-text": "9.0.4", "@types/js-yaml": "4.0.9", @@ -1239,11 +1245,11 @@ "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -1551,7 +1557,7 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], "@next/env": ["@next/env@16.3.4", "", {}, "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q=="], @@ -1679,7 +1685,83 @@ "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.150.0", "", { "os": "android", "cpu": "arm" }, "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.150.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.150.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.150.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.150.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.150.0", "", { "os": "linux", "cpu": "arm" }, "sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.150.0", "", { "os": "linux", "cpu": "arm" }, "sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.150.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.150.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.150.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.150.0", "", { "os": "linux", "cpu": "none" }, "sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.150.0", "", { "os": "linux", "cpu": "none" }, "sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.150.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.150.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.150.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.150.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.150.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.150.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.150.0", "", { "os": "win32", "cpu": "x64" }, "sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw=="], + + "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.24.2", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], @@ -2165,7 +2247,7 @@ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], "@types/archiver": ["@types/archiver@8.0.0", "", { "dependencies": { "@types/node": "*", "@types/readdir-glob": "*" } }, "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A=="], @@ -3135,6 +3217,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -3165,6 +3249,8 @@ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + "formatly": ["formatly@0.7.1", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ=="], + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -3213,7 +3299,7 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], @@ -3433,7 +3519,7 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="], @@ -3501,6 +3587,8 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "knip": ["knip@6.37.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.150.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.1", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-NKCYuoGKJ+o2AuoCuV1mDvfMYnSSWfDXqxwmzxeHyd8fftjNRl8TBUg3fbSrtJjhm7Vj6sc1PX3WZnZhECs9EQ=="], + "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -3917,6 +4005,10 @@ "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="], + + "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], + "p-cancelable": ["p-cancelable@4.0.1", "", {}, "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -3925,7 +4017,7 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -4345,6 +4437,8 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], "socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="], @@ -4427,7 +4521,7 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], @@ -4543,7 +4637,7 @@ "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], @@ -4571,6 +4665,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "unbash": ["unbash@4.0.11", "", {}, "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], @@ -4653,6 +4749,8 @@ "walk-sync": ["walk-sync@0.2.7", "", { "dependencies": { "ensure-posix-path": "^1.0.0", "matcher-collection": "^1.0.0" } }, "sha512-OH8GdRMowEFr0XSHQeX5fGweO6zSVHo7bG/0yJQx6LAj9Oukz0C8heI3/FYectT66gY0IPGe89kOvU410/UNpg=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], @@ -4727,7 +4825,7 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "yaml-js": ["yaml-js@0.3.1", "", {}, "sha512-LjoIFHCtSfkGzPsmYmfDsW+TShtQBcY7lwH1yLQ5brJHXRhjteUnVE2ThCbz1madq8JUZIAjFiavfnIFeTO8CQ=="], @@ -4765,6 +4863,8 @@ "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4773,28 +4873,190 @@ "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], + "@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-crypto/sha256-browser/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], + "@aws-crypto/sha256-js/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-crypto/supports-web-crypto/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-crypto/util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/checksums/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-appconfig/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-appconfigdata/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-athena/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-bedrock-runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-cloudformation/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-cloudtrail/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-cloudwatch/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-cloudwatch-logs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-codepipeline/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-dynamodb/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-ec2/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-ecs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-iam/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-identitystore/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-lambda/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-organizations/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-rds-data/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-s3/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-secrets-manager/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-sesv2/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-sqs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-ssm/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-sso-admin/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-sts/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-textract/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + "@aws-sdk/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-cognito-identity/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-env/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-http/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-ini/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-login/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-node/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-process/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + "@aws-sdk/credential-provider-sso/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-provider-web-identity/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/credential-providers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/dynamodb-codec/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/endpoint-cache/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/eventstream-handler-node/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/lib-dynamodb/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/lib-storage/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/middleware-endpoint-discovery/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/middleware-eventstream/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-sdk/middleware-sdk-ec2/@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + "@aws-sdk/middleware-sdk-ec2/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/middleware-sdk-s3/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/middleware-sdk-sqs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-sdk/middleware-websocket/@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + "@aws-sdk/middleware-websocket/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/nested-clients/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/s3-request-presigner/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + "@aws-sdk/signature-v4-multi-region/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/token-providers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/util-dynamodb/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/util-locate-window/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/xml-builder/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure-rest/core-client/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/abort-controller/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@azure/arm-containerinstance/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="], + "@azure/arm-containerinstance/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/communication-common/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/communication-email/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-auth/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-client/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-lro/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-paging/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-rest-pipeline/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-tracing/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/core-xml/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/identity/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/keyvault-common/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/keyvault-keys/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/logger/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/storage-blob/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/storage-common/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@azure/storage-queue/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -4835,6 +5097,10 @@ "@daytona/sdk/socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + "@daytona/sdk/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@earendil-works/pi-agent-core/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], @@ -4849,12 +5115,12 @@ "@earendil-works/pi-coding-agent/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@earendil-works/pi-coding-agent/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@earendil-works/pi-coding-agent/undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + "@earendil-works/pi-coding-agent/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4871,12 +5137,22 @@ "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + "@emnapi/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@esbuild-kit/esm-loader/get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@inquirer/core/mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -4909,6 +5185,16 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@opentelemetry/configuration/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "@peculiar/asn1-schema/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@peculiar/json-schema/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@peculiar/utils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@peculiar/webcrypto/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@playwright/browser-chromium/playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], "@playwright/test/playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], @@ -4985,27 +5271,53 @@ "@redis/client/cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], "@sim/emcn/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], "@sim/emcn/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], + "@smithy/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@smithy/credential-provider-imds/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@smithy/eventstream-codec/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], + "@smithy/eventstream-codec/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@smithy/fetch-http-handler/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@smithy/is-array-buffer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "@smithy/middleware-compression/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@smithy/node-http-handler/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@smithy/signature-v4/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], "@smithy/signature-v4/@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], + "@smithy/signature-v4/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@smithy/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@smithy/util-buffer-from/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@smithy/util-utf8/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], + "@smithy/util-utf8/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -5055,6 +5367,8 @@ "@trigger.dev/sdk/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/archiver/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/buffer-from/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5085,6 +5399,8 @@ "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@typespec/ts-http-runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -5109,14 +5425,14 @@ "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], - "app-builder-lib/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "archiver/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "aria-hidden/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "artillery/@azure/storage-blob": ["@azure/storage-blob@12.33.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.4.1", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-2SX8oP8PyblUcAFZSg39c8Ls+tFjavM6sBeV+qpw33mRzRhI/5hrFJmJ/x0H9xx5l6ECPvgSP8uPxqTeVbHNIA=="], "artillery/@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], @@ -5133,6 +5449,8 @@ "artillery/socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + "asn1js/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -5259,6 +5577,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "framer-motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "fumadocs-mdx/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -5339,6 +5659,8 @@ "lint-staged/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "lint-staged/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "log-update/ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -5359,6 +5681,8 @@ "mixpanel/https-proxy-agent": ["https-proxy-agent@5.0.0", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA=="], + "motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "neo4j-driver-bolt-connection/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -5379,14 +5703,12 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], - "pino-pretty/strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + "pkijs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "playwright/playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], @@ -5403,18 +5725,30 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "pvtsutils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-email/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "react-email/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "react-email/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], + "react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -5423,8 +5757,12 @@ "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "rxjs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5479,8 +5817,16 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "vfile-matter/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "webcrypto-core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "widest-line/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -5545,10 +5891,14 @@ "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@earendil-works/pi-ai/@smithy/node-http-handler/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], "@earendil-works/pi-ai/@smithy/node-http-handler/@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], + "@earendil-works/pi-ai/@smithy/node-http-handler/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@electron/fuses/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], @@ -5605,6 +5955,8 @@ "@grpc/proto-loader/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "@img/sharp-wasm32/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -5643,6 +5995,14 @@ "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@sim/emcn/@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@sim/emcn/@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -5729,6 +6089,8 @@ "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "artillery/@azure/storage-blob/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "artillery/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "artillery/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -6113,6 +6475,8 @@ "@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@sim/emcn/@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="], diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 00000000000..79239a303e0 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,94 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + // Gate reachability and dependency ownership. Export/type pruning needs review + // of public package contracts and test-only consumers, not a blanket threshold. + "include": ["files", "dependencies", "unlisted", "unresolved"], + "workspaces": { + ".": { + "entry": ["scripts/**/*.{ts,tsx}", "vitest.shared.ts"], + "paths": { "@/*": ["./apps/sim/*"] }, + // This alias selects the native tsc binary; see check-native-typecheck.ts. + "ignoreDependencies": ["@typescript/native"] + }, + "apps/sim": { + "entry": [ + // Container bootstrap and Trigger's directory-discovered task graph. + "bootstrap.ts", + "trigger.config.ts", + "background/**/*.ts", + "scripts/**/*.{ts,tsx}", + "**/*.integration.ts", + "vitest.integration.setup.ts", + // Spawned worker and assets bundled/loaded by filename. + "lib/execution/isolated-vm-worker.cjs", + "lib/execution/sandbox/bundles/*.{ts,cjs}", + // Compile-time fixture compatibility assertion, included by tsconfig. + "lib/table/fixture-contract.ts", + // scripts/generate-docs.ts imports this through pathToFileURL. + "app/(landing)/integrations/data/landing-content.ts", + // Mirrored from the sibling worker's contracts:sync, not app-owned. + "lib/mothership/generated/account.ts", + "lib/mothership/generated/local-files.ts" + ], + // Required/discoverable barrels whose children have direct live imports. + // Ignore only the barrel file finding, so it cannot keep dead children alive. + "ignoreIssues": { + "sandbox-tasks/index.ts": ["files"], + "components/mcp/index.ts": ["files"], + "triggers/quickbooks/index.ts": ["files"], + "lib/api/contracts/selectors/index.ts": ["files"], + "lib/api/contracts/tools/index.ts": ["files"], + "lib/api/contracts/tools/communication/index.ts": ["files"], + "lib/api/contracts/tools/databases/index.ts": ["files"], + "lib/api/contracts/tools/media/index.ts": ["files"], + "lib/api/contracts/v1/index.ts": ["files"], + "app/(landing)/components/shared/two-block-workflow-preview/index.ts": ["files"], + "app/(landing)/customers/components/customer-story-card/index.ts": ["files"], + "app/(landing)/customers/components/customer-story-media/index.ts": ["files"], + "app/(landing)/customers/components/customer-story-page/index.ts": ["files"] + }, + "ignoreDependencies": [ + // docker/app.Dockerfile invokes this binary to rebuild isolated-vm. + "node-gyp", + // Imported inside the generated source string in sandbox/bundles/build.ts. + "pptxgenjs", + // Next aliases this marker to its bundled server-only module. + "server-only", + // Knip's email CLI plugin infers this transitive preview implementation. + "@react-email/ui" + ], + // Live test spawns this path with cwd=MSHIP_WORKER_ROOT (a sibling repo). + "ignoreUnresolved": ["tools/probes/src/controller-run-read.ts"] + }, + "apps/realtime": { "entry": ["src/bootstrap.ts"] }, + "apps/desktop": { + // scripts/build.ts and e2e/updater.spec.ts supply these to esbuild by path. + "entry": [ + "src/main/index.ts", + "src/preload/*.ts", + "src/preload/browser/index.ts", + "src/renderer/*/index.tsx", + "scripts/*.ts", + "e2e/fixtures/updater.ts" + ], + // ensure-pty-prebuilds.ts assembles these package names for universal builds. + "ignoreDependencies": ["@lydell/node-pty-darwin-arm64", "@lydell/node-pty-darwin-x64"] + }, + "packages/db": { + "entry": [ + "scripts/*.ts", + "script-migrations/*.ts", + "**/*.integration.ts", + "vitest.integration.setup.ts" + ] + }, + "packages/sim-cli": { + "entry": ["src/index.ts", "src/runtime.ts", "scripts/*.ts"] + }, + "packages/ts-sdk": { "entry": ["examples/*.ts"] }, + // The contract audit reads this snapshot by filename. + "packages/desktop-bridge": { "entry": ["contract-snapshot.ts"] }, + // This shared config is consumed by apps that own the Next dependency. + "packages/tsconfig": { "ignoreUnresolved": ["next"] } + } +} diff --git a/package.json b/package.json index d9a38d50b25..618da09a7f6 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,17 @@ "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", - "test": "bun run test:setup && bun run test:scripts && turbo run test", + "test": "bun run test:scripts && turbo run test", "test:setup": "bun run --cwd packages/sim-setup test", "test:integration": "bun --no-env-file scripts/test-integration.ts", "test:search-performance": "KNOWLEDGE_SEARCH_PERFORMANCE_TEST=true bun --no-env-file scripts/test-integration.ts search-latency", - "format": "turbo run format", - "format:check": "turbo run format:check", - "lint": "turbo run lint", - "lint:check": "turbo run lint:check", + "format": "biome format --write scripts && turbo run format", + "format:check": "biome format scripts && turbo run format:check", + "lint": "biome check --write --unsafe scripts && turbo run lint", + "lint:check": "biome check scripts && turbo run lint:check", "lint:helm": "helm lint helm/sim --strict --values helm/sim/ci/default-values.yaml", - "lint:all": "turbo run lint && bun run lint:helm", - "check": "turbo run format:check", + "lint:all": "bun run lint && bun run lint:helm", + "check": "bun run format:check", "check:egress-boundary": "bun run scripts/check-egress-boundary.ts", "check:boundaries": "bun run scripts/check-monorepo-boundaries.ts", "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", @@ -123,7 +123,8 @@ "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts", "test:scripts": "vitest run --config scripts/vitest.config.ts", - "check:agent-cli-boundary": "bun run scripts/check-agent-cli-boundary.ts" + "check:agent-cli-boundary": "bun run scripts/check-agent-cli-boundary.ts", + "check:dead-code": "bun run node_modules/knip/bin/knip.js" }, "overrides": { "react": "19.2.4", @@ -174,8 +175,10 @@ "gray-matter": "4.0.3", "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", + "knip": "6.37.0", "lint-staged": "16.0.0", "opentype.js": "1.3.4", + "pdf-lib": "1.17.1", "react": "19.2.4", "remark-gfm": "4.0.1", "remark-parse": "11.0.0", diff --git a/scripts/check-monorepo-boundaries.test.ts b/scripts/check-monorepo-boundaries.test.ts new file mode 100644 index 00000000000..98a92b374b4 --- /dev/null +++ b/scripts/check-monorepo-boundaries.test.ts @@ -0,0 +1,69 @@ +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const ROOT = path.resolve(import.meta.dirname, '..') + +describe('package boundary command', () => { + it('rejects app module edges regardless of import form, without treating documentation as imports', () => { + const fixture = mkdtempSync(path.join(ROOT, 'node_modules/.boundary-audit-')) + try { + mkdirSync(path.join(fixture, 'scripts')) + mkdirSync(path.join(fixture, 'packages/example/src'), { recursive: true }) + mkdirSync(path.join(fixture, 'apps/web'), { recursive: true }) + mkdirSync(path.join(fixture, 'apps/docs')) + mkdirSync(path.join(fixture, 'apps/no-package')) + writeFileSync( + path.join(fixture, 'apps/web/package.json'), + JSON.stringify({ name: '@example/web' }) + ) + writeFileSync( + path.join(fixture, 'apps/docs/package.json'), + JSON.stringify({ name: 'example-docs' }) + ) + copyFileSync( + path.join(ROOT, 'scripts/check-monorepo-boundaries.ts'), + path.join(fixture, 'scripts/check-monorepo-boundaries.ts') + ) + const cases = [ + ["import { value } from '@/lib/value'", false], + ["import '@/lib/value'", false], + ["export * from '../../../apps/sim/lib/value'", false], + ["import { value } from '../../../apps/sim/lib/value'", false], + ["const value = import('@/lib/value')", false], + ['const value = import(`@/lib/value`)', false], + ["const value = require('../../../apps/sim/lib/value')", false], + ["import value = require('../../../apps/sim/lib/value')", false], + ["type Value = import('../../../apps/sim/lib/value').Value", false], + [String.raw`import '\x40/lib/value'`, false], + ["import { value } from '@example/web'", false], + ["import { value } from '@example/web/lib/value'", false], + ["import 'example-docs'", false], + ["import 'example-docs/lib/value'", false], + ["import { value } from './apps/value'", true], + ["import { value } from '@sim/utils/errors'", true], + ["import { value } from '@example/web-utils'", true], + ["import { value } from 'example-docs-utils'", true], + ["// import { value } from '@/lib/value'", true], + ['const example = "import { value } from \'@/lib/value\'"', true], + ] as const + + for (const [source, allowed] of cases) { + writeFileSync(path.join(fixture, 'packages/example/src/index.ts'), `${source}\n`) + const result = spawnSync( + 'bun', + [path.join(fixture, 'scripts/check-monorepo-boundaries.ts')], + { + cwd: fixture, + encoding: 'utf8', + } + ) + expect.soft(result.status, `${source}\n${result.stderr}`).toBe(allowed ? 0 : 1) + if (!allowed) expect.soft(result.stderr).toContain('packages/example/src/index.ts:1') + } + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/scripts/check-monorepo-boundaries.ts b/scripts/check-monorepo-boundaries.ts index c3623ee6fdc..f158e96c714 100644 --- a/scripts/check-monorepo-boundaries.ts +++ b/scripts/check-monorepo-boundaries.ts @@ -1,15 +1,21 @@ #!/usr/bin/env bun import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' +import ts from '@typescript/typescript6' const ROOT = path.resolve(import.meta.dir, '..') const PACKAGES_DIR = path.join(ROOT, 'packages') -const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ - { pattern: /from\s+['"]@\/(?!\*)/g, description: "'@/' path alias (apps/sim-only)" }, - { pattern: /from\s+['"]\.\.\/\.\.\/apps\//g, description: 'relative import into apps/' }, - { pattern: /from\s+['"]apps\//g, description: "bare 'apps/' import" }, -] +/** Package dependencies point toward shared code, including type-only imports. */ +function forbiddenImport(file: string, specifier: string, appNames: ReadonlySet): boolean { + if (specifier.startsWith('@/') || specifier.startsWith('apps/')) return true + for (const name of appNames) { + if (specifier === name || specifier.startsWith(`${name}/`)) return true + } + if (!specifier.startsWith('.')) return false + const target = path.relative(ROOT, path.resolve(path.dirname(file), specifier)) + return target === 'apps' || target.startsWith(`apps${path.sep}`) +} const SKIP_DIRS = new Set(['node_modules', 'dist', '.next', '.turbo', 'coverage']) @@ -28,6 +34,21 @@ async function walk(dir: string, results: string[] = []): Promise { } async function main() { + const appNames = new Set() + for (const entry of await readdir(path.join(ROOT, 'apps'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const content = await readFile( + path.join(ROOT, 'apps', entry.name, 'package.json'), + 'utf8' + ).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null + throw error + }) + if (content === null) continue + const manifest = JSON.parse(content) as { name?: string } + if (typeof manifest.name === 'string') appNames.add(manifest.name) + } + const packagesEntries = await readdir(PACKAGES_DIR, { withFileTypes: true }) const packageDirs = packagesEntries .filter((entry) => entry.isDirectory()) @@ -39,21 +60,42 @@ async function main() { const files = await walk(dir) for (const file of files) { const content = await readFile(file, 'utf8') + const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true) const lines = content.split('\n') - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - for (const { pattern, description } of FORBIDDEN_PATTERNS) { - pattern.lastIndex = 0 - if (pattern.test(line)) { - offenders.push({ - file: path.relative(ROOT, file), - line: i + 1, - description, - snippet: line.trim(), - }) - } + function visit(node: ts.Node): void { + let module: ts.Node | undefined + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + module = node.moduleSpecifier + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + module = node.moduleReference.expression + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + module = node.argument.literal + } else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) + ) { + module = node.arguments[0] + } + if ( + module && + ts.isStringLiteralLike(module) && + forbiddenImport(file, module.text, appNames) + ) { + const { line } = source.getLineAndCharacterOfPosition(module.getStart(source)) + offenders.push({ + file: path.relative(ROOT, file), + line: line + 1, + description: `import into apps/: ${module.text}`, + snippet: lines[line].trim(), + }) } + ts.forEachChild(node, visit) } + visit(source) } } diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts index 94d1958a46a..814ab48e571 100644 --- a/scripts/check-openapi.ts +++ b/scripts/check-openapi.ts @@ -1,12 +1,10 @@ #!/usr/bin/env bun import { spawnSync } from 'node:child_process' import path from 'node:path' -import { localBin } from './local-bin' const ROOT = path.resolve(import.meta.dir, '..') const CHECKS = [ [process.execPath, 'run', 'scripts/generate-openapi.ts', '--check'], - [localBin('vitest'), 'run', '--config', 'scripts/openapi/vitest.config.ts'], [process.execPath, 'run', 'scripts/check-openapi-specs.ts'], ] as const diff --git a/scripts/check-script-test-coverage.test.ts b/scripts/check-script-test-coverage.test.ts new file mode 100644 index 00000000000..4080a8ffb53 --- /dev/null +++ b/scripts/check-script-test-coverage.test.ts @@ -0,0 +1,97 @@ +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const ROOT = path.resolve(import.meta.dirname, '..') + +describe('script collection command', () => { + it('collects nested scripts and reports an orphan when a config drops its tests', () => { + const fixture = mkdtempSync(path.join(ROOT, 'node_modules/.script-coverage-')) + try { + mkdirSync(path.join(fixture, 'scripts/nested'), { recursive: true }) + mkdirSync(path.join(fixture, 'scripts/openapi')) + mkdirSync(path.join(fixture, 'node_modules/.bin'), { recursive: true }) + symlinkSync( + path.join(ROOT, 'node_modules/.bin/vitest'), + path.join(fixture, 'node_modules/.bin/vitest') + ) + for (const file of [ + 'scripts/check-script-test-coverage.ts', + 'scripts/local-bin.ts', + 'scripts/vitest.config.ts', + 'vitest.shared.ts', + ]) { + copyFileSync(path.join(ROOT, file), path.join(fixture, file)) + } + writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({ + scripts: { + test: 'bun run test:scripts', + 'test:scripts': 'vitest run --config scripts/vitest.config.ts', + }, + }) + ) + for (const file of [ + 'nested/check.test.ts', + 'nested/view.test.tsx', + 'nested/check.spec.ts', + 'openapi/api.test.ts', + ]) { + writeFileSync(path.join(fixture, 'scripts', file), '') + } + const run = () => + spawnSync('bun', [path.join(fixture, 'scripts/check-script-test-coverage.ts')], { + cwd: fixture, + encoding: 'utf8', + }) + const valid = run() + expect(valid.status, valid.stderr).toBe(0) + expect(valid.stdout).toContain('4 script tests collected') + + const manifestPath = path.join(fixture, 'package.json') + const originalManifest = readFileSync(manifestPath, 'utf8') + for (const command of [ + 'echo tests-disabled', + 'vitest run --config scripts/alternate.config.ts', + 'vitest run --config scripts/vitest.config.ts scripts/nested/check.test.ts', + ]) { + writeFileSync( + manifestPath, + JSON.stringify({ scripts: { test: 'bun run test:scripts', 'test:scripts': command } }) + ) + const disabled = run() + expect.soft(disabled.status, command).toBe(1) + expect + .soft(disabled.stderr, command) + .toContain('test:scripts must run the complete scripts') + } + writeFileSync(manifestPath, originalManifest) + + const config = path.join(fixture, 'scripts/vitest.config.ts') + writeFileSync( + config, + readFileSync(config, 'utf8').replace( + /include: \[[^\]]+\]/, + "include: ['scripts/*.test.ts']" + ) + ) + const missing = run() + expect(missing.status, missing.stderr).toBe(1) + expect(missing.stderr).toContain('scripts/nested/check.test.ts') + expect(missing.stderr).toContain('scripts/nested/view.test.tsx') + expect(missing.stderr).toContain('scripts/openapi/api.test.ts') + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/scripts/check-script-test-coverage.ts b/scripts/check-script-test-coverage.ts index e92dafdfe13..d7e96833caa 100644 --- a/scripts/check-script-test-coverage.ts +++ b/scripts/check-script-test-coverage.ts @@ -1,14 +1,14 @@ #!/usr/bin/env bun /** - * Asserts every `scripts/*.test.ts` file is collected by the scripts Vitest config. + * Asserts every script test is collected by the scripts Vitest config. * * The root `test` script once chained a hand-maintained list of `test:*` entries, and a * hand-maintained list silently drifts from the files on disk: a test added without a matching * entry never runs, in CI or locally, and nothing reports it. `scripts/check-migrations-safety.test.ts` * sat unreferenced and green for exactly that reason. `scripts/vitest.config.ts` now collects * the directory by glob, so drift can only come from a file the glob does not match (a test in a - * subdirectory, a different suffix) or from the `test` script no longer chaining `test:scripts`. - * This guard checks both by asking Vitest which files it would run. + * subdirectory, a different suffix) or from the test commands no longer running that config. + * This guard checks the command contract and asks Vitest which files it would run. * * `run-audits.ts` derives its own list from the `check:*` namespace precisely so a new audit is * picked up by default, so this guard registers itself simply by being named `check:*` — it cannot @@ -16,9 +16,12 @@ */ import { readdirSync } from 'node:fs' import path from 'node:path' +import { localBin } from './local-bin' const ROOT = path.resolve(import.meta.dir, '..') const SUB_SCRIPT_PATTERN = /bun run ([\w:-]+)/g +const SCRIPTS_TEST_CONFIG = 'scripts/vitest.config.ts' +const SCRIPTS_TEST_COMMAND = `vitest run --config ${SCRIPTS_TEST_CONFIG}` const manifest = await Bun.file(path.join(ROOT, 'package.json')).json() const commands = manifest.scripts as Record @@ -41,8 +44,14 @@ if (!reachableScripts('test').has('test:scripts')) { process.exit(1) } +// Keep collection tied to the actual runner; filters or another config can silently skip tests. +if (commands['test:scripts']?.trim() !== SCRIPTS_TEST_COMMAND) { + console.error(`test:scripts must run the complete scripts Vitest config: ${SCRIPTS_TEST_COMMAND}`) + process.exit(1) +} + const listed = Bun.spawnSync( - ['bunx', 'vitest', 'list', '--json', '--filesOnly', '--config', 'scripts/vitest.config.ts'], + [localBin('vitest'), 'list', '--json', '--filesOnly', '--config', SCRIPTS_TEST_CONFIG], { cwd: ROOT, } @@ -57,16 +66,24 @@ const collected = new Set( ) ) -const onDisk = readdirSync(path.join(ROOT, 'scripts')) - .filter((file) => file.endsWith('.test.ts')) - .map((file) => `scripts/${file}`) - .sort() +/** Inventory independently of the include globs so a narrowed config cannot hide its omissions. */ +function testFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + if (['node_modules', '.git', 'dist'].includes(entry.name)) return [] + const file = path.join(dir, entry.name) + if (entry.isDirectory()) return testFiles(file) + return /\.(test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name) + ? [path.relative(ROOT, file).split(path.sep).join('/')] + : [] + }) +} +const onDisk = testFiles(path.join(ROOT, 'scripts')).sort() const orphaned = onDisk.filter((file) => !collected.has(file)) if (orphaned.length > 0) { console.error( `Script tests never run by \`bun run test\`:\n${orphaned.map((file) => ` - ${file}`).join('\n')}\n` + - 'Make sure the `scripts/vitest.config.ts` include glob matches them.' + 'Make sure the scripts Vitest config collects them.' ) process.exit(1) } diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index e7f6a9c6491..a66142f9f72 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -259,7 +259,7 @@ describe('generated OpenAPI documents', () => { ) } } - }) + }, 30_000) it('covers the complete public v2 operation surface with canonical errors', () => { const outputs = DOCUMENTS.map((document) => document.output) diff --git a/scripts/openapi/vitest.config.ts b/scripts/openapi/vitest.config.ts deleted file mode 100644 index 5880af8e767..00000000000 --- a/scripts/openapi/vitest.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import path from 'node:path' -import { defineConfig, mergeConfig } from 'vitest/config' -import sharedConfig from '../../vitest.shared' - -const ROOT = path.resolve(import.meta.dirname, '../..') - -export default mergeConfig( - sharedConfig, - defineConfig({ - resolve: { - alias: { - '@': path.join(ROOT, 'apps/sim'), - }, - }, - test: { - include: ['scripts/openapi/**/*.test.ts'], - /** - * The determinism check serializes all seven published documents twice — - * roughly 2MB of JSON — so it was running against vitest's 5s default - * rather than a budget anyone chose. It already sat just under that on - * staging, and this branch's richer descriptions tip it over. - */ - testTimeout: 30_000, - }, - }) -) diff --git a/scripts/vitest.config.ts b/scripts/vitest.config.ts index ca2177118e8..f7a7950fd6b 100644 --- a/scripts/vitest.config.ts +++ b/scripts/vitest.config.ts @@ -4,9 +4,9 @@ import sharedConfig from '../vitest.shared' /** * Repo-level scripts have their own suites. One invocation for all of them - * replaces eighteen sequential `vitest run ` processes, each of which - * paid its own startup. `scripts/openapi` keeps its own config and runs under - * `check:openapi`. + * replaces separate `vitest run ` processes, each of which paid its own + * startup. OpenAPI suites share this owner so the collection guard verifies + * every script test against the configuration `bun run test` actually runs. * * The root is pinned so `bun run test:scripts` behaves the same from any cwd. */ @@ -14,11 +14,14 @@ export default mergeConfig( sharedConfig, defineConfig({ resolve: { - alias: { '@scripts': fileURLToPath(new URL('.', import.meta.url)) }, + alias: { + '@scripts': fileURLToPath(new URL('.', import.meta.url)), + '@': fileURLToPath(new URL('../apps/sim', import.meta.url)), + }, }, test: { root: fileURLToPath(new URL('..', import.meta.url)), - include: ['scripts/*.test.ts'], + include: ['scripts/**/*.{test,spec}.{ts,tsx,js,jsx,mts,mjs,cts,cjs}'], }, }) ) diff --git a/turbo.json b/turbo.json index 349b157b2c5..c662535ebbe 100644 --- a/turbo.json +++ b/turbo.json @@ -41,16 +41,20 @@ "outputs": [] }, "lint": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/biome.json"], "dependsOn": ["transit"], "outputs": [] }, "format": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/biome.json"], "outputs": [] }, "format:check": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/biome.json"], "outputs": [] }, "lint:check": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/biome.json"], "dependsOn": ["transit"], "outputs": [] }, From 66e5705b00752f46b76ee8a1e261d2ffbb097da3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 19:29:40 -0700 Subject: [PATCH 03/22] fix(tables): match unique JSON values exactly and validate row writes against the live schema (#8394) * fix(tables): match unique JSON values exactly and validate row writes against the live schema - Unique checks and the upsert conflict probe matched JSON objects and arrays by containment, so `{"a":1}` counted as a duplicate of `{"a":1,"b":2}` and upsert could overwrite a row that only contained its target. They now also require exact jsonb equality, keeping containment as the GIN-indexed leading clause; JSON values take per-value locks like other types - Row writes validated against a schema snapshot read before their transaction, so a concurrent make-unique, make-required, retype, or column delete could commit violating data. Each write now reads the live schema under the table's schema lock (shared) through `user_table_schema_for_write`, in the statement it already runs first, and validates against it - The background update runner derives each batch's patch from the raw payload against the live schema, through the same helper as the inline bulk update, and refuses unique and required-null patches under the lock * fix(tables): install the schema guard for db:push, refit rows from raw input - user_table_schema_for_write moves from Drizzle migration 0391 to script migration 0026, which db:push runs too. A db:push database (local dev, the CI push provision) never applied 0391, so every guarded row write failed there. The journal ends at 0390 again; the function body and its comments are unchanged. - A writer that finds the schema moved rebuilds the row from the caller's raw input instead of the value it coerced against its snapshot, so a "007" sent while a column changed from number to text is stored as "007", not "7". This covers insert, upsert, update, batch update and the import batch. - The refit re-checks the row's size, which a coercion to a wider type can grow past the limit after the pre-lock check passed. * fix(tables): re-check update batches under a moved schema, own-key reads - The background update runner re-reads a batch's rows inside the batch transaction and re-validates them merged with the re-derived patch when the schema moved since the page was checked, as the inline bulk update does; an unchanged schema adds no query. updatePageByIds takes an async per-batch hook with the transaction for this. - batchInsertRowsWithTx returns the definition it validated against, and batchInsertRows dispatches its insert triggers with it. - Row cells and patch keys are read as own properties (Object.hasOwn), so a legacy column keyed by a prototype name such as `constructor` is not seen in rows or patches that do not hold it. - The long schema-wait test asserts the write is waiting on the schema lock before the holder commits. * fix(tables): read the remaining row cells by own key in bulk validation, replace dedupe, and the upsert probe --- apps/sim/background/table-update.ts | 13 +- .../table/application/copilot-bulk-rows.ts | 5 +- .../lib/table/bulk-update-concurrency.test.ts | 9 +- apps/sim/lib/table/import-data.ts | 28 +- .../rows/bulk-update-patch-validation.test.ts | 5 +- apps/sim/lib/table/rows/live-schema.ts | 104 ++ apps/sim/lib/table/rows/ordering.ts | 40 +- .../lib/table/rows/row-writes.integration.ts | 1179 ++++++++++++++++- .../rows/secret-provenance.integration.ts | 7 +- apps/sim/lib/table/rows/service.ts | 643 +++++---- apps/sim/lib/table/rows/unique-locks.ts | 21 +- apps/sim/lib/table/sql.ts | 32 +- apps/sim/lib/table/tx.ts | 28 +- apps/sim/lib/table/update-row.test.ts | 79 +- apps/sim/lib/table/update-runner.test.ts | 22 +- apps/sim/lib/table/update-runner.ts | 136 +- apps/sim/lib/table/validation.ts | 71 +- ...rations-paused-billing-attribution.test.ts | 1 + ...016_backfill_search_vectors.integration.ts | 1 + .../0026_user_table_schema_for_write.ts | 66 + packages/db/script-migrations/index.ts | 3 + packages/db/scripts/push.ts | 1 + packages/testing/src/mocks/index.ts | 4 + .../src/mocks/table-rows-live-schema.mock.ts | 37 + 24 files changed, 2107 insertions(+), 428 deletions(-) create mode 100644 apps/sim/lib/table/rows/live-schema.ts create mode 100644 packages/db/script-migrations/0026_user_table_schema_for_write.ts create mode 100644 packages/testing/src/mocks/table-rows-live-schema.mock.ts diff --git a/apps/sim/background/table-update.ts b/apps/sim/background/table-update.ts index 412c241c535..e26fd7e8329 100644 --- a/apps/sim/background/table-update.ts +++ b/apps/sim/background/table-update.ts @@ -1,8 +1,9 @@ -import { task } from '@trigger.dev/sdk' +import { AbortTaskRunError, task } from '@trigger.dev/sdk' import { markTableUpdateFailed, runTableUpdate, type TableUpdatePayload, + UpdatePatchRejectedError, } from '@/lib/table/update-runner' /** @@ -19,7 +20,8 @@ export interface TableUpdateTaskPayload extends Omit { - await runTableUpdate({ ...payload, cutoff: new Date(payload.cutoff) }) + try { + await runTableUpdate({ ...payload, cutoff: new Date(payload.cutoff) }) + } catch (error) { + if (error instanceof UpdatePatchRejectedError) throw new AbortTaskRunError(error.message) + throw error + } }, onFailure: async ({ payload, error }) => { await markTableUpdateFailed(payload.tableId, payload.jobId, error) diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.ts b/apps/sim/lib/table/application/copilot-bulk-rows.ts index 0c29ed56b06..4720ea898d0 100644 --- a/apps/sim/lib/table/application/copilot-bulk-rows.ts +++ b/apps/sim/lib/table/application/copilot-bulk-rows.ts @@ -33,6 +33,7 @@ import { import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' +import { uniqueColumnsInPatch } from '@/lib/table/validation' const logger = createLogger('CopilotBulkRowsApplication') @@ -199,9 +200,7 @@ export const copilotUpdateRowsByFilter = defineAuthorizedTableUseCase({ validateLimit(input.limit) const idData = rowDataNameToId(input.data, buildIdByName(context.table.schema)) const filter = tablePredicateNamesToFilter(input.filter, context.table) - const patchTouchesUnique = context.table.schema.columns.some( - (column) => column.unique === true && (column.id ?? column.name) in idData - ) + const patchTouchesUnique = uniqueColumnsInPatch(context.table.schema, idData).length > 0 const inlineEligible = input.limit !== undefined && input.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE diff --git a/apps/sim/lib/table/bulk-update-concurrency.test.ts b/apps/sim/lib/table/bulk-update-concurrency.test.ts index fddffbdbf40..ecfa636c891 100644 --- a/apps/sim/lib/table/bulk-update-concurrency.test.ts +++ b/apps/sim/lib/table/bulk-update-concurrency.test.ts @@ -1,4 +1,5 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { tableRowsLiveSchemaMock } from '@sim/testing/mocks/table-rows-live-schema.mock' import { tableRowsSecretProvenanceMock, tableRowsSecretProvenanceMockFns, @@ -22,21 +23,25 @@ vi.mock('@/lib/table/rows/ordering', () => ({ vi.mock('@/lib/table/rows/secret-provenance', () => tableRowsSecretProvenanceMock) +vi.mock('@/lib/table/rows/live-schema', () => tableRowsLiveSchemaMock) + vi.mock('@/lib/table/sql', () => ({ buildFilterClause: vi.fn(() => sql`true`), buildPredicateClause: vi.fn(() => sql`true`), buildSortClause: vi.fn(() => sql`true`), escapeLikePattern: vi.fn((value: string) => value), - fieldPredicate: vi.fn(() => sql`true`), + uniqueValuePredicate: vi.fn(() => sql`true`), })) vi.mock('@/lib/table/trigger', () => tableTriggerMock) -vi.mock('@/lib/table/validation', () => ({ +vi.mock('@/lib/table/validation', async (importOriginal) => ({ + cellOf: (await importOriginal()).cellOf, validateRowSize: hoisted.validateRowSize, coerceRowToSchema: hoisted.coerceRowToSchema, coerceRowValues: vi.fn(), getUniqueColumns: vi.fn(() => []), + uniqueColumnsInPatch: vi.fn(() => []), checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), })) diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 48436a6144c..ee70fb34465 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -15,6 +15,7 @@ import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' import type { DbTransaction } from '@/lib/table/planner' +import { lockLiveTableSchema, refitRowToSchema, withLiveSchema } from '@/lib/table/rows/live-schema' import { acquireRowOrderLock, guardBatch, @@ -64,9 +65,10 @@ export interface BulkImportBatch { * `runWorkflowColumn`** (a 1M-row import must not dispatch a workflow run per row). * Append and replace imports run this against the live table, so other writers can * race it: the batch holds the table's unique columns exclusively while it checks and - * inserts. `row_count` is maintained set-based by the statement-level - * trigger. There is no surrounding transaction and no rollback: each batch commits on - * its own, so committed batches persist even if a later batch fails. + * inserts, and checks the rows against the schema it reads under the schema lock, which + * may have changed since the job resolved the table. `row_count` is maintained set-based + * by the statement-level trigger. There is no surrounding transaction and no rollback: + * each batch commits on its own, so committed batches persist even if a later batch fails. * * Throws on row-size/schema/unique violations or if the statement-level trigger rejects * the batch for crossing `max_rows`; the caller marks the import failed. @@ -82,6 +84,7 @@ export async function bulkInsertImportBatch( // the caller's snapshot too would reject a since-cleared lock. if (!revalidate) assertRowInsert(table) + const rawRows = data.rows.map((row) => ({ ...row })) for (let i = 0; i < data.rows.length; i++) { const sizeValidation = validateRowSize(data.rows[i]) if (!sizeValidation.valid) { @@ -119,14 +122,23 @@ export async function bulkInsertImportBatch( })) const inserted = await db.transaction(async (trx) => { - await guardBatch(trx, data.tableId, revalidate) - if (getUniqueColumns(table.schema).length > 0) { + const fresh = await guardBatch(trx, data.tableId, revalidate) + const live = fresh ? withLiveSchema(table, fresh.schema) : await lockLiveTableSchema(trx, table) + if (live !== table) { + for (let i = 0; i < data.rows.length; i++) { + const refit = refitRowToSchema(data.rows[i], rawRows[i], table.schema, live.schema, 'null') + if (!refit.valid) { + throw new OrchestrationError('validation', `Row ${i + 1}: ${refit.errors.join(', ')}`) + } + } + } + if (getUniqueColumns(live.schema).length > 0) { // The whole-table unique lock, not per-value: a batch is far more values than the value-lock cap. - await lockUniqueColumns(trx, table) + await lockUniqueColumns(trx, live) const uniqueResult = await checkBatchUniqueConstraintsDb( data.tableId, data.rows, - table.schema, + live.schema, trx ) if (!uniqueResult.valid) { @@ -314,7 +326,7 @@ export async function importAppendRows( generateId().slice(0, 8), { uniqueColumnsLocked: true } ) - inserted.push(...batchInserted) + inserted.push(...batchInserted.rows) } return { inserted, table: working } }) diff --git a/apps/sim/lib/table/rows/bulk-update-patch-validation.test.ts b/apps/sim/lib/table/rows/bulk-update-patch-validation.test.ts index aa6bf13b21b..61f18fa16a3 100644 --- a/apps/sim/lib/table/rows/bulk-update-patch-validation.test.ts +++ b/apps/sim/lib/table/rows/bulk-update-patch-validation.test.ts @@ -4,6 +4,7 @@ * filter matches rows or none. */ import { resetDbChainMock } from '@sim/testing' +import { tableRowsLiveSchemaMock } from '@sim/testing/mocks/table-rows-live-schema.mock' import { tableRowsSecretProvenanceMock, tableRowsSecretProvenanceMockFns, @@ -24,12 +25,14 @@ vi.mock('@/lib/table/rows/ordering', () => ({ vi.mock('@/lib/table/rows/secret-provenance', () => tableRowsSecretProvenanceMock) +vi.mock('@/lib/table/rows/live-schema', () => tableRowsLiveSchemaMock) + vi.mock('@/lib/table/sql', () => ({ buildFilterClause: vi.fn(() => sql`true`), buildPredicateClause: vi.fn(() => sql`true`), buildSortClause: vi.fn(() => sql`true`), escapeLikePattern: vi.fn((value: string) => value), - fieldPredicate: vi.fn(() => sql`true`), + uniqueValuePredicate: vi.fn(() => sql`true`), })) vi.mock('@/lib/table/trigger', () => tableTriggerMock) diff --git a/apps/sim/lib/table/rows/live-schema.ts b/apps/sim/lib/table/rows/live-schema.ts new file mode 100644 index 00000000000..d27f7fcc183 --- /dev/null +++ b/apps/sim/lib/table/rows/live-schema.ts @@ -0,0 +1,104 @@ +/** + * Keeps row writes on the table's live schema rather than the definition their caller resolved + * before the write transaction opened. + * + * Schema changes hold the table's schema lock exclusively (`withLockedTable`) while they check the + * stored rows against the new schema: a column made unique is scanned for duplicates, one made + * required for empty cells. A write validated against an older definition skips the check the new + * schema adds, and if it commits after that scan, the column ends up holding the duplicate or the + * empty cell. Each write transaction therefore takes the schema lock shared and reads the schema + * under it, then validates against that: a schema change waits for writes already in flight, and a + * write that waited sees the change. + */ + +import { compareStrings } from '@sim/utils/string' +import { sql } from 'drizzle-orm' +import { canonicalJson } from '@/lib/api/cursor-binding' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getColumnId } from '@/lib/table/column-keys' +import type { DbTransaction } from '@/lib/table/planner' +import { type TableTxTimeouts, tableTxTimeoutSettings } from '@/lib/table/tx' +import type { RowData, TableDefinition, TableSchema, ValidationResult } from '@/lib/table/types' +import { + coerceRowToSchema, + type PatchedKeys, + type UncoercibleValuePolicy, + validateRowSize, +} from '@/lib/table/validation' + +/** Compares schemas by content, whatever order their columns are listed in. */ +function schemaFingerprint(schema: TableSchema): string { + const columns = [...schema.columns].sort((a, b) => compareStrings(getColumnId(a), getColumnId(b))) + return canonicalJson({ ...schema, columns }) +} + +/** `table` itself when `schema` matches its schema, else a copy carrying `schema`. */ +export function withLiveSchema(table: TableDefinition, schema: TableSchema): TableDefinition { + return schemaFingerprint(schema) === schemaFingerprint(table.schema) + ? table + : { ...table, schema } +} + +/** + * Takes the table's schema lock shared and reads its live schema, and returns the definition to + * validate and write against: `table` itself when its schema is still current, else a copy carrying + * the live schema. Call it first in the transaction, before its other locks, passing the + * transaction's `timeouts` (see `setTableTxTimeouts`) in place of a separate timeouts statement. + * + * One statement: `user_table_schema_for_write` (script migration 0026) takes the lock and then + * reads the schema. It is VOLATILE, so under READ COMMITTED its read takes a fresh snapshot and sees + * a schema change that committed while the lock waited. The timeouts are applied in a subquery the call + * reads from, first. The lock waits as long as the transaction's `statement_timeout` allows, not + * its shorter `lock_timeout`, which the function leaves as it found it for the locks that follow. + */ +export async function lockLiveTableSchema( + trx: DbTransaction, + table: TableDefinition, + timeouts?: TableTxTimeouts +): Promise { + const read = sql`SELECT user_table_schema_for_write(${table.id}) AS schema` + const [live] = await trx.execute<{ schema: TableSchema | null }>( + timeouts ? sql`${read} FROM (SELECT ${tableTxTimeoutSettings(timeouts)}) AS settings` : read + ) + if (!live?.schema) throw new OrchestrationError('not_found', 'Table not found') + return withLiveSchema(table, live.schema) +} + +/** + * Removes, in place, the cells of columns `snapshot` defines and `live` no longer does. A column + * delete reclaims its cells in the background, so a cell written after that pass would stay behind. + */ +export function dropDeletedColumns( + rows: readonly RowData[], + snapshot: TableSchema, + live: TableSchema +): void { + const liveIds = new Set(live.columns.map(getColumnId)) + const deleted = snapshot.columns.map(getColumnId).filter((id) => !liveIds.has(id)) + if (deleted.length === 0) return + for (const row of rows) { + for (const id of deleted) delete row[id] + } +} + +/** + * Rebuilds `row`, in place, for `live` once the schema has moved since `snapshot`: from `raw`, the + * row as the caller wrote it before coercing it against `snapshot`, so a value that schema would + * have reshaped (`"007"` read as a number) reaches the live column as it was sent. Then drops the + * cells of deleted columns, coerces and validates against `live` exactly as the write first did, + * and re-checks the row's size, which a coercion to a wider type can grow. + */ +export function refitRowToSchema( + row: RowData, + raw: RowData, + snapshot: TableSchema, + live: TableSchema, + policy?: UncoercibleValuePolicy, + patchedKeys?: PatchedKeys +): ValidationResult { + for (const key of Object.keys(row)) delete row[key] + Object.assign(row, raw) + dropDeletedColumns([row], snapshot, live) + const result = coerceRowToSchema(row, live, policy, patchedKeys) + return result.valid ? validateRowSize(row) : result +} diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 53077b92602..0919efa7582 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -333,10 +333,12 @@ export async function insertOrderedRow(params: { /** Proof the caller asserted the insert lock (see `mutation-locks.ts`). */ proof: MutationProof<'insert'> /** - * Runs first in the transaction, before the row-order lock: the caller's unique-value locks and - * unique check (see `unique-locks.ts`), so the check sees any concurrent insert of the same value. + * Opens the transaction in place of the default timeouts, before the row-order lock: the + * caller's schema guard (see `live-schema.ts`), which applies the timeouts, then its unique-value + * locks and unique check (see `unique-locks.ts`), so the check sees any concurrent insert of the + * same value. */ - assertUnique?: (trx: DbTransaction) => Promise + validate?: (trx: DbTransaction) => Promise }): Promise<{ id: string data: RowData @@ -358,8 +360,8 @@ export async function insertOrderedRow(params: { secretProvenance, } = params const [row] = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx) - await params.assertUnique?.(trx) + if (params.validate) await params.validate(trx) + else await setTableTxTimeouts(trx) await acquireRowOrderLock(trx, tableId) // Resolve the authoritative order key from neighbor ids when given, else from the requested @@ -670,17 +672,29 @@ export async function deletePageByIds( return deleted } +/** The patch one update batch writes, or `null` when it writes nothing. */ +export interface PagePatch { + patchJson: string + secretProvenance: TableRowSecretProvenanceWrite +} + /** * Applies a JSONB-merge patch (`data || patchJson`) to a page of row ids, committed in * UPDATE_BATCH_SIZE chunks (each its own transaction, 60s timeout) so a large background update - * makes incremental, resumable progress. Returns the number of rows updated. + * makes incremental, resumable progress. Each batch takes its patch from `prepare`, called inside + * the batch's transaction with the definition `revalidate` read there and the batch's row ids, so a + * caller can derive it, and check the rows it merges into, against the live schema. Returns the + * number of rows updated. */ export async function updatePageByIds( tableId: string, workspaceId: string, rowIds: string[], - patchJson: string, - secretProvenance: TableRowSecretProvenanceWrite, + prepare: ( + trx: DbTransaction, + table: TableDefinition | undefined, + batch: string[] + ) => Promise, /** Proof the caller asserted the update lock (see `mutation-locks.ts`). */ _proof: MutationProof<'update'>, /** Re-asserts the lock inside each batch transaction. See {@link guardBatch}. */ @@ -692,15 +706,19 @@ export async function updatePageByIds( const batch = rowIds.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) const rows = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await guardBatch(trx, tableId, revalidate) + const patch = await prepare(trx, await guardBatch(trx, tableId, revalidate), batch) + if (!patch) return [] return mutateTableRowsWithSecretProvenance(trx, { - rows: batch.map((rowId) => ({ rowId, provenance: secretProvenance })), + rows: batch.map((rowId) => ({ rowId, provenance: patch.secretProvenance })), rowState: 'existing', mode: 'merge', mutate: async () => { const rows = await trx .update(userTableRows) - .set({ data: sql`${userTableRows.data} || ${patchJson}::jsonb`, updatedAt: now }) + .set({ + data: sql`${userTableRows.data} || ${patch.patchJson}::jsonb`, + updatedAt: now, + }) .where( and( eq(userTableRows.tableId, tableId), diff --git a/apps/sim/lib/table/rows/row-writes.integration.ts b/apps/sim/lib/table/rows/row-writes.integration.ts index 74a9a0584b7..e5c3a5de422 100644 --- a/apps/sim/lib/table/rows/row-writes.integration.ts +++ b/apps/sim/lib/table/rows/row-writes.integration.ts @@ -9,7 +9,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { tableBillingMock, tableBillingMockFns } from '@sim/testing/mocks/table-billing.mock' -import { tableTriggerMock } from '@sim/testing/mocks/table-trigger.mock' +import { tableTriggerMock, tableTriggerMockFns } from '@sim/testing/mocks/table-trigger.mock' import { tableWorkflowColumnsMock } from '@sim/testing/mocks/table-workflow-columns.mock' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -22,14 +22,31 @@ vi.mock('@/lib/table/trigger', () => tableTriggerMock) vi.mock('@/lib/table/workflow-columns', () => tableWorkflowColumnsMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +import { + deleteColumn, + updateColumnConstraints, + updateColumnOptions, +} from '@/lib/table/columns/service' +import { getMaxRowSizeBytes, TABLE_LIMITS } from '@/lib/table/constants' import { bulkInsertImportBatch, importReplaceRows } from '@/lib/table/import-data' +import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import type { DbTransaction } from '@/lib/table/planner' +import { lockLiveTableSchema } from '@/lib/table/rows/live-schema' import { acquireRowOrderLock } from '@/lib/table/rows/ordering' -import { batchInsertRows, batchUpdateRows, insertRow, updateRow } from '@/lib/table/rows/service' +import { + batchInsertRows, + batchUpdateRows, + insertRow, + replaceTableRows, + updateRow, + updateRowsByFilter, + upsertRow, +} from '@/lib/table/rows/service' import { lockUniqueColumns, lockUniqueValues } from '@/lib/table/rows/unique-locks' import { getTableById } from '@/lib/table/service' import { getOrCreateTableSnapshot } from '@/lib/table/snapshot-cache' -import type { ColumnDefinition, RowData, TableDefinition } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue, RowData, TableDefinition } from '@/lib/table/types' +import { runTableUpdate, UpdatePatchRejectedError } from '@/lib/table/update-runner' const url = readTestDatabaseUrl() if (process.env.DATABASE_URL !== url) { @@ -80,6 +97,27 @@ async function rowsVersion(tableId: string): Promise { const textColumns = (...ids: string[]): ColumnDefinition[] => ids.map((id) => ({ id, name: id, type: 'string' })) +/** Sessions waiting on the table's schema lock, matched by the key's hash as `pg_locks` shows it. */ +async function schemaLockWaiters(tableId: string): Promise { + const [{ waiting }] = await control<{ waiting: number }[]>` + WITH lock AS (SELECT hashtextextended(${`user_table_schema:${tableId}`}, 0) AS key) + SELECT count(*)::int AS waiting FROM pg_locks l CROSS JOIN lock + WHERE l.locktype = 'advisory' AND NOT l.granted + AND l.classid = ((lock.key >> 32) & 4294967295)::oid + AND l.objid = (lock.key & 4294967295)::oid` + return waiting +} + +/** Polls until exactly `expected` sessions wait on the table's schema lock, and asserts it. */ +async function untilSchemaLockWaiters(tableId: string, expected: number) { + let waiting = 0 + for (let attempt = 0; attempt < 400 && waiting !== expected; attempt++) { + await sleep(5) + waiting = await schemaLockWaiters(tableId) + } + expect(waiting).toBe(expected) +} + describe('table row writes against real PostgreSQL', () => { beforeAll(async () => { await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) @@ -121,26 +159,39 @@ describe('table row writes against real PostgreSQL', () => { /** * Polls until exactly `expected.onOrderLock` sessions wait on the table's row-order lock and - * `expected.onValueLock` wait on a unique-value lock, and returns the last count seen. + * `expected.onValueLock` wait on one of its unique locks, and returns the last count seen. Both + * counts are scoped to the table, so concurrent suites cannot skew them: a unique-lock waiter + * waits on the table's unique lock itself, or on a value lock while holding the table's unique + * lock shared. */ async function waitForLockWaiters(tableId: string, expected: LockWaiters) { const orderLockKey = `user_table_rows_pos:${tableId}` + const uniqueLockKey = `user_table_unique:${tableId}` let waiting: LockWaiters = { onOrderLock: 0, onValueLock: 0 } for (let attempt = 0; attempt < 400; attempt++) { await sleep(5) ;[waiting] = await control` - WITH lock AS (SELECT hashtextextended(${orderLockKey}, 0) AS key) + WITH lock AS ( + SELECT hashtextextended(${orderLockKey}, 0) AS order_key, + hashtextextended(${uniqueLockKey}, 0) AS unique_key + ), advisory AS ( + SELECT l.pid, l.granted, + l.classid = ((lock.order_key >> 32) & 4294967295)::oid + AND l.objid = (lock.order_key & 4294967295)::oid AS is_order, + l.classid = ((lock.unique_key >> 32) & 4294967295)::oid + AND l.objid = (lock.unique_key & 4294967295)::oid AS is_unique + FROM pg_locks l CROSS JOIN lock + WHERE l.locktype = 'advisory' + AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database()) + ) SELECT + count(*) FILTER (WHERE NOT granted AND is_order)::int AS "onOrderLock", count(*) FILTER ( - WHERE l.classid = ((lock.key >> 32) & 4294967295)::oid - AND l.objid = (lock.key & 4294967295)::oid - )::int AS "onOrderLock", - count(*) FILTER (WHERE a.query LIKE '%user_table_unique_value%')::int AS "onValueLock" - FROM pg_locks l - JOIN pg_stat_activity a ON a.pid = l.pid - CROSS JOIN lock - WHERE l.locktype = 'advisory' AND NOT l.granted - AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database())` + WHERE NOT granted AND NOT is_order AND ( + is_unique OR pid IN (SELECT pid FROM advisory WHERE granted AND is_unique) + ) + )::int AS "onValueLock" + FROM advisory` if ( waiting.onOrderLock === expected.onOrderLock && waiting.onValueLock === expected.onValueLock @@ -177,10 +228,7 @@ describe('table row writes against real PostgreSQL', () => { } } - async function storedCount( - tableId: string, - match: Record> - ) { + async function storedCount(tableId: string, match: Record) { const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count FROM user_table_rows WHERE table_id = ${tableId} AND data @> ${control.json(match)}` return count @@ -483,6 +531,1101 @@ describe('table row writes against real PostgreSQL', () => { ).rejects.toThrow(/must be unique/) expect(await storedCount(table.id, { meta: { x: 1, y: 2 } })).toBe(0) }) + + describe('json columns compare whole values', () => { + const jsonColumns: ColumnDefinition[] = [ + { id: 'meta', name: 'meta', type: 'json', unique: true }, + ] + + const insertMeta = (table: TableDefinition, meta: JsonValue) => + insertRow( + { + tableId: table.id, + workspaceId, + data: { meta }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'json-unique' + ) + + async function metaValues(tableId: string): Promise { + const rows = await control<{ meta: JsonValue }[]>`SELECT data->'meta' AS meta + FROM user_table_rows WHERE table_id = ${tableId} ORDER BY created_at, id` + return rows.map((row) => row.meta) + } + + it('accepts an object or array that a stored value merely contains', async () => { + const table = await createTable(jsonColumns) + await seedRows(table.id, [ + { id: `${table.id}-a`, data: { meta: { a: 1, b: 2 } }, orderKey: 'a0' }, + { id: `${table.id}-b`, data: { meta: [1, 2, 3] }, orderKey: 'a1' }, + ]) + + await expect(insertMeta(table, { a: 1 })).resolves.toBeDefined() + await expect(insertMeta(table, [1])).resolves.toBeDefined() + expect(await storedCount(table.id, { meta: { a: 1 } })).toBe(2) + }) + + it('rejects the same object in a different key order', async () => { + const table = await createTable(jsonColumns) + await seedRows(table.id, [ + { id: `${table.id}-a`, data: { meta: { a: 1, b: 2 } }, orderKey: 'a0' }, + ]) + const batch = (rows: RowData[]) => ({ + tableId: table.id, + workspaceId, + rows, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }) + + await expect(insertMeta(table, { b: 2, a: 1 })).rejects.toThrow(/must be unique/) + await expect( + batchInsertRows(batch([{ meta: { b: 2, a: 1 } }]), table, 'json-unique') + ).rejects.toThrow(/must be unique/) + await expect( + replaceTableRows( + batch([{ meta: { b: 2, a: 1 } }, { meta: { a: 1, b: 2 } }]), + table, + 'json-unique' + ) + ).rejects.toThrow(/must be unique/) + expect(await metaValues(table.id)).toEqual([{ a: 1, b: 2 }]) + }) + + it('upserts on a JSON conflict target without overwriting a row that contains it', async () => { + const table = await createTable(jsonColumns) + await seedRows(table.id, [ + { id: `${table.id}-a`, data: { meta: { a: 1, b: 2 } }, orderKey: 'a0' }, + ]) + + const result = await upsertRow( + { + tableId: table.id, + workspaceId, + data: { meta: { a: 1 } }, + conflictTarget: 'meta', + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'json-unique' + ) + + expect(result.operation).toBe('insert') + expect(await metaValues(table.id)).toEqual([{ a: 1, b: 2 }, { a: 1 }]) + }) + + it('accepts a batch update of values contained in other rows', async () => { + const table = await createTable(jsonColumns) + await seedRows(table.id, [ + { id: `${table.id}-a`, data: { meta: { n: 1 } }, orderKey: 'a0' }, + { id: `${table.id}-b`, data: { meta: { n: 2 } }, orderKey: 'a1' }, + { id: `${table.id}-c`, data: { meta: { a: 1, b: 2, c: 3 } }, orderKey: 'a2' }, + ]) + + await batchUpdateRows( + { + tableId: table.id, + workspaceId, + updates: [ + { rowId: `${table.id}-a`, data: { meta: { a: 1 } } }, + { rowId: `${table.id}-b`, data: { meta: { a: 1, b: 2 } } }, + ], + capabilityGovernedUserId: null, + }, + table, + 'json-unique' + ) + + expect(await metaValues(table.id)).toEqual([{ a: 1 }, { a: 1, b: 2 }, { a: 1, b: 2, c: 3 }]) + }) + + it('locks JSON values one by one, equating objects whose keys differ only in order', async () => { + const table = await createTable(jsonColumns) + + const different = await raceUnderHeldOrderLock( + table.id, + [() => insertMeta(table, { a: 1 }), () => insertMeta(table, { a: 1, b: 2 })], + { onOrderLock: 2, onValueLock: 0 } + ) + expect(fulfilled(different)).toBe(2) + + const reordered = await raceUnderHeldOrderLock( + table.id, + [ + () => insertMeta(table, { x: 1, y: [1, 2] }), + () => insertMeta(table, { y: [1, 2], x: 1 }), + ], + { onOrderLock: 1, onValueLock: 1 } + ) + expect(fulfilled(reordered)).toBe(1) + expect(await storedCount(table.id, { meta: { x: 1 } })).toBe(1) + }) + }) + }) + + describe('writers validate against the live schema, not their snapshot', () => { + const columns: ColumnDefinition[] = [ + { id: 'key', name: 'key', type: 'string', unique: true }, + { id: 'email', name: 'email', type: 'string' }, + { id: 'note', name: 'note', type: 'string' }, + ] + + /** A table whose `k1`/`k2` rows hold distinct emails and filled notes, and its snapshot. */ + async function seededTable() { + const table = await createTable(columns) + await seedRows(table.id, [ + { + id: `${table.id}-1`, + data: { key: 'k1', email: 'dup@example.test', note: 'n' }, + orderKey: 'a0', + }, + { + id: `${table.id}-2`, + data: { key: 'k2', email: 'b@example.test', note: 'n' }, + orderKey: 'a1', + }, + ]) + return table + } + + type StaleWrite = (table: TableDefinition, row: RowData) => Promise + + const rows = (table: TableDefinition, data: RowData[]) => ({ + tableId: table.id, + workspaceId, + rows: data, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }) + + /** + * One entry per row writer. Each writes `row`, a new row (`key: 'k3'`) or a patch to row `k2`, + * holding the snapshot it was handed. + */ + const writers: Array<[string, StaleWrite]> = [ + [ + 'insertRow', + (table, row) => + insertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k3', ...row }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ), + ], + [ + 'batchInsertRows', + (table, row) => + batchInsertRows(rows(table, [{ key: 'k3', ...row }]), table, 'stale-schema'), + ], + [ + 'bulkInsertImportBatch', + (table, row) => + bulkInsertImportBatch( + { tableId: table.id, workspaceId, rows: [{ key: 'k3', ...row }], startPosition: 2 }, + table, + 'stale-schema' + ), + ], + [ + 'replaceTableRows', + (table, row) => + replaceTableRows( + rows(table, [ + { key: 'k1', email: 'dup@example.test', note: 'n' }, + { key: 'k3', ...row }, + ]), + table, + 'stale-schema' + ), + ], + [ + 'upsertRow', + (table, row) => + upsertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k3', ...row }, + conflictTarget: 'key', + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ), + ], + [ + 'updateRow', + (table, row) => + updateRow( + { + tableId: table.id, + rowId: `${table.id}-2`, + workspaceId, + data: row, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ), + ], + [ + 'batchUpdateRows', + (table, row) => + batchUpdateRows( + { + tableId: table.id, + workspaceId, + updates: [{ rowId: `${table.id}-2`, data: row }], + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ), + ], + [ + 'updateRowsByFilter', + (table, row) => + updateRowsByFilter( + table, + { + filter: { key: 'k2' }, + data: row, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + 'stale-schema' + ), + ], + [ + 'updateRowsByFilter with a limit', + (table, row) => + updateRowsByFilter( + table, + { + filter: { key: 'k2' }, + data: row, + limit: 1, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + 'stale-schema' + ), + ], + ] + + async function countRows( + tableId: string, + where: 'email-dup' | 'email-c' | 'note-empty' | 'note-restored' + ) { + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${tableId} AND ${ + where === 'email-dup' + ? control`data->>'email' = 'dup@example.test'` + : where === 'email-c' + ? control`data->>'email' = 'c@example.test'` + : where === 'note-empty' + ? control`data->>'note' IS NULL` + : control`data->>'note' = 'restored'` + }` + return count + } + + it.each(writers)( + '%s rejects a duplicate in a column made unique since its snapshot', + async (_, write) => { + const table = await seededTable() + await updateColumnConstraints( + { tableId: table.id, columnName: 'email', unique: true }, + 'stale-schema' + ) + + await expect(write(table, { email: 'dup@example.test', note: 'n' })).rejects.toThrow( + /must be unique|would violate uniqueness/ + ) + expect(await countRows(table.id, 'email-dup')).toBe(1) + } + ) + + it.each(writers)( + '%s rejects an empty cell in a column made required since its snapshot', + async (_, write) => { + const table = await seededTable() + await updateColumnConstraints( + { tableId: table.id, columnName: 'note', required: true }, + 'stale-schema' + ) + + await expect(write(table, { email: 'c@example.test', note: null })).rejects.toThrow( + /Missing required field/ + ) + expect(await countRows(table.id, 'note-empty')).toBe(0) + } + ) + + it.each(writers)('%s drops a cell of a column deleted since its snapshot', async (_, write) => { + const table = await seededTable() + await deleteColumn({ tableId: table.id, columnName: 'note' }, 'stale-schema') + + await write(table, { email: 'c@example.test', note: 'restored' }) + expect(await countRows(table.id, 'note-restored')).toBe(0) + }) + + it.each([ + [ + 'updateRow', + (table: TableDefinition, rowId: string) => + updateRow( + { + tableId: table.id, + rowId, + workspaceId, + data: { out: null }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ), + ], + [ + 'batchUpdateRows', + (table: TableDefinition, rowId: string) => + batchUpdateRows( + { + tableId: table.id, + workspaceId, + updates: [{ rowId, data: { out: null } }], + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ), + ], + ])('%s clears the run of a workflow output added since its snapshot', async (_, write) => { + const table = await createTable([ + { id: 'name', name: 'name', type: 'string' }, + { id: 'out', name: 'out', type: 'string', workflowGroupId: 'group-1' }, + ]) + await control`UPDATE user_table_definitions SET schema = jsonb_set(schema, '{workflowGroups}', + ${control.json([ + { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'b', path: 'p', columnName: 'out' }], + }, + ])}) WHERE id = ${table.id}` + const stale: TableDefinition = { + ...table, + schema: { columns: textColumns('name', 'out') }, + } + const rowId = `${table.id}-a` + await seedRows(table.id, [{ id: rowId, data: { name: 'a', out: 'done' }, orderKey: 'a0' }]) + await control`INSERT INTO table_row_executions (table_id, row_id, group_id, status, workflow_id) + VALUES (${table.id}, ${rowId}, 'group-1', 'completed', 'workflow-1')` + + await write(stale, rowId) + + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM table_row_executions WHERE row_id = ${rowId}` + expect(count).toBe(0) + }) + + /** A table whose `code` column is text, and a snapshot from when it was a number. */ + async function retypedTable(): Promise<{ table: TableDefinition; stale: TableDefinition }> { + const table = await createTable([ + { id: 'key', name: 'key', type: 'string', unique: true }, + { id: 'code', name: 'code', type: 'string' }, + { id: 'filler', name: 'filler', type: 'string' }, + ]) + await seedRows(table.id, [ + { id: `${table.id}-2`, data: { key: 'k2', filler: '' }, orderKey: 'a0' }, + ]) + const stale: TableDefinition = { + ...table, + schema: { + columns: table.schema.columns.map((column) => + column.id === 'code' ? { ...column, type: 'number' as const } : column + ), + }, + } + return { table, stale } + } + + /** Writes `row` (a new row, or a patch to row `k2`) through each writer holding `stale`. */ + const retypeWriters: Array< + [string, (table: TableDefinition, row: RowData) => Promise] + > = [ + [ + 'insertRow', + (table, row) => + insertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k3', ...row }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'retype' + ), + ], + [ + 'upsertRow', + (table, row) => + upsertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k3', ...row }, + conflictTarget: 'key', + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'retype' + ), + ], + [ + 'bulkInsertImportBatch', + (table, row) => + bulkInsertImportBatch( + { tableId: table.id, workspaceId, rows: [{ key: 'k3', ...row }], startPosition: 1 }, + table, + 'retype' + ), + ], + [ + 'updateRow', + (table, row) => + updateRow( + { + tableId: table.id, + rowId: `${table.id}-2`, + workspaceId, + data: row, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'retype' + ), + ], + [ + 'batchUpdateRows', + (table, row) => + batchUpdateRows( + { + tableId: table.id, + workspaceId, + updates: [{ rowId: `${table.id}-2`, data: row }], + capabilityGovernedUserId: null, + }, + table, + 'retype' + ), + ], + ] + + it.each(retypeWriters)( + '%s stores the value it was sent in a column retyped since its snapshot', + async (_, write) => { + const { table, stale } = await retypedTable() + + await write(stale, { code: '007' }) + + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} AND data->'code' = '"007"'` + expect(count).toBe(1) + } + ) + + it.each(retypeWriters)( + '%s refuses a row a retype since its snapshot grows past the size limit', + async (writer, write) => { + const { table, stale } = await retypedTable() + // Exactly at the limit with `code` a number; the live text column stores it with quotes. + const inserting = writer !== 'updateRow' && writer !== 'batchUpdateRows' + const shape = (filler: string) => + inserting ? { key: 'k3', code: 7, filler } : { key: 'k2', filler, code: 7 } + const filler = 'x'.repeat( + getMaxRowSizeBytes() - Buffer.byteLength(JSON.stringify(shape(''))) + ) + + await expect(write(stale, { code: 7, filler })).rejects.toThrow(/Row size exceeds limit/) + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} AND data->>'code' = '7'` + expect(count).toBe(0) + } + ) + + it('fires the insert trigger of a batch insert with the column names of the live schema', async () => { + const table = await seededTable() + const stale: TableDefinition = { + ...table, + schema: { + columns: table.schema.columns.map((column) => + column.id === 'note' ? { ...column, name: 'old_note' } : column + ), + }, + } + tableTriggerMockFns.mockFireTableTrigger.mockClear() + + await batchInsertRows( + { + tableId: table.id, + workspaceId, + rows: [{ key: 'k3', note: 'n' }], + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + stale, + 'stale-schema' + ) + + const [trigger] = tableTriggerMockFns.mockFireTableTrigger.mock.calls + const schema = trigger?.[6] as { columns: ColumnDefinition[] } + expect(schema.columns.map((column) => column.name)).toContain('note') + }) + + it('does not count a legacy unique column named after an object prototype key as patched', async () => { + const table = await createTable([ + { name: 'constructor', type: 'string', unique: true }, + { id: 'note', name: 'note', type: 'string' }, + ]) + await seedRows(table.id, [ + { id: `${table.id}-a`, data: { constructor: 'a', note: 'n' }, orderKey: 'a0' }, + { id: `${table.id}-b`, data: { constructor: 'b', note: 'n' }, orderKey: 'a1' }, + ]) + + const result = await updateRowsByFilter( + table, + { + filter: { note: 'n' }, + data: { note: 'patched' }, + // The limited path: the paged one skips rows created after its JS-clock cutoff. + limit: 10, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + 'prototype-key' + ) + + expect(result.affectedCount).toBe(2) + }) + + it('does not count a required legacy column named after a prototype key as supplied', async () => { + const table = await createTable([ + { name: 'constructor', type: 'string', required: true }, + { id: 'note', name: 'note', type: 'string' }, + ]) + await seedRows(table.id, [ + { id: `${table.id}-a`, data: { constructor: 'a', note: 'n' }, orderKey: 'a0' }, + ]) + + const result = await updateRowsByFilter( + table, + { + filter: { note: 'n' }, + data: { note: 'patched' }, + limit: 10, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + 'prototype-key' + ) + + expect(result.affectedCount).toBe(1) + }) + + it('replaces rows that leave a unique legacy column named after a prototype key empty', async () => { + const table = await createTable([ + { name: 'constructor', type: 'string', unique: true }, + { id: 'note', name: 'note', type: 'string' }, + ]) + + await replaceTableRows( + { + tableId: table.id, + workspaceId, + rows: [{ note: 'a' }, { note: 'b' }], + secretProvenance: undefined, + }, + table, + 'prototype-key' + ) + + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id}` + expect(count).toBe(2) + }) + + it('refuses an upsert missing its conflict target named after a prototype key', async () => { + const table = await createTable([ + { name: 'constructor', type: 'string', unique: true }, + { id: 'note', name: 'note', type: 'string' }, + ]) + + await expect( + upsertRow( + { + tableId: table.id, + workspaceId, + data: { note: 'a' }, + conflictTarget: 'constructor', + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'prototype-key' + ) + ).rejects.toThrow(/requires a value for the conflict target/) + }) + + it('refuses a bulk update writing one value to rows of a column made unique since its snapshot', async () => { + const table = await seededTable() + await updateColumnConstraints( + { tableId: table.id, columnName: 'email', unique: true }, + 'stale-schema' + ) + + await expect( + updateRowsByFilter( + table, + { + filter: { note: 'n' }, + data: { email: 'same@example.test' }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + 'stale-schema' + ) + ).rejects.toThrow(/would violate uniqueness/) + expect(await countRows(table.id, 'email-dup')).toBe(1) + }) + + it('refuses an upsert on a column no longer unique since its snapshot', async () => { + const table = await seededTable() + await updateColumnConstraints( + { tableId: table.id, columnName: 'key', unique: false }, + 'stale-schema' + ) + + await expect( + upsertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k2', email: 'c@example.test', note: 'n' }, + conflictTarget: 'key', + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ) + ).rejects.toThrow(/requires at least one unique column/) + const [row] = await control<{ email: string }[]>`SELECT data->>'email' AS email + FROM user_table_rows WHERE id = ${`${table.id}-2`}` + expect(row.email).toBe('b@example.test') + }) + + /** + * Holds the table's schema lock exclusively in a transaction that marks `email` unique, starts + * `write`, and commits only once `write` is seen waiting on the lock, then settles as `write` does. + */ + async function writeDuringUniqueChange( + table: TableDefinition, + write: () => Promise + ): Promise { + const schemaLockKey = `user_table_schema:${table.id}` + const holder = await control.reserve() + try { + await holder`BEGIN` + await holder`SELECT pg_advisory_xact_lock(hashtextextended(${schemaLockKey}, 0))` + await holder`UPDATE user_table_definitions + SET schema = jsonb_set(schema, '{columns,1,unique}', 'true') + WHERE id = ${table.id}` + const pending = write() + const settled = pending.then( + () => 'fulfilled', + () => 'rejected' + ) + + await untilSchemaLockWaiters(table.id, 1) + expect(await Promise.race([settled, sleep(50).then(() => 'pending')])).toBe('pending') + + await holder`COMMIT` + return await pending + } finally { + await holder`ROLLBACK`.catch(() => {}) + holder.release() + } + } + + /** + * Holds the table's schema lock exclusively, starts `write`, and commits once `write` is seen + * waiting on it and `holdMs` more has passed. Settles as `write` does. + */ + async function writeBehindSchemaLock( + table: TableDefinition, + holdMs: number, + write: () => Promise + ): Promise { + const holder = await control.reserve() + try { + await holder`BEGIN` + await holder`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${table.id}`}, 0))` + const pending = write() + pending.catch(() => {}) + await untilSchemaLockWaiters(table.id, 1) + await sleep(holdMs) + await holder`COMMIT` + return await pending + } finally { + await holder`ROLLBACK`.catch(() => {}) + holder.release() + } + } + + it.each([ + [ + 'insertRow, which sets a 3 s lock timeout', + (table: TableDefinition) => + insertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k3', email: 'c@example.test' }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'long-schema-change' + ), + ], + [ + 'updateRow, which sets none', + (table: TableDefinition) => + updateRow( + { + tableId: table.id, + rowId: `${table.id}-2`, + workspaceId, + data: { email: 'c@example.test' }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'long-schema-change' + ), + ], + ])( + '%s waits out a schema change held past the lock timeout', + async (_, write) => { + const table = await seededTable() + + await expect(writeBehindSchemaLock(table, 3_500, () => write(table))).resolves.toBeDefined() + expect(await countRows(table.id, 'email-c')).toBe(1) + }, + 15_000 + ) + + it('bounds the schema-lock wait by the statement timeout', async () => { + const table = await seededTable() + + await expect( + writeBehindSchemaLock(table, 1_500, () => + db.transaction((trx) => lockLiveTableSchema(trx, table, { statementMs: 300 })) + ) + ).rejects.toMatchObject({ cause: { code: '55P03' } }) + }) + + it('leaves the lock timeout the transaction set for the locks after the guard', async () => { + const table = await seededTable() + + const lockTimeout = await db.transaction(async (trx) => { + await lockLiveTableSchema(trx, table, {}) + const [{ setting }] = await trx.execute<{ setting: string }>( + sql`SELECT current_setting('lock_timeout') AS setting` + ) + return setting + }) + expect(lockTimeout).toBe('3s') + }) + + it('waits for a schema change in flight and then writes against it', async () => { + const table = await seededTable() + + await expect( + writeDuringUniqueChange(table, () => + insertRow( + { + tableId: table.id, + workspaceId, + data: { key: 'k3', email: 'dup@example.test' }, + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'stale-schema' + ) + ) + ).rejects.toThrow(/must be unique/) + expect(await countRows(table.id, 'email-dup')).toBe(1) + }) + }) + + describe('background updates validate their patch against the live schema', () => { + const columns: ColumnDefinition[] = [ + { id: 'email', name: 'email', type: 'string' }, + { id: 'kind', name: 'kind', type: 'string' }, + { + id: 'status', + name: 'status', + type: 'select', + options: [{ id: 'opt_open', name: 'Open' }], + }, + { id: 'note', name: 'note', type: 'string' }, + { id: 'due', name: 'due', type: 'date' }, + ] + /** Two update batches' worth of rows. */ + const ROWS = TABLE_LIMITS.UPDATE_BATCH_SIZE + 50 + + async function seededJob(data: RowData) { + const table = await createTable(columns) + await control`INSERT INTO user_table_rows (id, table_id, workspace_id, data, position, order_key) + SELECT ${table.id} || '-' || lpad(g::text, 4, '0'), ${table.id}, ${workspaceId}, + jsonb_build_object('email', 'e' || g || '@example.test', 'kind', 'seed'), g, 'a' || lpad(g::text, 4, '0') + FROM generate_series(1, ${ROWS}) g` + const jobId = generateId() + const filter = { kind: 'seed' } + expect( + await markTableJobRunningInWorkspace(table.id, workspaceId, jobId, 'update', { + filter, + data, + }) + ).toBe(true) + const run = () => + runTableUpdate({ + jobId, + tableId: table.id, + workspaceId, + filter, + data: { ...data }, + cutoff: new Date(), + }) + /** Re-runs the job as a retry after a crash would: the job is still running. */ + const retry = async () => { + await control`UPDATE table_jobs SET status = 'running', completed_at = NULL + WHERE id = ${jobId}` + return run() + } + return { table, run, retry } + } + + /** + * Commits `change` between the job's first and second batch. A held schema lock stops the + * first batch; `change` then queues behind it, and a waiting lock is granted in queue order, so + * the first batch commits, then `change`, then the second batch. + */ + async function changeBetweenBatches( + tableId: string, + run: () => Promise, + change: () => Promise + ): Promise[]> { + const holder = await control.reserve() + try { + await holder`BEGIN` + await holder`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))` + const job = run() + await untilSchemaLockWaiters(tableId, 1) + const changed = change() + await untilSchemaLockWaiters(tableId, 2) + await holder`COMMIT` + return await Promise.allSettled([job, changed]) + } finally { + await holder`ROLLBACK`.catch(() => {}) + holder.release() + } + } + + /** Commits `schema = ` for the table under its exclusive schema lock. */ + async function changeSchemaUnderLock(tableId: string, change: string): Promise { + const changer = await control.reserve() + try { + await changer`BEGIN` + await changer`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))` + await changer.unsafe(`UPDATE user_table_definitions SET schema = ${change} WHERE id = $1`, [ + tableId, + ]) + await changer`COMMIT` + } finally { + changer.release() + } + } + + async function countEmail(tableId: string, email: string): Promise { + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${tableId} AND data->>'email' = ${email}` + return count + } + + it('refuses a patch to a column made unique before the job started, retry included', async () => { + const { table, run } = await seededJob({ email: 'same@example.test' }) + await updateColumnConstraints( + { tableId: table.id, columnName: 'email', unique: true }, + 'bulk-update' + ) + + await expect(run()).rejects.toBeInstanceOf(UpdatePatchRejectedError) + await expect(run()).rejects.toBeInstanceOf(UpdatePatchRejectedError) + expect(await countEmail(table.id, 'same@example.test')).toBe(0) + }) + + it('refuses the next batch once a column it clears is made required mid-job', async () => { + const { table, run } = await seededJob({ note: null }) + await control`UPDATE user_table_rows SET data = data || '{"note":"filled"}' WHERE table_id = ${table.id}` + + const [job, change] = await changeBetweenBatches(table.id, run, async () => { + const changer = await control.reserve() + try { + await changer`BEGIN` + await changer`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${table.id}`}, 0))` + await changer`UPDATE user_table_definitions + SET schema = jsonb_set(schema, '{columns,3,required}', 'true') WHERE id = ${table.id}` + await changer`COMMIT` + } finally { + changer.release() + } + }) + + expect(change.status).toBe('fulfilled') + expect(job.status === 'rejected' && job.reason).toBeInstanceOf(UpdatePatchRejectedError) + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} AND data->'note' = 'null'::jsonb` + expect(count).toBe(TABLE_LIMITS.UPDATE_BATCH_SIZE) + }) + + it('refuses the next batch once a patched column is made unique mid-job, retry included', async () => { + const { table, run } = await seededJob({ email: 'same@example.test' }) + + const [job, change] = await changeBetweenBatches(table.id, run, () => + changeSchemaUnderLock(table.id, `jsonb_set(schema, '{columns,0,unique}', 'true')`) + ) + + expect(change.status).toBe('fulfilled') + expect(job.status === 'rejected' && job.reason).toBeInstanceOf(UpdatePatchRejectedError) + await expect(run()).rejects.toBeInstanceOf(UpdatePatchRejectedError) + expect(await countEmail(table.id, 'same@example.test')).toBe(TABLE_LIMITS.UPDATE_BATCH_SIZE) + }) + + it('finishes when a column it writes gains a select option mid-job', async () => { + const { table, run } = await seededJob({ status: 'Open' }) + + const [job, change] = await changeBetweenBatches(table.id, run, () => + updateColumnOptions( + { + tableId: table.id, + columnName: 'status', + options: [ + { id: 'opt_open', name: 'Open' }, + { id: 'opt_closed', name: 'Closed' }, + ], + }, + 'bulk-update' + ) + ) + + expect(change.status).toBe('fulfilled') + expect(job.status).toBe('fulfilled') + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} AND data->>'status' = 'opt_open'` + expect(count).toBe(ROWS) + }) + + it('re-derives the patch for later batches once a patched column is retyped mid-job', async () => { + const { table, run, retry } = await seededJob({ note: '7' }) + const notes = async () => + control<{ kind: string; count: number }[]>`SELECT jsonb_typeof(data->'note') AS kind, + count(*)::int AS count FROM user_table_rows WHERE table_id = ${table.id} + GROUP BY 1 ORDER BY 1` + + const [job, change] = await changeBetweenBatches(table.id, run, () => + changeSchemaUnderLock(table.id, `jsonb_set(schema, '{columns,3,type}', '"number"')`) + ) + + expect([job.status, change.status]).toEqual(['fulfilled', 'fulfilled']) + expect(await notes()).toEqual([ + { kind: 'number', count: ROWS - TABLE_LIMITS.UPDATE_BATCH_SIZE }, + { kind: 'string', count: TABLE_LIMITS.UPDATE_BATCH_SIZE }, + ]) + const [{ seven }] = await control<{ seven: number }[]>`SELECT count(*)::int AS seven + FROM user_table_rows WHERE table_id = ${table.id} AND data->'note' IN ('"7"', '7')` + expect(seven).toBe(ROWS) + + await expect(retry()).resolves.toBeUndefined() + expect(await notes()).toEqual([{ kind: 'number', count: ROWS }]) + }) + + it('drops a column deleted mid-job from later batches', async () => { + const { table, run } = await seededJob({ note: 'x', email: 'y@example.test' }) + + const [job, change] = await changeBetweenBatches(table.id, run, () => + changeSchemaUnderLock(table.id, `schema #- '{columns,3}'`) + ) + + expect([job.status, change.status]).toEqual(['fulfilled', 'fulfilled']) + expect(await countEmail(table.id, 'y@example.test')).toBe(ROWS) + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} AND data->>'note' = 'x'` + expect(count).toBe(TABLE_LIMITS.UPDATE_BATCH_SIZE) + }) + + it('refuses a batch whose re-derived patch grows a row past the size limit', async () => { + const { table, run } = await seededJob({ note: 7 }) + await changeSchemaUnderLock(table.id, `jsonb_set(schema, '{columns,3,type}', '"number"')`) + const bigRowId = `${table.id}-${String(TABLE_LIMITS.UPDATE_BATCH_SIZE + 1).padStart(4, '0')}` + // Stored jsonb orders keys by length, so a merged row reads kind, email, filler, then note. + const email = `e${TABLE_LIMITS.UPDATE_BATCH_SIZE + 1}@example.test` + const base = Buffer.byteLength(JSON.stringify({ kind: 'seed', email, filler: '', note: 7 })) + await control`UPDATE user_table_rows + SET data = data || jsonb_build_object('filler', repeat('x', ${getMaxRowSizeBytes() - base})) + WHERE id = ${bigRowId}` + + const [job, change] = await changeBetweenBatches(table.id, run, () => + changeSchemaUnderLock(table.id, `jsonb_set(schema, '{columns,3,type}', '"string"')`) + ) + + expect(change.status).toBe('fulfilled') + expect(job.status === 'rejected' && job.reason).toBeInstanceOf(UpdatePatchRejectedError) + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} AND data ? 'note'` + expect(count).toBe(TABLE_LIMITS.UPDATE_BATCH_SIZE) + }) + + it('writes a date patch given as an epoch number', async () => { + const { table, run } = await seededJob({ due: 1704067200000 }) + + await run() + + const [{ count }] = await control<{ count: number }[]>`SELECT count(*)::int AS count + FROM user_table_rows WHERE table_id = ${table.id} + AND (data->>'due')::timestamptz = '2024-01-01T00:00:00Z'` + expect(count).toBe(ROWS) + }) }) describe.skipIf(!migrated)('rows_version', () => { diff --git a/apps/sim/lib/table/rows/secret-provenance.integration.ts b/apps/sim/lib/table/rows/secret-provenance.integration.ts index 1eaae4ba5ee..a818d4aa5d2 100644 --- a/apps/sim/lib/table/rows/secret-provenance.integration.ts +++ b/apps/sim/lib/table/rows/secret-provenance.integration.ts @@ -188,7 +188,7 @@ describe('table provenance in PostgreSQL', () => { await connection`CREATE SCHEMA ${connection(testSchema)}` database.current = drizzle(connection, { schema }) await connection.unsafe(` - CREATE TABLE user_table_definitions (id text PRIMARY KEY, workspace_id text NOT NULL, rows_version integer NOT NULL); + CREATE TABLE user_table_definitions (id text PRIMARY KEY, workspace_id text NOT NULL, rows_version integer NOT NULL, schema jsonb NOT NULL); CREATE TABLE user_table_rows ( id text PRIMARY KEY, table_id text NOT NULL, workspace_id text NOT NULL, data jsonb NOT NULL, updated_at timestamp NOT NULL, secret_provenance_version integer, @@ -207,6 +207,9 @@ describe('table provenance in PostgreSQL', () => { row_id text PRIMARY KEY, content_updated_at timestamp NOT NULL, status text NOT NULL, entries jsonb NOT NULL, updated_at timestamp DEFAULT now() ); + CREATE FUNCTION user_table_schema_for_write(p_table_id text) RETURNS jsonb LANGUAGE sql VOLATILE AS $body$ + SELECT schema FROM user_table_definitions WHERE id = p_table_id + $body$; CREATE FUNCTION demote_changed_row() RETURNS trigger LANGUAGE plpgsql AS $body$ BEGIN IF NEW.data IS DISTINCT FROM OLD.data THEN @@ -225,7 +228,7 @@ describe('table provenance in PostgreSQL', () => { await connection.unsafe( 'TRUNCATE table_row_executions, user_table_rows, user_table_row_secret_provenance, user_table_definitions' ) - await connection`INSERT INTO user_table_definitions VALUES ('table-1', 'workspace-1', 7)` + await connection`INSERT INTO user_table_definitions VALUES ('table-1', 'workspace-1', 7, ${JSON.stringify(table.schema)}::jsonb)` }) afterAll(async () => { diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index b06adec9091..ae42156c51a 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -24,7 +24,7 @@ import { wouldExceedRowLimit, } from '@/lib/table/billing' import { getColumnId } from '@/lib/table/column-keys' -import { columnTypeOf, columnValueForEquality } from '@/lib/table/column-types' +import { columnTypeOf } from '@/lib/table/column-types' import { getMaxPageBytes, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { TableQueryValidationError } from '@/lib/table/errors' import { @@ -50,6 +50,11 @@ import { tableMayHaveRunState, writeExecutionsPatch, } from '@/lib/table/rows/executions' +import { + dropDeletedColumns, + lockLiveTableSchema, + refitRowToSchema, +} from '@/lib/table/rows/live-schema' import { acquireRowOrderLock, type DeletedTableRow, @@ -73,7 +78,7 @@ import { buildPredicateClause, buildSortClause, escapeLikePattern, - fieldPredicate, + uniqueValuePredicate, } from '@/lib/table/sql' import { fireTableTrigger } from '@/lib/table/trigger' import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx' @@ -99,17 +104,20 @@ import type { TableDefinition, TableRow, TableRowsCursor, + TableSchema, UpdateRowData, UpsertResult, UpsertRowData, } from '@/lib/table/types' import { + cellOf, checkBatchUniqueConstraintsDb, checkUniqueConstraintsDb, coerceRowToSchema, coerceRowValues, getUniqueColumns, type UncoercibleValuePolicy, + uniqueColumnsInPatch, uniqueValueKey, validateRowSize, } from '@/lib/table/validation' @@ -171,6 +179,7 @@ export async function insertRow( } // Validate against schema + const raw = { ...data.data } const schemaValidation = coerceRowToSchema(data.data, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( @@ -188,6 +197,7 @@ export async function insertRow( const rowId = `row_${generateId().replace(/-/g, '')}` const now = new Date() + let written = table const row = await insertOrderedRow({ tableId: data.tableId, @@ -202,22 +212,37 @@ export async function insertRow( secretProvenance: data.secretProvenance, proof: insertProof, readProvenance: options.readProvenance, - assertUnique: - getUniqueColumns(table.schema).length > 0 - ? async (trx) => { - await lockUniqueValues(trx, table, [data.data]) - const uniqueValidation = await checkUniqueConstraintsDb( - data.tableId, - data.data, - table.schema, - undefined, - trx - ) - if (!uniqueValidation.valid) { - throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) - } - } - : undefined, + validate: async (trx) => { + const live = await lockLiveTableSchema(trx, table, {}) + written = live + if (live !== table) { + const refit = refitRowToSchema( + data.data, + raw, + table.schema, + live.schema, + options.uncoercibleValues + ) + if (!refit.valid) { + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${refit.errors.join(', ')}` + ) + } + } + if (getUniqueColumns(live.schema).length === 0) return + await lockUniqueValues(trx, live, [data.data]) + const uniqueValidation = await checkUniqueConstraintsDb( + data.tableId, + data.data, + live.schema, + undefined, + trx + ) + if (!uniqueValidation.valid) { + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) + } + }, }) notifyTableRowUsage({ @@ -246,7 +271,7 @@ export async function insertRow( 'insert', [insertedRow], null, - table.schema, + written.schema, requestId ) void runWorkflowColumn({ @@ -286,20 +311,30 @@ export async function batchInsertRows( addedRows: data.rows.length, }) - const result = await db.transaction((trx) => - batchInsertRowsWithTx(trx, data, table, requestId, options) + const { rows, table: written } = await db.transaction((trx) => + batchInsertRowsWithTx(trx, data, table, requestId, { ...options, lockSchema: true }) ) notifyTableRowUsage({ workspaceId: table.workspaceId, currentRowCount: table.rowCount, - addedRows: result.length, + addedRows: rows.length, limit: rowLimit, }) - dispatchAfterBatchInsert(table, result, requestId, data.userId, data.capabilityGovernedUserId) - return result + dispatchAfterBatchInsert(written, rows, requestId, data.userId, data.capabilityGovernedUserId) + return rows +} + +/** Options for the transaction-bound whole-row writers. */ +export interface TxRowWriteOptions extends RowWriteOptions { + /** + * Opens the transaction with the table's schema guard (see `live-schema.ts`) and validates + * against the live schema it reads. Callers that already read `table` under the schema lock in + * this transaction (imports, `withLockedTable`) leave it off. + */ + lockSchema?: boolean } -export interface BatchInsertOptions extends RowWriteOptions { +export interface BatchInsertOptions extends TxRowWriteOptions { /** * The caller already holds `lockUniqueColumns` for the table in this transaction (bulk imports, * which take it before the row-order lock), so the batch takes no per-value locks of its own. @@ -311,23 +346,28 @@ export interface BatchInsertOptions extends RowWriteOptions { * Transaction-bound variant of `batchInsertRows`. Validates rows and unique * constraints, then performs INSERTs inside the provided transaction. Caller * is responsible for opening the transaction. Use when row inserts must be - * atomic with other writes (e.g., schema mutations) on the same tx. + * atomic with other writes (e.g., schema mutations) on the same tx. Returns the inserted rows and + * the definition they were validated against, for the caller's post-commit dispatch. * * Capacity is NOT checked here (it would mean a billing-pool read inside the tx). * Callers gate it before opening the tx — see `batchInsertRows` and the import paths. * * Takes the rows' unique-value locks (see `unique-locks.ts`) before the unique check and the * row-order lock, so a caller must not already hold the row-order lock unless it passes - * `uniqueColumnsLocked`. + * `uniqueColumnsLocked`. Validates against `table` unless it passes `lockSchema`, so a caller that + * does not reads `table` under the table's schema lock in this transaction. */ export async function batchInsertRowsWithTx( trx: DbTransaction, data: BatchInsertData, - table: TableDefinition, + snapshot: TableDefinition, requestId: string, options: BatchInsertOptions = {} -): Promise { - assertRowInsert(table) +): Promise<{ rows: TableRow[]; table: TableDefinition }> { + assertRowInsert(snapshot) + const timeouts = { statementMs: 60_000 } + const table = options.lockSchema ? await lockLiveTableSchema(trx, snapshot, timeouts) : snapshot + if (table !== snapshot) dropDeletedColumns(data.rows, snapshot.schema, table.schema) for (let i = 0; i < data.rows.length; i++) { const row = data.rows[i] @@ -368,7 +408,7 @@ export async function batchInsertRowsWithTx( const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) + if (!options.lockSchema) await setTableTxTimeouts(trx, timeouts) const buildRow = (rowData: RowData, position: number, orderKey: string) => ({ id: `row_${generateId().replace(/-/g, '')}`, @@ -418,7 +458,7 @@ export async function batchInsertRowsWithTx( })) await options.readProvenance?.capture(trx, result) - return result + return { rows: result, table } } /** @@ -490,7 +530,7 @@ export async function replaceTableRows( addedRows: data.rows.length, }) const result = await db.transaction((trx) => - replaceTableRowsWithTx(trx, data, table, requestId, options) + replaceTableRowsWithTx(trx, data, table, requestId, { ...options, lockSchema: true }) ) notifyTableRowUsage({ workspaceId: table.workspaceId, @@ -509,17 +549,29 @@ export async function replaceTableRows( * Callers gate it before opening the tx — see `replaceTableRows` and `importReplaceRows`. * * Takes the table's unique lock before its row-order lock, so a caller already holding the - * row-order lock must take `lockUniqueColumns` first. + * row-order lock must take `lockUniqueColumns` first. Validates against `table` unless it passes + * `lockSchema`, so a caller that does not reads `table` under the table's schema lock in this + * transaction. */ export async function replaceTableRowsWithTx( trx: DbTransaction, data: ReplaceRowsData, - table: TableDefinition, + snapshot: TableDefinition, requestId: string, - options: RowWriteOptions = {} + options: TxRowWriteOptions = {} ): Promise { - assertRowDelete(table) - assertRowInsert(table) + assertRowDelete(snapshot) + assertRowInsert(snapshot) + + const totalRowWork = Math.max(0, snapshot.rowCount ?? 0) + data.rows.length + const statementMs = scaledStatementTimeoutMs(totalRowWork, { + baseMs: 120_000, + perRowMs: 3, + }) + const table = options.lockSchema + ? await lockLiveTableSchema(trx, snapshot, { statementMs }) + : snapshot + if (table !== snapshot) dropDeletedColumns(data.rows, snapshot.schema, table.schema) if (data.tableId !== table.id) { throw new Error(`Table ID mismatch: ${data.tableId} vs ${table.id}`) @@ -560,11 +612,9 @@ export async function replaceTableRowsWithTx( // Coerced rows are keyed by column id, not display name — reading // `row[col.name]` silently misses renamed columns and lets dupes through. const colId = getColumnId(col) - const value = row[colId] + const value = cellOf(row, colId) if (value === null || value === undefined) continue - // Case-sensitive, consistent with the unique-constraint check leaf. - const comparable = columnValueForEquality(value, col) - const normalized = typeof comparable === 'string' ? comparable : JSON.stringify(comparable) + const normalized = uniqueValueKey(value, col) const map = seen.get(colId)! if (map.has(normalized)) { throw new OrchestrationError( @@ -579,13 +629,7 @@ export async function replaceTableRowsWithTx( const now = new Date() - const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length - const statementMs = scaledStatementTimeoutMs(totalRowWork, { - baseMs: 120_000, - perRowMs: 3, - }) - - await setTableTxTimeouts(trx, { statementMs }) + if (!options.lockSchema) await setTableTxTimeouts(trx, { statementMs }) // Every current row is about to go, so any concurrent write of a unique value conflicts with the // replacement set: hold the unique columns exclusively, ahead of the row-order lock. @@ -662,28 +706,13 @@ export async function replaceTableRowsWithTx( } /** - * Upserts a row: updates an existing row if a match is found on the conflict target - * column, otherwise inserts a new row. - * - * Uses a single unique column for matching (not OR across all unique columns) to avoid - * ambiguous matches when multiple unique columns exist. Capacity is checked best-effort - * against the current plan limit on the insert path. On the insert path we acquire the - * per-table advisory lock and re-check for an existing match before inserting, so a - * concurrent upsert racing on the same conflict target cannot produce a duplicate row. - * - * @param data - Upsert data including optional conflictTarget - * @param table - Table definition - * @param requestId - Request ID for logging - * @returns The upserted row and whether it was an insert or update - * @throws Error if no unique columns, ambiguous conflict target, or capacity exceeded + * The single unique column an upsert matches on, resolved from `conflictTarget` — an id + * (first-party) or a name (legacy/internal) — or, without one, the table's only unique column. */ -export async function upsertRow( - data: UpsertRowData, - table: TableDefinition, - requestId: string, - options: RowWriteOptions = {} -): Promise { - const schema = table.schema +function resolveUpsertTarget( + schema: TableSchema, + conflictTarget: string | undefined +): ColumnDefinition { const uniqueColumns = getUniqueColumns(schema) if (uniqueColumns.length === 0) { @@ -693,13 +722,9 @@ export async function upsertRow( ) } - // Determine the single conflict target column, resolving to its stable - // storage id (the row-data key). `conflictTarget` may arrive as an id - // (first-party) or a name (legacy/internal) — match either. - let targetColumnKey: string - if (data.conflictTarget) { + if (conflictTarget) { const col = uniqueColumns.find( - (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget + (c) => getColumnId(c) === conflictTarget || c.name === conflictTarget ) if (!col) { /** @@ -707,25 +732,65 @@ export async function upsertRow( * `conflictTarget` to its storage id before this call, so echoing the * argument verbatim answers a request naming `email` with a `col_…` id * the caller has never seen and cannot map back. Same rule as the - * missing-value branch below. + * missing-value branch in {@link upsertConflictProbe}. */ const requested = - schema.columns.find((c) => getColumnId(c) === data.conflictTarget)?.name ?? - data.conflictTarget + schema.columns.find((c) => getColumnId(c) === conflictTarget)?.name ?? conflictTarget throw new OrchestrationError( 'validation', `Column "${requested}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` ) } - targetColumnKey = getColumnId(col) - } else if (uniqueColumns.length === 1) { - targetColumnKey = getColumnId(uniqueColumns[0]) - } else { + return col + } + if (uniqueColumns.length === 1) return uniqueColumns[0] + throw new OrchestrationError( + 'validation', + `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` + ) +} + +/** + * The upsert's conflict probe: the coerced row's `target` value, matched through the same + * {@link uniqueValuePredicate} as the unique check, so "find the row to update" and "is this value + * unique" agree. Read after coercion so the probe matches the persisted type (a coerced `"123"` + * matches a stored `123`). + */ +function upsertConflictProbe(target: ColumnDefinition, row: RowData): SQL { + const targetValue = cellOf(row, getColumnId(target)) + if (targetValue === undefined || targetValue === null) { + // Surface the display name, not the internal id — v1 callers pass a name. throw new OrchestrationError( 'validation', - `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` + `Upsert requires a value for the conflict target column "${target.name}"` ) } + return uniqueValuePredicate(USER_TABLE_ROWS_SQL_NAME, getColumnId(target), targetValue, target) +} + +/** + * Upserts a row: updates an existing row if a match is found on the conflict target + * column, otherwise inserts a new row. + * + * Uses a single unique column for matching (not OR across all unique columns) to avoid + * ambiguous matches when multiple unique columns exist. Capacity is checked best-effort + * against the current plan limit on the insert path. On the insert path we acquire the + * per-table advisory lock and re-check for an existing match before inserting, so a + * concurrent upsert racing on the same conflict target cannot produce a duplicate row. + * + * @param data - Upsert data including optional conflictTarget + * @param table - Table definition + * @param requestId - Request ID for logging + * @returns The upserted row and whether it was an insert or update + * @throws Error if no unique columns, ambiguous conflict target, or capacity exceeded + */ +export async function upsertRow( + data: UpsertRowData, + table: TableDefinition, + requestId: string, + options: RowWriteOptions = {} +): Promise { + const target = resolveUpsertTarget(table.schema, data.conflictTarget) // Validate row data const sizeValidation = validateRowSize(data.data) @@ -733,7 +798,8 @@ export async function upsertRow( throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } - const schemaValidation = coerceRowToSchema(data.data, schema, options.uncoercibleValues) + const raw = { ...data.data } + const schemaValidation = coerceRowToSchema(data.data, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -741,49 +807,44 @@ export async function upsertRow( ) } - // Read the conflict-target value *after* coercion so `matchFilter` branches on - // the persisted type (e.g. a coerced `"123"` → `123` matches existing rows). - const targetValue = data.data[targetColumnKey] - if (targetValue === undefined || targetValue === null) { - // Surface the display name, not the internal id — v1 callers pass a name. - const targetColumnName = - uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey - throw new OrchestrationError( - 'validation', - `Upsert requires a value for the conflict target column "${targetColumnName}"` - ) - } - - // Build the conflict probe through the SAME leaf as the unique-constraint check - // (`fieldPredicate` → case-sensitive JSONB containment). This is what makes - // "find the row to update" and "is this value unique" agree: a value differing - // only in case can no longer slip past the probe and then trip the guard. - // `eq` always yields a clause for a non-null value (guaranteed above). - const matchFilter = fieldPredicate( - USER_TABLE_ROWS_SQL_NAME, - targetColumnKey, - 'eq', - targetValue, - table.schema.columns.find((c) => getColumnId(c) === targetColumnKey) - ) - if (!matchFilter) { - throw new Error('Failed to build upsert conflict predicate') - } + const snapshotMatchFilter = upsertConflictProbe(target, data.data) // Resolve the plan limit BEFORE the tx (the lookup is a separate pool read; doing // it inside the tx would hold a connection + the row-order lock during it). The // insert branch enforces it; the update path doesn't add a row, so it's exempt. const rowLimit = await getMaxRowsPerTable(table.workspaceId) + let written = table const result = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx) + const live = await lockLiveTableSchema(trx, table, {}) + written = live // The conflict lookups below match on `data->>key` — unestimatable, and an // insert-path upsert (no existing match) can't exit early, so the planner // would seq-scan the whole shared relation. See withSeqscanOff. await trx.execute(sql`SET LOCAL enable_seqscan = off`) + let matchFilter = snapshotMatchFilter + if (live !== table) { + const refit = refitRowToSchema( + data.data, + raw, + table.schema, + live.schema, + options.uncoercibleValues + ) + if (!refit.valid) { + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${refit.errors.join(', ')}` + ) + } + matchFilter = upsertConflictProbe( + resolveUpsertTarget(live.schema, data.conflictTarget), + data.data + ) + } // Holds every unique value this row writes, the conflict target included, before the lookup // and the unique check below: concurrent upserts and inserts of the same value serialize here. - await lockUniqueValues(trx, table, [data.data]) + await lockUniqueValues(trx, live, [data.data]) // Find existing row by single conflict target column const [existingRow] = await trx @@ -802,7 +863,7 @@ export async function upsertRow( const uniqueValidation = await checkUniqueConstraintsDb( data.tableId, data.data, - schema, + live.schema, existingRow?.id, // exclude the matched row on updates trx ) @@ -945,7 +1006,7 @@ export async function upsertRow( 'insert', [result.row], null, - table.schema, + written.schema, requestId ) } else if (result.operation === 'update' && result.previousData) { @@ -957,7 +1018,7 @@ export async function upsertRow( 'update', [result.row], oldRows, - table.schema, + written.schema, requestId ) } @@ -1767,7 +1828,7 @@ export async function updateRow( // Auto-clear exec records for workflow output columns the user just wiped // AND for downstream groups whose deps just changed. Surfaces the in-flight // downstream groups so the caller can cancel + re-run them. - const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = + let { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = deriveExecClearsForDataPatch( data.data, table.schema, @@ -1775,7 +1836,7 @@ export async function updateRow( data.executionsPatch, mergedData ) - const mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch) + let mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch) // Validate size const sizeValidation = validateRowSize(mergedData) @@ -1810,12 +1871,8 @@ export async function updateRow( // unrelated cell edit should not fail on data it did not write, and blocking // it was never a repair mechanism. const patchedColumnIds = new Set(Object.keys(data.data)) - const patchedUniqueColumns = getUniqueColumns(table.schema).filter((column) => - patchedColumnIds.has(getColumnId(column)) - ) const now = new Date() - const persistedData = jsonbMergePatch(Object.keys(data.data), mergedData) // Cell-task partial writes pass `cancellationGuard` so the upsert into // `tableRowExecutions` is a no-op when (a) a stop click already wrote @@ -1826,12 +1883,48 @@ export async function updateRow( // and the row out of sync. const guard = data.cancellationGuard let persistedRow: typeof userTableRows.$inferSelect + let written = table try { persistedRow = await db.transaction(async (trx) => { + // An executions-only write stores no cell, so it has no schema to hold still. + const live = patchedColumnIds.size > 0 ? await lockLiveTableSchema(trx, table) : table + written = live + if (live !== table) { + const refit = refitRowToSchema( + mergedData, + { ...(existingRow.data as RowData), ...data.data }, + table.schema, + live.schema, + options.uncoercibleValues, + Object.keys(data.data) + ) + if (!refit.valid) { + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${refit.errors.join(', ')}` + ) + } + ;({ executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = + deriveExecClearsForDataPatch( + data.data, + live.schema, + existingRow.executions, + data.executionsPatch, + mergedData + )) + mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch) + } + const persistedData = jsonbMergePatch( + Object.keys(data.data).filter((columnId) => Object.hasOwn(mergedData, columnId)), + mergedData + ) + const patchedUniqueColumns = getUniqueColumns(live.schema).filter((column) => + patchedColumnIds.has(getColumnId(column)) + ) if (patchedUniqueColumns.length > 0) { - // The value locks come first in the transaction, so a concurrent write of the same value - // commits before this check runs, or waits for this edit to commit. - await lockUniqueValues(trx, table, [mergedData], patchedColumnIds) + // The value locks come first after the schema lock, so a concurrent write of the same + // value commits before this check runs, or waits for this edit to commit. + await lockUniqueValues(trx, live, [mergedData], patchedColumnIds) const uniqueValidation = await checkUniqueConstraintsDb( data.tableId, mergedData, @@ -1839,7 +1932,7 @@ export async function updateRow( // probe issues one SELECT per unique column it is given, so a table with // several would otherwise re-check all of them to validate a patch that // touched one. `schema` is read only for its unique columns here. - { ...table.schema, columns: patchedUniqueColumns }, + { ...live.schema, columns: patchedUniqueColumns }, data.rowId, // Exclude current row trx ) @@ -1917,7 +2010,7 @@ export async function updateRow( 'update', [updatedRow], oldRows, - table.schema, + written.schema, requestId ) @@ -2038,7 +2131,7 @@ function validateBulkUpdatePatch( policy: UncoercibleValuePolicy | undefined ): void { const suppliedColumns = table.schema.columns.filter((column) => { - const value = patch[getColumnId(column)] + const value = cellOf(patch, getColumnId(column)) return value !== null && value !== undefined }) if (suppliedColumns.length === 0) return @@ -2053,6 +2146,26 @@ function validateBulkUpdatePatch( } } +/** + * The patch a bulk update writes against `live`: `raw` with the cells of columns deleted since + * `previous` dropped, coerced with `policy`, and validated on its own (see + * {@link validateBulkUpdatePatch}). The inline update derives its patch here, again whenever the + * schema moves under it, and the background runner derives each batch's patch here, so the two + * cannot disagree about what one request writes. + */ +export function deriveBulkUpdatePatch( + raw: RowData, + previous: TableSchema, + live: TableDefinition, + policy: UncoercibleValuePolicy | undefined +): RowData { + const patch = { ...raw } + dropDeletedColumns([patch], previous, live.schema) + coerceRowValues(patch, live.schema, policy) + validateBulkUpdatePatch(live, patch, policy) + return patch +} + /** Validates a bounded page of rows against a bulk merge patch. */ function validateBulkUpdateMatches( table: TableDefinition, @@ -2066,10 +2179,55 @@ function validateBulkUpdateMatches( } } -/** Persists one bounded bulk-update page and returns the locked rows actually changed. */ +/** + * Refuses a bulk patch that writes a unique column into more than one row, and otherwise locks and + * checks the one row's unique values. The value locks come first after the schema lock, so a + * concurrent write of the same value is committed and visible to the check. + */ +async function assertBulkUpdateUnique( + trx: DbTransaction, + table: TableDefinition, + rows: BulkUpdateMatch[], + patch: RowData +): Promise { + const uniqueColumnsInUpdate = getUniqueColumns(table.schema).filter((column) => + Object.hasOwn(patch, getColumnId(column)) + ) + if (uniqueColumnsInUpdate.length === 0 || rows.length === 0) return + if (rows.length > 1) { + throw new OrchestrationError( + 'validation', + `Cannot set unique column values when updating multiple rows. ` + + `Columns with unique constraint: ${uniqueColumnsInUpdate.map((column) => column.name).join(', ')}. ` + + `Updating ${rows.length} rows with the same value would violate uniqueness.` + ) + } + await lockUniqueValues(trx, table, [patch]) + const uniqueValidation = await checkUniqueConstraintsDb( + table.id, + { ...rows[0].data, ...patch }, + table.schema, + rows[0].id, + trx + ) + if (!uniqueValidation.valid) { + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) + } +} + +/** + * Persists one bounded bulk-update page and returns the locked rows actually changed, with the + * patch as written and the definition it was written against: re-coerced, and live, when the + * schema changed since the caller prepared it. + */ async function persistBulkUpdateBatch(params: { table: TableDefinition rows: BulkUpdateMatch[] + /** The request's patch before coercion, re-derived when the schema moved. */ + raw: RowData patch: RowData patchJson: string filterClause: SQL @@ -2077,26 +2235,27 @@ async function persistBulkUpdateBatch(params: { secretProvenance: BulkUpdateData['secretProvenance'] requestId: string uncoercibleValues: UncoercibleValuePolicy | undefined - /** Runs first in the write transaction: the unique-value locks and check of a unique patch. */ - assertUnique?: (trx: DbTransaction) => Promise -}): Promise<{ rows: BulkUpdateMatch[]; affectedRowIds: string[] }> { - const { - table, - rows, - patch, - patchJson, - filterClause, - now, - secretProvenance, - requestId, - uncoercibleValues, - assertUnique, - } = params +}): Promise<{ + rows: BulkUpdateMatch[] + affectedRowIds: string[] + patch: RowData + table: TableDefinition +}> { + const { table: snapshot, rows, raw, filterClause, now, secretProvenance, requestId } = params + const { uncoercibleValues } = params + let { patch, patchJson } = params const ids = rows.map((row) => row.id) const persistedRows: BulkUpdateMatch[] = [] + let written = snapshot const affectedRowIds = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await assertUnique?.(trx) + const table = await lockLiveTableSchema(trx, snapshot, { statementMs: 60_000 }) + written = table + if (table !== snapshot) { + patch = deriveBulkUpdatePatch(raw, snapshot.schema, table, uncoercibleValues) + validateBulkUpdateMatches(table, rows, patch, uncoercibleValues) + patchJson = JSON.stringify(patch) + } + await assertBulkUpdateUnique(trx, table, rows, patch) return mutateTableRowsWithSecretProvenance(trx, { rows: ids.map((rowId) => ({ rowId, provenance: secretProvenance })), rowState: 'existing', @@ -2152,7 +2311,7 @@ async function persistBulkUpdateBatch(params: { }, }) }) - return { rows: persistedRows, affectedRowIds } + return { rows: persistedRows, affectedRowIds, patch, table: written } } /** Emits trigger and enrichment side effects for one committed bulk-update page. */ @@ -2235,40 +2394,15 @@ export async function updateRowsByFilter( eq(userTableRows.workspaceId, table.workspaceId) ) - coerceRowValues(data.data, table.schema, options.uncoercibleValues) - validateBulkUpdatePatch(table, data.data, options.uncoercibleValues) - const uniqueColumns = getUniqueColumns(table.schema) - const uniqueColumnsInUpdate = uniqueColumns.filter((col) => getColumnId(col) in data.data) - const patchJson = JSON.stringify(data.data) + const patch = deriveBulkUpdatePatch(data.data, table.schema, table, options.uncoercibleValues) + const uniqueColumnsInUpdate = uniqueColumnsInPatch(table.schema, patch) + const patchJson = JSON.stringify(patch) const now = new Date() const limit = data.limit - /** - * A unique patch reaches exactly one row. Its value locks and check run first in the write - * transaction, so a concurrent write of the same value is committed and visible to the check. - */ - const uniqueGuard = - (row: BulkUpdateMatch) => - async (trx: DbTransaction): Promise => { - await lockUniqueValues(trx, table, [data.data]) - const uniqueValidation = await checkUniqueConstraintsDb( - table.id, - { ...row.data, ...data.data }, - table.schema, - row.id, - trx - ) - if (!uniqueValidation.valid) { - throw new OrchestrationError( - 'validation', - `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` - ) - } - } if (limit === undefined) { const cutoff = new Date() let matchingRowCount = 0 - let singleMatchingRow: BulkUpdateMatch | undefined let afterId: string | undefined while (true) { @@ -2282,9 +2416,8 @@ export async function updateRowsByFilter( }) if (page.length === 0) break - validateBulkUpdateMatches(table, page, data.data, options.uncoercibleValues) + validateBulkUpdateMatches(table, page, patch, options.uncoercibleValues) matchingRowCount += page.length - singleMatchingRow ??= page[0] afterId = page[page.length - 1].id if (page.length < TABLE_LIMITS.UPDATE_BATCH_SIZE) break } @@ -2302,14 +2435,7 @@ export async function updateRowsByFilter( `Updating ${matchingRowCount} rows with the same value would violate uniqueness.` ) } - if (!singleMatchingRow) { - throw new Error('Bulk update lost its selected row') - } } - const assertUnique = - uniqueColumnsInUpdate.length > 0 && singleMatchingRow - ? uniqueGuard(singleMatchingRow) - : undefined const affectedRowIds: string[] = [] afterId = undefined @@ -2328,21 +2454,21 @@ export async function updateRowsByFilter( const persisted = await persistBulkUpdateBatch({ table, rows: batchRows, - patch: data.data, + raw: data.data, + patch, patchJson, filterClause, now, secretProvenance: data.secretProvenance, requestId, uncoercibleValues: options.uncoercibleValues, - assertUnique, }) affectedRowIds.push(...persisted.affectedRowIds) dispatchBulkUpdateEffects( - table, + persisted.table, persisted.rows, persisted.affectedRowIds, - data.data, + persisted.patch, now, requestId, data.actorUserId, @@ -2369,7 +2495,7 @@ export async function updateRowsByFilter( return { affectedCount: 0, affectedRowIds: [] } } - validateBulkUpdateMatches(table, matchingRows, data.data, options.uncoercibleValues) + validateBulkUpdateMatches(table, matchingRows, patch, options.uncoercibleValues) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { throw new OrchestrationError( @@ -2384,23 +2510,23 @@ export async function updateRowsByFilter( const persisted = await persistBulkUpdateBatch({ table, rows: matchingRows, - patch: data.data, + raw: data.data, + patch, patchJson, filterClause, now, secretProvenance: data.secretProvenance, requestId, uncoercibleValues: options.uncoercibleValues, - assertUnique: uniqueColumnsInUpdate.length > 0 ? uniqueGuard(matchingRows[0]) : undefined, }) const { affectedRowIds } = persisted logger.info(`[${requestId}] Updated ${affectedRowIds.length} rows in table ${table.id}`) dispatchBulkUpdateEffects( - table, + persisted.table, persisted.rows, affectedRowIds, - data.data, + persisted.patch, now, requestId, data.actorUserId, @@ -2413,6 +2539,54 @@ export async function updateRowsByFilter( } } +/** One update of a batch, merged over its stored row and coerced to the schema. */ +interface MergedRowUpdate { + rowId: string + changedColumnIds: string[] + mergedData: RowData + mergedExecutions: RowExecutions + executionsPatch?: Record + inFlightDownstreamGroups: string[] +} + +/** + * The unique columns each update of a batch changes, for its value locks and unique check. Like + * `updateRow`, an update checks only the unique columns it changes: a value it leaves alone is the + * one already stored, and updates that change none cost nothing here. The DB check runs before any + * write and the value locks are deduplicated, so two updates writing the same value would both pass + * it; they are rejected here instead, keyed like the locks. + */ +function planBatchUpdateUniqueChecks( + schema: TableSchema, + updates: readonly MergedRowUpdate[] +): Array<{ rowId: string; mergedData: RowData; columns: ColumnDefinition[] }> { + const uniqueColumns = getUniqueColumns(schema) + const uniqueChecks: Array<{ rowId: string; mergedData: RowData; columns: ColumnDefinition[] }> = + [] + if (uniqueColumns.length === 0) return uniqueChecks + const firstWriter = new Map() + for (const { rowId, changedColumnIds, mergedData } of updates) { + const changed = new Set(changedColumnIds) + const columns = uniqueColumns.filter((column) => changed.has(getColumnId(column))) + if (columns.length === 0) continue + uniqueChecks.push({ rowId, mergedData, columns }) + for (const column of columns) { + const value = mergedData[getColumnId(column)] + if (value === null || value === undefined) continue + const key = `${getColumnId(column)}:${uniqueValueKey(value, column)}` + const otherRowId = firstWriter.get(key) + if (otherRowId !== undefined && otherRowId !== rowId) { + throw new OrchestrationError( + 'validation', + `Row ${rowId}: Column "${column.name}" must be unique. Value "${String(value)}" duplicates row ${otherRowId} in batch` + ) + } + firstWriter.set(key, rowId) + } + } + return uniqueChecks +} + export interface BatchUpdateRowsOptions extends RowWriteOptions { /** * Marks the batch as workflow/enrichment output cells (the backfill runner), @@ -2476,14 +2650,7 @@ export async function batchUpdateRows( throw new OrchestrationError('validation', `Rows not found: ${missing.join(', ')}`) } - const mergedUpdates: Array<{ - rowId: string - changedColumnIds: string[] - mergedData: RowData - mergedExecutions: RowExecutions - executionsPatch?: Record - inFlightDownstreamGroups: string[] - }> = [] + const mergedUpdates: MergedRowUpdate[] = [] for (const update of data.updates) { const existing = existingMap.get(update.rowId)! const merged = { ...existing.data, ...update.data } @@ -2532,44 +2699,52 @@ export async function batchUpdateRows( }) } - // Like `updateRow`, each update locks and checks only the unique columns it changes: a value it - // leaves alone is the one already stored. Updates that change none cost nothing extra here. - const uniqueColumns = getUniqueColumns(table.schema) - const uniqueChecks: Array<{ rowId: string; mergedData: RowData; columns: ColumnDefinition[] }> = - [] - if (uniqueColumns.length > 0) { - // The DB check runs before any write and the value locks are deduplicated, so two updates in - // this batch writing the same value would both pass it; catch them here, keyed like the locks. - const firstWriter = new Map() - for (const { rowId, changedColumnIds, mergedData } of mergedUpdates) { - const changed = new Set(changedColumnIds) - const columns = uniqueColumns.filter((column) => changed.has(getColumnId(column))) - if (columns.length === 0) continue - uniqueChecks.push({ rowId, mergedData, columns }) - for (const column of columns) { - const value = mergedData[getColumnId(column)] - if (value === null || value === undefined) continue - const key = `${getColumnId(column)}:${uniqueValueKey(value, column)}` - const otherRowId = firstWriter.get(key) - if (otherRowId !== undefined && otherRowId !== rowId) { + let uniqueChecks = planBatchUpdateUniqueChecks(table.schema, mergedUpdates) + + const now = new Date() + let written = table + + const affectedRowIds = await db.transaction(async (trx) => { + const live = await lockLiveTableSchema(trx, table, { statementMs: 60_000 }) + written = live + if (live !== table) { + for (const [index, update] of mergedUpdates.entries()) { + const request = data.updates[index] + const existing = existingMap.get(update.rowId)! + const refit = refitRowToSchema( + update.mergedData, + { ...existing.data, ...request.data }, + table.schema, + live.schema, + options.uncoercibleValues, + update.changedColumnIds + ) + if (!refit.valid) { throw new OrchestrationError( 'validation', - `Row ${rowId}: Column "${column.name}" must be unique. Value "${String(value)}" duplicates row ${otherRowId} in batch` + `Row ${update.rowId}: ${refit.errors.join(', ')}` ) } - firstWriter.set(key, rowId) + update.changedColumnIds = update.changedColumnIds.filter((columnId) => + Object.hasOwn(update.mergedData, columnId) + ) + const cleared = deriveExecClearsForDataPatch( + request.data, + live.schema, + existing.executions, + request.executionsPatch, + update.mergedData + ) + update.executionsPatch = cleared.executionsPatch + update.inFlightDownstreamGroups = cleared.inFlightDownstreamGroups + update.mergedExecutions = applyExecutionsPatch(existing.executions, cleared.executionsPatch) } + uniqueChecks = planBatchUpdateUniqueChecks(live.schema, mergedUpdates) } - } - - const now = new Date() - - const affectedRowIds = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) if (uniqueChecks.length > 0) { await lockUniqueValues( trx, - table, + live, uniqueChecks.map(({ mergedData, columns }) => Object.fromEntries( columns.map((column) => [getColumnId(column), mergedData[getColumnId(column)]]) @@ -2580,7 +2755,7 @@ export async function batchUpdateRows( const uniqueValidation = await checkUniqueConstraintsDb( data.tableId, mergedData, - { ...table.schema, columns }, + { ...live.schema, columns }, rowId, trx ) @@ -2653,7 +2828,7 @@ export async function batchUpdateRows( 'update', updatedRowsForTrigger, oldRowsForTrigger, - table.schema, + written.schema, requestId ) } diff --git a/apps/sim/lib/table/rows/unique-locks.ts b/apps/sim/lib/table/rows/unique-locks.ts index 313c4f79076..e5b2ee8b40e 100644 --- a/apps/sim/lib/table/rows/unique-locks.ts +++ b/apps/sim/lib/table/rows/unique-locks.ts @@ -7,9 +7,9 @@ * values it writes, so the second writer's check runs after the first commits and sees its row. * Writers of different values never wait on each other. * - * Lock order, everywhere: the table's schema lock (when taken), then the table's unique lock and its - * value locks in sorted order, in one statement, then the table's row-order lock, then the - * definition row. + * Lock order, everywhere: the table's schema lock (shared by row writes, exclusive by schema + * changes; see `live-schema.ts`), then the table's unique lock and its value locks in sorted order, + * in one statement, then the table's row-order lock, then the definition row. */ import { compareStrings } from '@sim/utils/string' @@ -17,7 +17,7 @@ import { type AdvisoryXactLockRequest, acquireAdvisoryXactLocks } from '@/lib/db import { getColumnId } from '@/lib/table/column-keys' import type { DbTransaction } from '@/lib/table/planner' import type { RowData, TableDefinition } from '@/lib/table/types' -import { getUniqueColumns, uniqueValueKey } from '@/lib/table/validation' +import { cellOf, getUniqueColumns, uniqueValueKey } from '@/lib/table/validation' const UNIQUE_LOCK_TAG = 'user_table_unique_value' @@ -45,11 +45,11 @@ function tableLockKey(tableId: string): string { * lock only the unique columns a patch changes; values it leaves alone are already stored. * * The table lock is taken shared, then an exclusive lock per value. The value key includes - * `uniqueValueKey`, so two values the check treats as equal always share a key; null cells never - * conflict and take none. The table lock is taken exclusively instead, with no value locks, when a - * value is an object or array (a `json` column's check matches by containment, which no single key - * can express) or when the transaction would exceed {@link MAX_VALUE_LOCKS}. An exclusive holder - * waits for, and blocks, every shared holder, so it still serializes against per-value writers. + * `uniqueValueKey`, so two values the check treats as equal always share a key, objects and arrays + * included, since the check compares those by jsonb equality; null cells never conflict and take + * none. The table lock is taken exclusively instead, with no value locks, when the transaction would + * exceed {@link MAX_VALUE_LOCKS}. An exclusive holder waits for, and blocks, every shared holder, so + * it still serializes against per-value writers. */ export async function lockUniqueValues( trx: DbTransaction, @@ -63,9 +63,8 @@ export async function lockUniqueValues( const columnId = getColumnId(column) if (columnIds && !columnIds.has(columnId)) continue for (const row of rows) { - const value = row[columnId] + const value = cellOf(row, columnId) if (value === null || value === undefined) continue - if (typeof value === 'object') return lockUniqueColumns(trx, table) const key = `${tableKey}:${columnId}:${uniqueValueKey(value, column)}` if (!valueKeys.has(key) && valueKeys.size === MAX_VALUE_LOCKS) { return lockUniqueColumns(trx, table) diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index d154a6f44db..0f287ead4cb 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -532,10 +532,11 @@ function buildFieldCondition( /** * The single leaf primitive: compiles one `field op value` into SQL. Every * matcher routes through here — both filter compilers (`buildFilterClause` for - * the legacy `$`-grammar, `buildPredicateClause` for the v2 grammar), the upsert - * conflict probe, and the unique-constraint checks. Centralizing the leaf means - * equality/case/null/cast semantics are defined exactly once, so "find the row" - * and "is this value unique" can never disagree. + * the legacy `$`-grammar, `buildPredicateClause` for the v2 grammar), and, through + * {@link uniqueValuePredicate}, the upsert conflict probe and the unique-constraint + * checks. Centralizing the leaf means equality/case/null/cast semantics are + * defined exactly once, so "find the row" and "is this value unique" can never + * disagree. * * Returns `undefined` when the predicate is a no-op (empty `in`/`nin` array), * matching the legacy behavior of emitting no clause. @@ -869,6 +870,29 @@ function buildSystemColumnClause( } } +/** + * Whether a row holds `value` in a unique column: the upsert conflict probe and every unique check + * match with it. It is the `eq` leaf of {@link fieldPredicate}, made exact for an object or array. + * Containment is not equality there (`{"a":1,"b":2} @> {"a":1}`), so the leaf ANDs jsonb equality + * on the cell, keeping the containment half for the GIN index. jsonb equality ignores object key + * order and keeps array order, as `uniqueValueKey` does. A scalar leaf's containment is already + * equality. + */ +export function uniqueValuePredicate( + tableName: string, + field: string, + value: JsonValue, + column: ColumnDefinition +): SQL { + const clause = fieldPredicate(tableName, field, 'eq', value, column) + if (!clause) { + throw new Error(`Failed to build unique-constraint predicate for column "${column.name}"`) + } + if (value === null || typeof value !== 'object') return clause + const cell = sql`${sql.raw(`${tableName}.data`)}->${field}::text` + return sql`(${clause} AND ${cell} = ${JSON.stringify(value)}::jsonb)` +} + /** Builds JSONB containment clause: `data @> '{"field": value}'::jsonb` (uses GIN index) */ function buildContainmentClause(tableName: string, field: string, value: JsonValue): SQL { const jsonObj = JSON.stringify({ [field]: value }) diff --git a/apps/sim/lib/table/tx.ts b/apps/sim/lib/table/tx.ts index 43e5f57bf2d..5b5b05322f9 100644 --- a/apps/sim/lib/table/tx.ts +++ b/apps/sim/lib/table/tx.ts @@ -5,7 +5,7 @@ * directly from `@/lib/table/tx`. */ -import { sql } from 'drizzle-orm' +import { type SQL, sql } from 'drizzle-orm' import type { DbTransaction } from '@/lib/table/planner' const TIMEOUT_CAP_MS = 10 * 60_000 @@ -28,19 +28,29 @@ const TIMEOUT_CAP_MS = 10 * 60_000 * Safe under pgBouncer transaction pooling — the settings are transaction-scoped and cleared at * COMMIT/ROLLBACK before the session returns to the pool. */ -export async function setTableTxTimeouts( - trx: DbTransaction, - opts?: { statementMs?: number; lockMs?: number; idleMs?: number } -) { +export async function setTableTxTimeouts(trx: DbTransaction, opts?: TableTxTimeouts) { + await trx.execute(sql`select ${tableTxTimeoutSettings(opts)}`) +} + +/** Per-transaction timeouts, in milliseconds. See {@link setTableTxTimeouts}. */ +export interface TableTxTimeouts { + statementMs?: number + lockMs?: number + idleMs?: number +} + +/** + * The `set_config` calls {@link setTableTxTimeouts} runs, for a statement that applies them + * alongside its own work. + */ +export function tableTxTimeoutSettings(opts?: TableTxTimeouts): SQL { const s = opts?.statementMs ?? 10_000 const l = opts?.lockMs ?? 3_000 const i = opts?.idleMs ?? 5_000 - await trx.execute(sql` - select + return sql` set_config('statement_timeout', ${`${s}ms`}, true), set_config('lock_timeout', ${`${l}ms`}, true), - set_config('idle_in_transaction_session_timeout', ${`${i}ms`}, true) - `) + set_config('idle_in_transaction_session_timeout', ${`${i}ms`}, true)` } /** diff --git a/apps/sim/lib/table/update-row.test.ts b/apps/sim/lib/table/update-row.test.ts index 6df3dde836f..27ca42f3bbc 100644 --- a/apps/sim/lib/table/update-row.test.ts +++ b/apps/sim/lib/table/update-row.test.ts @@ -1,6 +1,7 @@ import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { tableBillingMock, tableBillingMockFns } from '@sim/testing/mocks/table-billing.mock' +import { tableRowsLiveSchemaMock } from '@sim/testing/mocks/table-rows-live-schema.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { batchUpdateRows, @@ -20,18 +21,24 @@ tableBillingMockFns.mockWouldExceedRowLimit.mockReturnValue(false) // suites can use large synthetic row counts without tripping the plan limit. vi.mock('@/lib/table/billing', () => tableBillingMock) -vi.mock('@/lib/table/validation', async (importOriginal) => ({ - uniqueValueKey: (await importOriginal()).uniqueValueKey, - validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), - validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), - coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })), - coerceRowValues: vi.fn(), - validateTableName: vi.fn(() => ({ valid: true, errors: [] })), - validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })), - getUniqueColumns: vi.fn(() => []), - checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), - checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), -})) +vi.mock('@/lib/table/rows/live-schema', () => tableRowsLiveSchemaMock) + +vi.mock('@/lib/table/validation', async (importOriginal) => { + const actual = await importOriginal() + return { + cellOf: actual.cellOf, + uniqueValueKey: actual.uniqueValueKey, + validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), + validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), + coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })), + coerceRowValues: vi.fn(), + validateTableName: vi.fn(() => ({ valid: true, errors: [] })), + validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })), + getUniqueColumns: vi.fn(() => []), + checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), + checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), + } +}) /** * Inspects the queued `trx.execute(...)` calls for SQL containing `substring`. @@ -52,31 +59,6 @@ function findExecutedSqlContaining(substring: string): boolean { }) } -/** Every string reachable from a drizzle `sql` fragment — its literal chunks AND its bound values. */ -function collectStrings(node: unknown, out: string[] = []): string[] { - if (typeof node === 'string') out.push(node) - else if (Array.isArray(node)) for (const entry of node) collectStrings(entry, out) - else if (node && typeof node === 'object') - for (const entry of Object.values(node as Record)) collectStrings(entry, out) - return out -} - -/** - * Whether one `set_config(, '', true)` guard was executed. - * - * The guards bind their values as parameters, so the setting name lives in the statement's - * literal chunks while the duration lives in its bound values — asserting on a rendered string - * would only re-check the placeholder. - */ -function executedTxTimeout(setting: string, value: string): boolean { - return dbChainMockFns.execute.mock.calls.some(([arg]) => { - const strings = collectStrings(arg) - return ( - strings.some((entry) => entry.includes(`set_config('${setting}'`)) && strings.includes(value) - ) - }) -} - /** * The `data` payload of the last `.set(...)` row write. `updateRow` always writes a JSONB merge * (`data = data || {changed}::jsonb`), so this is a `sql` fragment exposing `{ strings, values }`. @@ -342,29 +324,6 @@ describe('mutation paths — SET LOCAL timeouts', () => { dbChainMockFns.execute.mockResolvedValue([{ count: 0 }]) }) - it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => { - const bigTable: TableDefinition = { ...TABLE, rowCount: 100_000, maxRows: 1_000_000 } - const payload = Array.from({ length: 50_000 }, (_, i) => ({ name: `row-${i}` })) - - await replaceTableRows( - { tableId: 'tbl-1', workspaceId: 'ws-1', rows: payload }, - bigTable, - 'req-1' - ) - - // (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap - expect(executedTxTimeout('statement_timeout', '450000ms')).toBe(true) - }) - - it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => { - const hugeTable: TableDefinition = { ...TABLE, rowCount: 10_000_000, maxRows: 20_000_000 } - - await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1') - - // 10M × 3ms = 30M ms, capped at 600_000ms (10 min) - expect(executedTxTimeout('statement_timeout', '600000ms')).toBe(true) - }) - it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => { await replaceTableRows( { tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }] }, diff --git a/apps/sim/lib/table/update-runner.test.ts b/apps/sim/lib/table/update-runner.test.ts index 3d5930f68ba..0ff5b940b1b 100644 --- a/apps/sim/lib/table/update-runner.test.ts +++ b/apps/sim/lib/table/update-runner.test.ts @@ -31,11 +31,16 @@ vi.mock('@/lib/table/rows/ordering', () => ({ })) vi.mock('@/lib/table/events', () => tableEventsMock) vi.mock('@/lib/table/sql', () => ({ buildFilterClause: mockBuildFilterClause })) -vi.mock('@/lib/table/validation', () => ({ - validateRowSize: mockValidateRowSize, - coerceRowToSchema: mockCoerceRowToSchema, - coerceRowValues: mockCoerceRowValues, -})) +vi.mock('@/lib/table/validation', async (importOriginal) => { + const actual = await importOriginal() + return { + cellOf: actual.cellOf, + uniqueColumnsInPatch: actual.uniqueColumnsInPatch, + validateRowSize: mockValidateRowSize, + coerceRowToSchema: mockCoerceRowToSchema, + coerceRowValues: mockCoerceRowValues, + } +}) vi.mock('@/lib/table/constants', () => ({ ...tableConstantsMock, TABLE_LIMITS: { ...tableConstantsMock.TABLE_LIMITS, DELETE_PAGE_SIZE: 2, UPDATE_BATCH_SIZE: 100 }, @@ -57,7 +62,12 @@ const UNLOCKED = { updateLocked: false, deleteLocked: false, } -const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] }, locks: UNLOCKED } +const table = { + id: 'tbl_1', + workspaceId: 'ws_1', + schema: { columns: [{ id: 'flag', name: 'flag', type: 'boolean' }] }, + locks: UNLOCKED, +} const cutoff = new Date('2026-06-05T00:00:00Z') function basePayload(overrides = {}) { diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index b3ed3380002..e1a75e84b15 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -1,8 +1,12 @@ +import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import type { Filter, RowData, TableDefinition } from '@/lib/table' +import { and, eq, inArray } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { Filter, RowData, TableDefinition, TableSchema } from '@/lib/table' +import { getColumnId } from '@/lib/table/column-keys' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { @@ -19,11 +23,13 @@ import { TableLockedError, } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' +import { withLiveSchema } from '@/lib/table/rows/live-schema' import { selectRowDataPage, updatePageByIds } from '@/lib/table/rows/ordering' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import { deriveBulkUpdatePatch } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' -import { coerceRowToSchema, coerceRowValues, validateRowSize } from '@/lib/table/validation' +import { coerceRowToSchema, uniqueColumnsInPatch, validateRowSize } from '@/lib/table/validation' const logger = createLogger('TableUpdateRunner') @@ -36,6 +42,88 @@ const PROGRESS_INTERVAL_ROWS = 5000 */ class JobSupersededError extends Error {} +/** + * The patch cannot be applied to the table as it now stands. Retrying cannot help: the schema that + * refused it is the one a retry would read. + */ +export class UpdatePatchRejectedError extends Error { + constructor(message: string) { + super(message) + this.name = 'UpdatePatchRejectedError' + } +} + +/** + * The patch one batch writes against `live`, derived from the job's raw `patch` exactly as the + * inline bulk update derives its own ({@link deriveBulkUpdatePatch}): cells of columns deleted since + * `previous` dropped, the rest coerced and validated. It is refused, with an + * {@link UpdatePatchRejectedError}, when it cannot be written to every matched row: a value the + * columns refuse, a null in a required column, or any unique column, since one value in many rows + * cannot stay unique. Batches derive it under the schema lock, so a column made required or unique + * while the job waited is refused before the batch writes. + */ +function deriveJobPatch(patch: RowData, previous: TableSchema, live: TableDefinition): RowData { + let derived: RowData + try { + derived = deriveBulkUpdatePatch(patch, previous, live, undefined) + } catch (err) { + if (err instanceof OrchestrationError && err.code === 'validation') { + throw new UpdatePatchRejectedError(err.message) + } + throw err + } + const cleared = live.schema.columns.filter((column) => { + const columnId = getColumnId(column) + return ( + column.required && Object.hasOwn(derived, columnId) && (derived[columnId] ?? null) === null + ) + }) + if (cleared.length > 0) { + throw new UpdatePatchRejectedError( + `Missing required field: ${cleared.map((column) => column.name).join(', ')}` + ) + } + const unique = uniqueColumnsInPatch(live.schema, derived) + if (unique.length > 0) { + throw new UpdatePatchRejectedError( + `Cannot set unique column values when updating multiple rows: ${unique.map((column) => column.name).join(', ')}` + ) + } + return derived +} + +/** Refuses a row that `patch`, merged over it, would leave oversized or invalid under `schema`. */ +function assertMergedRowFits( + schema: TableSchema, + row: { id: string; data: RowData }, + patch: RowData +): void { + const merged = { ...row.data, ...patch } + const sizeValidation = validateRowSize(merged) + if (!sizeValidation.valid) { + throw new UpdatePatchRejectedError(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) + } + const schemaValidation = coerceRowToSchema(merged, schema) + if (!schemaValidation.valid) { + throw new UpdatePatchRejectedError(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) + } +} + +/** Reads a batch's rows in its transaction and refuses any `patch` would not fit under `live`. */ +async function validateMergedRows( + trx: DbTransaction, + live: TableDefinition, + rowIds: string[], + patch: RowData +): Promise { + const rows = await trx + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, live.id), inArray(userTableRows.id, rowIds))) + for (const row of rows) + assertMergedRowFits(live.schema, { id: row.id, data: row.data as RowData }, patch) +} + export interface TableUpdatePayload { jobId: string tableId: string @@ -65,7 +153,8 @@ export interface TableUpdatePayload { * the affected columns explicitly afterward if downstream recompute is needed. * * Unexpected errors are rethrown for the caller's retry machinery; the caller marks the job - * failed via `markTableUpdateFailed`. A superseded run returns quietly. + * failed via `markTableUpdateFailed`. An {@link UpdatePatchRejectedError} is rethrown too, and is + * not worth a retry. A superseded run returns quietly. */ export async function runTableUpdate(payload: TableUpdatePayload): Promise { const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = payload @@ -109,7 +198,8 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise if ((await stopIfLocked(table, 0)) === null) return // Runs inside each batch's transaction, under the same advisory lock the - // lock toggle holds, so no page can be written after a lock commits. + // lock toggle and schema changes hold, so no page can be written after a + // lock commits, nor under a schema that would not store the patch. const revalidate = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertRowUpdate(fresh, patchColumnIds(data)) @@ -119,10 +209,10 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) if (!filterClause) throw new Error('Filter is required for bulk update') - // Coerce the patch once to the schema's types — the merged validation below and the persisted - // JSONB merge both use this normalized copy. - coerceRowValues(data, table.schema) - const patchJson = JSON.stringify(data) + // `data` stays the raw payload. Each page, and each batch under the schema lock, derives the + // patch it writes from it against the schema of that moment, as the inline update would. Derive + // it once up front too, so a patch the table refuses fails before any page is read. + deriveJobPatch(data, table.schema, table) // Resume the persisted count: a retried attempt's earlier pages are already committed, so // starting at zero would overwrite cumulative progress. Doubles as the initial ownership gate. @@ -145,6 +235,10 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise if (!current) throw new JobSupersededError() const pageProof = await stopIfLocked(current, processed) if (pageProof === null) return + const pagePatch = deriveJobPatch(data, table.schema, current) + // Every column the update wrote has since been deleted: nothing is left to write. + if (Object.keys(pagePatch).length === 0) break + const patchJson = JSON.stringify(pagePatch) const page = await selectRowDataPage({ tableId, @@ -163,25 +257,25 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise // Validate each merged result before writing the page — a row that would overflow the size // cap or violate the schema fails the job (earlier pages stay applied; best-effort). - for (const row of page) { - const merged = { ...row.data, ...data } - const sizeValidation = validateRowSize(merged) - if (!sizeValidation.valid) { - throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) - } - const schemaValidation = coerceRowToSchema(merged, table.schema) - if (!schemaValidation.valid) { - throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) - } - } + for (const row of page) assertMergedRowFits(current.schema, row, pagePatch) try { processed += await updatePageByIds( tableId, workspaceId, page.map((r) => r.id), - patchJson, - createExactEmptyTableRowSecretProvenance(data), + async (trx, fresh, batch) => { + const live = fresh ? withLiveSchema(current, fresh.schema) : current + const batchPatch = deriveJobPatch(data, table.schema, live) + if (Object.keys(batchPatch).length === 0) return null + // The page was checked against `current`; a batch under a schema that moved since is + // checked again, against the rows as they stand, before it writes. + if (live !== current) await validateMergedRows(trx, live, batch, batchPatch) + return { + patchJson: JSON.stringify(batchPatch), + secretProvenance: createExactEmptyTableRowSecretProvenance(batchPatch), + } + }, pageProof, revalidate ) diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index cbe13d617f8..29bd9157078 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -27,7 +27,7 @@ import { } from '@/lib/table/constants' import { withSeqscanOff } from '@/lib/table/planner' import { resolveSelectOptionId, splitMultiSelectInput } from '@/lib/table/select-options' -import { fieldPredicate } from '@/lib/table/sql' +import { uniqueValuePredicate } from '@/lib/table/sql' import type { ColumnDefinition, JsonValue, @@ -252,12 +252,20 @@ export function validateTableSchema(schema: TableSchema): ValidationResult { return { valid: errors.length === 0, errors } } +/** + * The cell `row` holds for `columnId`, read as an own property: a legacy column keyed by its name + * can be called `constructor`, which a plain index would find on every row's prototype. + */ +export function cellOf(row: RowData, columnId: string): JsonValue | undefined { + return Object.hasOwn(row, columnId) ? row[columnId] : undefined +} + /** Validates row data matches schema column types and required fields. */ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): ValidationResult { const errors: string[] = [] for (const column of schema.columns) { - const value = data[getColumnId(column)] + const value = cellOf(data, getColumnId(column)) if (column.required && (value === undefined || value === null)) { errors.push(`Missing required field: ${column.name}`) @@ -348,7 +356,7 @@ export function coerceRowValues( const policyFor = policyResolver(policy, patchedKeys) for (const column of schema.columns) { const key = getColumnId(column) - const value = data[key] + const value = cellOf(data, key) if (value === null || value === undefined) continue const coerced = coerceValueToColumnType(value, column) @@ -407,6 +415,14 @@ export function getUniqueColumns(schema: TableSchema): ColumnDefinition[] { return schema.columns.filter((col) => col.unique === true) } +/** + * The unique columns `patch` writes. A patch applied to more than one row cannot write any of them + * without storing a duplicate. + */ +export function uniqueColumnsInPatch(schema: TableSchema, patch: RowData): ColumnDefinition[] { + return getUniqueColumns(schema).filter((column) => Object.hasOwn(patch, getColumnId(column))) +} + /** * The key two unique-column values share when the unique check treats them as equal. Object keys * are sorted, since the check compares JSONB, where key order carries no meaning. In-batch @@ -428,13 +444,13 @@ export function validateUniqueConstraints( for (const column of uniqueColumns) { const key = getColumnId(column) - const value = data[key] + const value = cellOf(data, key) if (value === null || value === undefined) continue const duplicate = existingRows.find((row) => { if (excludeRowId && row.id === excludeRowId) return false // Case-sensitive, matching the DB unique-check leaf (`fieldPredicate` eq). - const existing = row.data[key] + const existing = cellOf(row.data, key) return ( existing !== undefined && columnValueForEquality(value, column) === columnValueForEquality(existing, column) @@ -481,13 +497,14 @@ export async function checkUniqueConstraintsDb( for (const column of uniqueColumns) { const key = getColumnId(column) - const value = data[key] + const value = cellOf(data, key) if (value === null || value === undefined) continue - // Same leaf as the upsert conflict probe → case-sensitive JSONB containment - // (GIN-indexed). `eq` always yields a clause for a non-null value. - const clause = fieldPredicate(USER_TABLE_ROWS_SQL_NAME, key, 'eq', value, column) - if (clause) conditions.push({ column, value, sql: clause }) + conditions.push({ + column, + value, + sql: uniqueValuePredicate(USER_TABLE_ROWS_SQL_NAME, key, value, column), + }) } if (conditions.length === 0) { @@ -495,7 +512,7 @@ export async function checkUniqueConstraintsDb( } // Query for each unique column separately to provide specific error messages. - // The predicate is now case-sensitive JSONB containment (`data @> {...}`), + // The predicate leads with case-sensitive JSONB containment (`data @> {...}`), // which can use the GIN index. We still pin `enable_seqscan = off` (tenant- // bounded) defensively for the small-table / cold-stats case. With an external // transaction the flag is set on it for the check only (see withSeqscanOffOn) @@ -600,7 +617,7 @@ export async function checkBatchUniqueConstraintsDb( for (const column of uniqueColumns) { const key = getColumnId(column) - const value = rowData[key] + const value = cellOf(rowData, key) if (value === null || value === undefined) continue const normalizedValue = uniqueValueKey(value, column) @@ -645,18 +662,7 @@ export async function checkBatchUniqueConstraintsDb( // string — a unique `date` column normalized to a bare `2024-01-01` // and then threw `SyntaxError` trying to parse it back. const originalValue: JsonValue = JSON.parse(normalizedValue) - // Same case-sensitive containment leaf as every other matcher. - const clause = fieldPredicate( - USER_TABLE_ROWS_SQL_NAME, - columnId, - 'eq', - originalValue, - column - ) - if (!clause) { - throw new Error(`Failed to build unique-constraint predicate for column "${column.name}"`) - } - return clause + return uniqueValuePredicate(USER_TABLE_ROWS_SQL_NAME, columnId, originalValue, column) }) const conflictingRows = await ex @@ -672,22 +678,17 @@ export async function checkBatchUniqueConstraintsDb( // Map conflicts back to batch rows for (const conflict of conflictingRows) { const conflictData = conflict.data as RowData - const conflictValue = columnValueForEquality(conflictData[columnId], column) - const normalizedConflictValue = - typeof conflictValue === 'string' ? conflictValue : JSON.stringify(conflictValue) + const conflictValue = cellOf(conflictData, columnId) + if (conflictValue === null || conflictValue === undefined) continue + // Keyed like the batch, since stored jsonb comes back with its keys reordered. + const normalizedConflictValue = uniqueValueKey(conflictValue, column) // Find which batch rows have this conflicting value for (let i = 0; i < rows.length; i++) { - const rowValue = rows[i][columnId] + const rowValue = cellOf(rows[i], columnId) if (rowValue === null || rowValue === undefined) continue - const comparableRowValue = columnValueForEquality(rowValue, column) - const normalizedRowValue = - typeof comparableRowValue === 'string' - ? comparableRowValue - : JSON.stringify(comparableRowValue) - - if (normalizedRowValue === normalizedConflictValue) { + if (uniqueValueKey(rowValue, column) === normalizedConflictValue) { // Check if this row already has errors for this column let rowError = rowErrors.find((e) => e.row === i) if (!rowError) { diff --git a/packages/db/script-migrations-paused-billing-attribution.test.ts b/packages/db/script-migrations-paused-billing-attribution.test.ts index f7901d6c65a..131364665ac 100644 --- a/packages/db/script-migrations-paused-billing-attribution.test.ts +++ b/packages/db/script-migrations-paused-billing-attribution.test.ts @@ -378,6 +378,7 @@ describe('script migration registry', () => { '0023_projection_acl_skip_unfilled', '0024_knowledge_projection_async', '0025_scope_keyword_projections', + '0026_user_table_schema_for_write', ]) }) }) diff --git a/packages/db/script-migrations/0016_backfill_search_vectors.integration.ts b/packages/db/script-migrations/0016_backfill_search_vectors.integration.ts index d9972d14d3d..fc9cc6f741c 100644 --- a/packages/db/script-migrations/0016_backfill_search_vectors.integration.ts +++ b/packages/db/script-migrations/0016_backfill_search_vectors.integration.ts @@ -396,6 +396,7 @@ describe('search projection upgrade in PostgreSQL', () => { { name: '0023_projection_acl_skip_unfilled' }, { name: '0024_knowledge_projection_async' }, { name: '0025_scope_keyword_projections' }, + { name: '0026_user_table_schema_for_write' }, ]) const [{ complete }] = await sql`SELECT count(*)::int AS complete FROM embedding e JOIN embedding_search s ON s.id = e.id JOIN embedding_keyword_search k ON k.id = e.id diff --git a/packages/db/script-migrations/0026_user_table_schema_for_write.ts b/packages/db/script-migrations/0026_user_table_schema_for_write.ts new file mode 100644 index 00000000000..d651e9da730 --- /dev/null +++ b/packages/db/script-migrations/0026_user_table_schema_for_write.ts @@ -0,0 +1,66 @@ +import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url' +import type { ScriptMigration } from '@sim/db/script-migrations/types' +import postgres, { type Sql } from 'postgres' + +/** + * Installs `user_table_schema_for_write(table_id)`, which takes a table's schema lock shared and + * returns the schema a row write must validate against. Every row write in `apps/sim` calls it, so + * it is a script migration: `db:push` builds a database from `schema.ts` and the script migrations + * it lists, never from the SQL migrations. + * + * Schema changes hold the advisory lock `user_table_schema:` exclusively while they + * check stored rows against the new schema (`withLockedTable`), so a row write holds it shared + * for its whole transaction: a change waits for writes in flight, and a write that waited must + * validate against the change it waited for. The key and hash match `acquireAdvisoryXactLock`. + * + * The function must stay VOLATILE. Under READ COMMITTED a volatile function takes a fresh + * snapshot for each query it runs, so the read below sees a schema change that committed while + * the lock waited, even though the calling statement's snapshot predates it. A STABLE function + * would read through the caller's snapshot and return the schema from before the wait. Folding + * the lock and the read into one call lets a writer take both inside a statement it already runs. + * + * The schema lock waits under the caller's `statement_timeout`, not its `lock_timeout`. A schema + * change (a retype, a constraint scan, an import) can hold the lock far longer than the short + * `lock_timeout` a writer sets for its row locks, and failing an edit there turns an ordinary wait + * into an error; a writer that set no timeouts keeps waiting as it did before the guard existed. + * The value is read here rather than left to `statement_timeout` itself because a writer applies + * its timeouts in the same statement that calls this function, and a statement's own timeout is + * armed before it runs. The caller's `lock_timeout` is restored before the function returns, so the + * rest of the transaction's locks keep their bound. + * + * Returns NULL when the table does not exist. `CREATE OR REPLACE` makes it safe to run again. + */ +export async function installUserTableSchemaForWrite(sql: Sql): Promise { + await sql.unsafe(`CREATE OR REPLACE FUNCTION "public"."user_table_schema_for_write"(p_table_id text) +RETURNS jsonb +LANGUAGE plpgsql +VOLATILE +SET search_path = pg_catalog, public +AS $$ +DECLARE + caller_lock_timeout text := current_setting('lock_timeout'); +BEGIN + PERFORM set_config('lock_timeout', current_setting('statement_timeout'), true); + PERFORM pg_advisory_xact_lock_shared(hashtextextended('user_table_schema:' || p_table_id, 0)); + PERFORM set_config('lock_timeout', caller_lock_timeout, true); + RETURN (SELECT "schema" FROM "public"."user_table_definitions" WHERE "id" = p_table_id); +END; +$$`) +} + +export const userTableSchemaForWriteMigration: ScriptMigration = { + name: '0026_user_table_schema_for_write', + up: installUserTableSchemaForWrite, +} + +/** Run directly by `db:push`. */ +if (import.meta.main) { + const url = resolveMigrationDatabaseUrl() + if (!url) throw new Error('DATABASE_URL is required to install user_table_schema_for_write') + const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined }) + try { + await installUserTableSchemaForWrite(sql) + } finally { + await sql.end() + } +} diff --git a/packages/db/script-migrations/index.ts b/packages/db/script-migrations/index.ts index b469e505669..a98a4cfcd6e 100644 --- a/packages/db/script-migrations/index.ts +++ b/packages/db/script-migrations/index.ts @@ -9,6 +9,7 @@ import { projectionSourceAclBackfillMigration } from '@sim/db/script-migrations/ import { projectionAclSkipUnfilledMigration } from '@sim/db/script-migrations/0023_projection_acl_skip_unfilled' import { knowledgeProjectionAsyncMigration } from '@sim/db/script-migrations/0024_knowledge_projection_async' import { scopeKeywordProjectionsMigration } from '@sim/db/script-migrations/0025_scope_keyword_projections' +import { userTableSchemaForWriteMigration } from '@sim/db/script-migrations/0026_user_table_schema_for_write' import type { Sql } from 'postgres' import { backfillTableOrderKeys } from './0001_backfill_table_order_keys' import { backfillPausedBillingAttribution } from './0002_backfill_paused_billing_attribution' @@ -55,6 +56,8 @@ export const scriptMigrations: readonly ScriptMigration[] = [ knowledgeProjectionAsyncMigration, /** 0025 keeps the keyword projections for search indexes only. */ scopeKeywordProjectionsMigration, + /** 0026 installs the schema guard every table row write takes before it validates. */ + userTableSchemaForWriteMigration, ] /** diff --git a/packages/db/scripts/push.ts b/packages/db/scripts/push.ts index 90b2288954c..2d10c1c4e0e 100644 --- a/packages/db/scripts/push.ts +++ b/packages/db/scripts/push.ts @@ -9,6 +9,7 @@ const RECONCILIATION_COMMANDS = [ ['bun', '--env-file=.env', 'run', './script-migrations/0021_embedding_search_connector.ts'], ['bun', '--env-file=.env', 'run', './script-migrations/0024_knowledge_projection_async.ts'], ['bun', '--env-file=.env', 'run', './script-migrations/0025_scope_keyword_projections.ts'], + ['bun', '--env-file=.env', 'run', './script-migrations/0026_user_table_schema_for_write.ts'], ] /** diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index c118f2c6922..1f408765f71 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -807,6 +807,10 @@ export { tableRouteUtilsMock, tableRouteUtilsMockFns, } from './table-route-utils.mock' +export { + tableRowsLiveSchemaMock, + tableRowsLiveSchemaMockFns, +} from './table-rows-live-schema.mock' export { MockTableRowProvenanceReader, tableRowsSecretProvenanceMock, diff --git a/packages/testing/src/mocks/table-rows-live-schema.mock.ts b/packages/testing/src/mocks/table-rows-live-schema.mock.ts new file mode 100644 index 00000000000..68cfb34c9df --- /dev/null +++ b/packages/testing/src/mocks/table-rows-live-schema.mock.ts @@ -0,0 +1,37 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/table/rows/live-schema`. + * + * Defaults: `lockLiveTableSchema` and `withLiveSchema` return the definition they are handed, as + * when its schema is still current, so no refit runs; `dropDeletedColumns` is a no-op and + * `refitRowToSchema` reports a valid row. + * + * @example + * ```ts + * import { tableRowsLiveSchemaMockFns } from '@sim/testing/mocks/table-rows-live-schema.mock' + * + * tableRowsLiveSchemaMockFns.mockLockLiveTableSchema.mockResolvedValueOnce(liveTable) + * ``` + */ +export const tableRowsLiveSchemaMockFns = { + mockLockLiveTableSchema: vi.fn(async (_trx: unknown, table: T): Promise => table), + mockWithLiveSchema: vi.fn((table: T): T => table), + mockDropDeletedColumns: vi.fn((..._args: unknown[]): void => {}), + mockRefitRowToSchema: vi.fn((..._args: unknown[]) => ({ valid: true, errors: [] as string[] })), +} + +/** + * Static mock module for `@/lib/table/rows/live-schema`. + * + * @example + * ```ts + * vi.mock('@/lib/table/rows/live-schema', () => tableRowsLiveSchemaMock) + * ``` + */ +export const tableRowsLiveSchemaMock = { + lockLiveTableSchema: tableRowsLiveSchemaMockFns.mockLockLiveTableSchema, + withLiveSchema: tableRowsLiveSchemaMockFns.mockWithLiveSchema, + dropDeletedColumns: tableRowsLiveSchemaMockFns.mockDropDeletedColumns, + refitRowToSchema: tableRowsLiveSchemaMockFns.mockRefitRowToSchema, +} From 46e9b5be9234d0407b26807a19f7804474c4a2ab Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 28 Sep 2026 19:56:10 -0700 Subject: [PATCH 04/22] fix(selectors): rebind Copilot principals for nested domain use cases (#8397) * fix(selectors): rebind Copilot principals for nested domain use cases Selectors backed by another domain (knowledge documents, table columns, workflows, sandboxes, MCP tools) forwarded the Copilot principal admitted under the selector audience into use cases that accept only their own audience. The refusal is a DelegatedWorkspaceAuthorizationError, which the v2 surface conceals as 404, so Chat saw "Workspace not found" on workspaces the user administers. Fork sync previews hit it whenever a saved dependent value (a table conflict column, a knowledge document) needed validation. Selectors now rebind through bindCopilotWorkspaceOperation, which can also project the single resource a nested call reaches, as the executor and Chat MCP paths do when they mint. A grant already narrowed to one resource is never moved to another. Managed MCP connections now reach their credential-group rule and return its 403 instead of the concealed 404. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(selectors): scope table selector reads to the selected table Tables honor resourceScope.tableId, so the nested read names the table it reads, as the MCP selector names its server or connection. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../copilot-workspace-invocation.test.ts | 25 +++- .../copilot-workspace-invocation.ts | 43 ++++++- .../copilot-nested-selectors.integration.ts | 114 ++++++++++++++++++ .../selectors/application/execute-selector.ts | 9 +- .../lib/selectors/application/operations.ts | 2 + apps/sim/lib/selectors/server/internal.ts | 47 ++++++-- .../sim/lib/selectors/server/providers/mcp.ts | 15 ++- apps/sim/lib/selectors/server/types.ts | 28 +++++ .../__integration__/fork-sync.integration.ts | 96 +++++++++++++++ 9 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts index e67ebd571a2..81c615dd918 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { markCopilotWorkspaceInvocation } from '@/lib/core/application/copilot-workspace-invocation' +import { + bindCopilotWorkspaceOperation, + markCopilotWorkspaceInvocation, +} from '@/lib/core/application/copilot-workspace-invocation' +import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' import { tableDelegationPolicy } from '@/lib/table/application/authorization' @@ -45,3 +49,22 @@ describe('private table workspace invocation', () => { ) }) }) +describe('nested workspace operation binding', () => { + it('never moves a grant already narrowed to one resource onto another', () => { + const caller = createCopilotChatPrincipal( + { userId: 'actor', workspaceId: 'workspace', chatId: 'chat' }, + 'sim:selectors', + { credentialId: 'mcp-cg-granted' } + ) + markCopilotWorkspaceInvocation(caller) + expect(() => + bindCopilotWorkspaceOperation( + caller, + 'workspace', + ['sim:selectors'], + { delegationAudience: MANAGED_MCP_DELEGATION_AUDIENCE }, + { credentialId: 'mcp-cg-other' } + ) + ).toThrow(/resource scope/) + }) +}) diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.ts index 3fb3a1fcd07..8d023187323 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.ts @@ -25,12 +25,43 @@ export function isCopilotWorkspaceInvocation(principal: DelegatedPrincipal): boo ) } -/** Nested domain reads keep the admitted actor, resource restrictions, workspace, and expiry. */ +const NESTED_RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'credentialId', 'mcpServerId'] as const + +export type NestedResourceScope = Partial< + Record<(typeof NESTED_RESOURCE_SCOPE_KEYS)[number], string> +> + +/** Adds the nested target to the admitted scope; a key already granted never moves to another resource. */ +function narrowResourceScope( + granted: DelegatedPrincipal['resourceScope'], + target: NestedResourceScope +): NonNullable { + const scope = { ...granted } + for (const key of NESTED_RESOURCE_SCOPE_KEYS) { + const value = target[key] + if (value === undefined) continue + if (!value.trim() || (scope[key] !== undefined && scope[key] !== value)) { + throw new OrchestrationError( + 'forbidden', + 'Nested operation cannot move its delegated resource scope' + ) + } + scope[key] = value + } + return Object.freeze(scope) +} + +/** + * Nested domain reads keep the admitted actor, resource restrictions, workspace, and expiry. + * A nested operation bound to one resource names it in `resourceScope`, as the executor and + * Chat tool paths do when they mint a principal for that resource. + */ export function bindCopilotWorkspaceOperation

( principal: P, workspaceId: string, sourceAudiences: readonly string[], - useCase: Pick, 'delegationAudience'> + useCase: Pick, 'delegationAudience'>, + resourceScope?: NestedResourceScope ): P { if (principal.kind !== 'delegated' || principal.serviceId !== 'copilot') return principal if ( @@ -44,7 +75,13 @@ export function bindCopilotWorkspaceOperation

( 'Nested operation requires the current workspace invocation' ) } - const derived = { ...principal, audience: useCase.delegationAudience } + const derived = { + ...principal, + audience: useCase.delegationAudience, + ...(resourceScope + ? { resourceScope: narrowResourceScope(principal.resourceScope, resourceScope) } + : {}), + } if (derived.kind === 'delegated') markCopilotWorkspaceInvocation(derived) return derived } diff --git a/apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts b/apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts new file mode 100644 index 00000000000..8a8251b4165 --- /dev/null +++ b/apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts @@ -0,0 +1,114 @@ +/** Chat's in-process CLI reaching selectors whose options are owned by another domain. */ + +import { db } from '@sim/db' +import { + credential, + credentialGroupEnrollment, + mcpServers, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' +import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' +import { encryptManagedMcpTokens } from '@/lib/credentials/managed-mcp' +import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' + +const ORIGIN = 'http://localhost:3000' +const userId = generateId() +const workspaceId = generateId() +const connectionId = generateManagedMcpConnectionId() + +function listSelector(selectorKey: string, context: Record) { + const transport = createScopedCliTransport(ORIGIN, { userId, workspaceId, chatId: generateId() }) + return withWorkspaceInvocationScope({ workspaceId }, () => + transport(`${ORIGIN}/api/v2/selectors/list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId, selectorKey, context }), + }) + ) +} + +describe('Copilot selectors backed by another domain', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Selector fixture', + email: `${userId}@selector.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Selector fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const group = await ensureWorkspaceAccountsGroup(workspaceId, userId) + const serverId = generateId() + await db.insert(mcpServers).values({ + id: serverId, + workspaceId, + credentialGroupId: group.id, + managedConnectorId: 'notion', + name: 'Notion', + transport: 'streamable-http', + url: 'https://mcp.notion.com/mcp', + authType: 'oauth', + createdBy: userId, + }) + const enrollmentId = generateId() + await db.insert(credentialGroupEnrollment).values({ + id: enrollmentId, + credentialGroupId: group.id, + userId, + email: `${userId}@selector.test`, + status: 'completed', + invitationTokenHash: sha256Hex(generateId()), + invitationExpiresAt: new Date(Date.now() + 60_000), + invitedAt: now, + }) + await db.insert(credential).values({ + id: connectionId, + workspaceId, + type: 'managed_mcp', + displayName: 'Notion', + grantedAt: now, + mcpTools: [], + credentialGroupEnrollmentId: enrollmentId, + mcpServerId: serverId, + managedOauthStatus: 'active', + encryptedOauthTokenSet: await encryptManagedMcpTokens({ + access_token: 'fixture-access', + token_type: 'Bearer', + }), + }) + }) + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + it('decides a managed MCP connection by its credential-group rule instead of concealing it', async () => { + const response = await listSelector('mcp.tools', { mcpServerId: connectionId }) + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(403) + expect(body.error.message).toBe('Credential Group credential access denied') + }) +}) diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index d2d7a29eeb9..df881f217fb 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -12,7 +12,10 @@ import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.ser import { OrchestrationError } from '@/lib/core/orchestration/types' import { authorizePersonalSearchSetup } from '@/lib/knowledge/application/personal-search-account' import { type CredentialAuditRequest, recordCredentialAccess } from '@/lib/oauth/token-resolution' -import { selectorOperations } from '@/lib/selectors/application/operations' +import { + SELECTOR_DELEGATION_AUDIENCE, + selectorOperations, +} from '@/lib/selectors/application/operations' import { resolveSelectorApplicationContext, type SelectorApplicationContext, @@ -327,7 +330,9 @@ const executeWorkspaceSelector = defineAuthorizedWorkspaceUseCase< if (context.workspaceId === undefined) throw new SelectorContextUnavailableError() return context }, - authorizationOptions: { delegation: { audience: 'sim:selectors', isWithinScope: () => true } }, + authorizationOptions: { + delegation: { audience: SELECTOR_DELEGATION_AUDIENCE, isWithinScope: () => true }, + }, authorizeResource: ({ input, context }) => validateAuthorizedInput(input, context), execute: executeAuthorizedSelector, }) diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index 8377443d14a..8594705d022 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -1,5 +1,7 @@ import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +export const SELECTOR_DELEGATION_AUDIENCE = 'sim:selectors' + export const selectorOperations = { // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a selector reaches. That decision is enforced from the use case by assertSelectorIntegrationAllowed, against the selector's own resource, ahead of the provider call. execute: defineWorkspaceOperation({ diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index 446d1bdf236..887e8019195 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -22,6 +22,8 @@ import { detailSelectorResult, type ExecuteServerSelectorArgs, listSelectorResult, + nestedSelectorPrincipal, + type SelectorPrincipal, type ServerSelectorAttachmentMap, } from '@/lib/selectors/server/types' import { readTableUseCase } from '@/lib/table/application/tables' @@ -45,17 +47,14 @@ function labelWorkflow( return `${base} (${folder})` } -async function loadWorkflows( - args: Parameters<(typeof listWorkflows)['execute']>[0]['principal'], - workspaceId: string -) { +async function loadWorkflows(principal: SelectorPrincipal, workspaceId: string) { const workflows: Array< Awaited>['workflows'][number] > = [] let cursorKeys: Awaited>['nextCursorKeys'] = null for (let page = 0; page < MAX_WORKFLOW_PAGES; page += 1) { const result = await listWorkflows.execute({ - principal: args, + principal: nestedSelectorPrincipal(principal, workspaceId, listWorkflows), input: { workspaceId, scope: 'active', @@ -82,7 +81,11 @@ export const internalSelectorAttachments = { const knowledgeBaseId = args.context.knowledgeBaseId! if (args.request.kind === 'detail') { const result = await readKnowledgeDocument.execute({ - principal: args.principal, + principal: nestedSelectorPrincipal( + args.principal, + args.workspaceId, + readKnowledgeDocument + ), input: { knowledgeBaseId, documentId: args.request.id, @@ -98,7 +101,11 @@ export const internalSelectorAttachments = { const offset = args.request.cursor ? Number(args.request.cursor) : 0 if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid selector cursor') const result = await listKnowledgeDocuments.execute({ - principal: args.principal, + principal: nestedSelectorPrincipal( + args.principal, + args.workspaceId, + listKnowledgeDocuments + ), input: { knowledgeBaseId, assertedWorkspaceId: args.workspaceId, @@ -150,9 +157,12 @@ export const internalSelectorAttachments = { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { if (!args.workspaceId) throw new SelectorContextUnavailableError() + const tableId = args.context.tableId! const { table } = await readTableUseCase.execute({ - principal: args.principal, - input: { tableId: args.context.tableId!, workspaceId: args.workspaceId }, + principal: nestedSelectorPrincipal(args.principal, args.workspaceId, readTableUseCase, { + tableId, + }), + input: { tableId, workspaceId: args.workspaceId }, }) const options = (table.schema?.columns ?? []) .filter((column) => column.unique) @@ -168,9 +178,12 @@ export const internalSelectorAttachments = { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { if (!args.workspaceId) throw new SelectorContextUnavailableError() + const tableId = args.context.tableId! const { table } = await readTableUseCase.execute({ - principal: args.principal, - input: { tableId: args.context.tableId!, workspaceId: args.workspaceId }, + principal: nestedSelectorPrincipal(args.principal, args.workspaceId, readTableUseCase, { + tableId, + }), + input: { tableId, workspaceId: args.workspaceId }, }) const options = (table.schema?.columns ?? []).map((column) => ({ id: getColumnId(column), @@ -291,7 +304,11 @@ export const internalSelectorAttachments = { if (args.request.kind === 'detail') { const result = await getWorkspaceSandboxUseCase .execute({ - principal: args.principal, + principal: nestedSelectorPrincipal( + args.principal, + args.workspaceId, + getWorkspaceSandboxUseCase + ), input: { workspaceId: args.workspaceId, sandboxId: args.request.id }, }) .catch((error: unknown) => { @@ -308,7 +325,11 @@ export const internalSelectorAttachments = { }) } const { sandboxes, nextCursorKeys } = await listWorkspaceSandboxesUseCase.execute({ - principal: args.principal, + principal: nestedSelectorPrincipal( + args.principal, + args.workspaceId, + listWorkspaceSandboxesUseCase + ), input: { workspaceId: args.workspaceId, limit: 1000 }, }) if (nextCursorKeys) throw new SelectorOptionsUnavailableError() diff --git a/apps/sim/lib/selectors/server/providers/mcp.ts b/apps/sim/lib/selectors/server/providers/mcp.ts index 7852d84372c..9d9a7392d3b 100644 --- a/apps/sim/lib/selectors/server/providers/mcp.ts +++ b/apps/sim/lib/selectors/server/providers/mcp.ts @@ -6,6 +6,7 @@ import { definePreparedSelectorAttachment, detailSelectorResult, listSelectorResult, + nestedSelectorPrincipal, } from '@/lib/selectors/server/types' /** The existing MCP use case binds the destination and credentials to an authorized server. */ @@ -25,7 +26,12 @@ export const mcpSelectorAttachments = { if (!isManagedMcpConnectionId(serverId)) throw new OrchestrationError('validation', 'Invalid managed MCP connection ID') return discoverManagedMcpToolsUseCase.execute({ - principal: args.principal, + principal: nestedSelectorPrincipal( + args.principal, + args.workspaceId, + discoverManagedMcpToolsUseCase, + { credentialId: serverId } + ), input: { workspaceId: args.workspaceId, credentialId: serverId, @@ -34,7 +40,12 @@ export const mcpSelectorAttachments = { }) } return discoverMcpServerToolsUseCase.execute({ - principal: args.principal, + principal: nestedSelectorPrincipal( + args.principal, + args.workspaceId, + discoverMcpServerToolsUseCase, + { mcpServerId: serverId } + ), input: { workspaceId: args.workspaceId, serverId, diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 0ac2d246512..113ac2e2174 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -1,5 +1,11 @@ import type { Principal, SessionPrincipal } from '@sim/auth/principal' import type { CredentialAccessResult } from '@/lib/auth/credential-access' +import { + bindCopilotWorkspaceOperation, + type NestedResourceScope, +} from '@/lib/core/application/copilot-workspace-invocation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation' +import { SELECTOR_DELEGATION_AUDIENCE } from '@/lib/selectors/application/operations' import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' import type { SelectorKey, ServerSelectorKey } from '@/lib/selectors/manifest' import type { @@ -14,6 +20,28 @@ export type SelectorPrincipal = | Extract | (Extract & { serviceId: 'copilot' }) +/** + * The principal a selector hands to another domain's use case. A Copilot + * invocation is admitted under the selector audience, which no other domain + * accepts, so it is re-bound to the nested use case's own audience for the same + * workspace, naming the one resource it reaches when that domain scopes its + * delegations. Every other principal passes through unchanged. + */ +export function nestedSelectorPrincipal

( + principal: P, + workspaceId: string, + useCase: Pick, 'delegationAudience'>, + resourceScope?: NestedResourceScope +): P { + return bindCopilotWorkspaceOperation( + principal, + workspaceId, + [SELECTOR_DELEGATION_AUDIENCE], + useCase, + resourceScope + ) +} + export type SelectorDestinationPolicy = 'fixed' | 'credential-bound' | 'user-controlled' export type SelectorProtectedValueKind = 'secret' | 'reference' diff --git a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts index 7768e5148e2..13118cd4e0b 100644 --- a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts +++ b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts @@ -4,6 +4,7 @@ import { outboxEvent, permissions, user, + userTableDefinitions, workflow, workflowBlocks, workflowDeploymentOperation, @@ -16,7 +17,9 @@ import { import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { processOutboxEventById } from '@/lib/core/outbox/service' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' import { admitWorkflowState, saveAdmittedWorkflowState } from '@/lib/workflows/persistence/utils' import { getWorkspaceOperation } from '@/lib/workspaces/operations/application' @@ -517,6 +520,99 @@ describe('authorized fork and sync against PostgreSQL', () => { } }) + it('validates a mapped table dependent through a Copilot CLI push preview', async () => { + const childId = await createChild() + const [sourceVersion] = await db + .select() + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, sourceWorkflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + const sourceTableId = generateId() + const childTableId = generateId() + const schema = { columns: [{ id: 'col_key', name: 'key', type: 'string', unique: true }] } + await db.insert(userTableDefinitions).values([ + { + id: sourceTableId, + workspaceId: sourceWorkspaceId, + name: `Source table ${sourceTableId}`, + schema, + createdBy: userId, + }, + { id: childTableId, workspaceId: childId, name: 'Child table', schema, createdBy: userId }, + ]) + const sourceState = structuredClone(sourceVersion.state) as WorkflowState + sourceState.blocks.upsert = { + id: 'upsert', + type: 'table', + name: 'Upsert', + enabled: true, + position: { x: 400, y: 0 }, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'upsert_row' }, + tableSelector: { id: 'tableSelector', type: 'table-selector', value: sourceTableId }, + conflictColumnSelector: { + id: 'conflictColumnSelector', + type: 'column-selector', + value: 'col_key', + }, + }, + outputs: {}, + } + const transport = createScopedCliTransport('http://localhost:3000', { + userId, + workspaceId: sourceWorkspaceId, + chatId: generateId(), + }) + try { + await db + .update(workflowDeploymentVersion) + .set({ state: sourceState }) + .where(eq(workflowDeploymentVersion.id, sourceVersion.id)) + const response = await withWorkspaceInvocationScope({ workspaceId: sourceWorkspaceId }, () => + transport( + `http://localhost:3000/api/v2/workspaces/${sourceWorkspaceId}/fork/push/preview`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + otherWorkspaceId: childId, + mappings: [ + { resourceType: 'table', sourceId: sourceTableId, targetId: childTableId }, + ], + dependentValues: [ + { + sourceWorkflowId, + sourceBlockId: 'upsert', + subBlockKey: 'conflictColumnSelector', + value: 'col_key', + }, + ], + }), + } + ) + ) + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(200) + expect(body.data.configuration).toContainEqual( + expect.objectContaining({ + sourceBlockId: 'upsert', + subBlockKey: 'conflictColumnSelector', + currentValue: 'col_key', + discoveryWorkspaceId: childId, + }) + ) + } finally { + await db + .update(workflowDeploymentVersion) + .set({ state: sourceVersion.state }) + .where(eq(workflowDeploymentVersion.id, sourceVersion.id)) + } + }) + it('refuses inherited folder locks with no committed mutation', async () => { const childId = await createChild() const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) From 52cc796aa68aaf521f403b5c3a6f817ab68e7948 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 19:59:17 -0700 Subject: [PATCH 05/22] fix(ui): clean up stale workflow state and provider policy handling (#8400) * fix(ui): clean up stale workflow state and provider policy handling * chore(tests): address cleanup review findings --- .../components/chat/chat.test.tsx | 125 ++++++ .../w/[workflowId]/components/chat/chat.tsx | 32 +- .../components/user-input/constants.ts | 225 ----------- .../components/user-input/hooks/index.ts | 1 - .../user-input/hooks/use-mention-data.ts | 365 ------------------ .../user-input/hooks/use-mention-menu.ts | 3 +- .../copilot/components/user-input/utils.ts | 30 -- .../components/block/block.tsx | 7 + .../components/group-detail.test.tsx | 161 ++++++++ .../components/group-detail.tsx | 115 +++--- apps/sim/hooks/queries/allowed-providers.ts | 18 +- .../src/mocks/terminal-console.mock.ts | 9 + scripts/check-application-graph.test.ts | 12 +- 13 files changed, 388 insertions(+), 715 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts create mode 100644 apps/sim/ee/access-control/components/group-detail.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx new file mode 100644 index 00000000000..4e1fea54a41 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -0,0 +1,125 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react' +import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { providersModelsMock } from '@sim/testing/mocks/providers-models.mock' +import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat' +import { useChatStore } from '@/stores/chat/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +vi.mock('@/lib/auth/auth-client', () => authClientMock) +vi.mock('@/providers/models', () => providersModelsMock) +vi.mock('@/providers/utils', () => providersUtilsMock) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution', () => ({ + useWorkflowExecution: () => ({ handleRunWorkflow: vi.fn(), handleCancelExecution: vi.fn() }), + isChatWorkflowRunResult: () => false, + WorkflowAttachmentUploadError: class extends Error {}, +})) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components', () => ({ + ChatMessage: () => null, + OutputSelect: () => null, +})) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float', () => ({ + useFloatDrag: () => ({}), + useFloatBoundarySync: () => {}, + useFloatResize: () => ({}), +})) + +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: vi.fn() }) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + useChatStore.setState({ ...useChatStore.getInitialState(), isChatOpen: true }) + useWorkflowRegistry.setState({ activeWorkflowId: 'workflow-a' }) + useChatStore.getState().addMessage({ workflowId: 'workflow-a', type: 'user', content: 'first' }) + useChatStore.getState().addMessage({ workflowId: 'workflow-a', type: 'user', content: 'second' }) + useChatStore.getState().addMessage({ + workflowId: 'workflow-a', + type: 'workflow', + content: 'partial', + isStreaming: true, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root.render()) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useChatStore.setState(useChatStore.getInitialState()) + useWorkflowRegistry.setState({ activeWorkflowId: null }) + Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') + vi.useRealTimers() +}) + +function input() { + const element = container.querySelector( + 'input[placeholder="Type a message..."]' + ) + if (!element) throw new Error('Chat composer missing') + return element +} + +function press(key: string) { + act(() => { + input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + }) +} + +describe('floating chat prompt history', () => { + it('keeps the history cursor while assistant output streams and finalizes', () => { + press('ArrowUp') + expect(input().value).toBe('second') + const responseId = useChatStore.getState().messages[2].id + act(() => useChatStore.getState().setMessageContent(responseId, 'next chunk')) + press('ArrowUp') + expect(input().value).toBe('first') + act(() => useChatStore.getState().finalizeMessageStream(responseId)) + press('ArrowDown') + expect(input().value).toBe('second') + press('ArrowDown') + expect(input().value).toBe('') + }) + + it('resets navigation when the workflow changes even with identical prompt history', () => { + act(() => { + useChatStore + .getState() + .addMessage({ workflowId: 'workflow-b', type: 'user', content: 'first' }) + useChatStore + .getState() + .addMessage({ workflowId: 'workflow-b', type: 'user', content: 'second' }) + }) + press('ArrowUp') + expect(input().value).toBe('second') + act(() => useWorkflowRegistry.setState({ activeWorkflowId: 'workflow-b' })) + press('ArrowUp') + expect(input().value).toBe('second') + act(() => + useChatStore + .getState() + .addMessage({ workflowId: 'workflow-b', type: 'user', content: 'newest' }) + ) + press('ArrowUp') + expect(input().value).toBe('newest') + act(() => useChatStore.getState().clearChat('workflow-b')) + press('ArrowDown') + expect(input().value).toBe('newest') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 2dff953adcd..8dbf0230432 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -267,6 +267,17 @@ export function Chat() { })) ) + const promptHistory = useChatStore( + useShallow((state) => + !activeWorkflowId + ? [] + : state.messages + .filter((message) => message.workflowId === activeWorkflowId && message.type === 'user') + .map((message) => message.content) + .filter((content): content is string => typeof content === 'string') + ) + ) + const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated) const entries = useWorkflowConsoleEntries( hasConsoleHydrated && typeof activeWorkflowId === 'string' ? activeWorkflowId : undefined @@ -277,7 +288,6 @@ export function Chat() { const { addToQueue } = useOperationQueue() const [chatMessage, setChatMessage] = useState('') - const [promptHistory, setPromptHistory] = useState([]) const [historyIndex, setHistoryIndex] = useState(-1) const [moreMenuOpen, setMoreMenuOpen] = useState(false) @@ -428,23 +438,9 @@ export function Chat() { } ) - const userMessages = useMemo(() => { - return workflowMessages - .filter((msg) => msg.type === 'user') - .map((msg) => msg.content) - .filter((content): content is string => typeof content === 'string') - }, [workflowMessages]) - useEffect(() => { - if (!activeWorkflowId) { - setPromptHistory([]) - setHistoryIndex(-1) - return - } - - setPromptHistory(userMessages) setHistoryIndex(-1) - }, [activeWorkflowId, userMessages]) + }, [activeWorkflowId, promptHistory]) /** * Auto-scroll to bottom when messages load and chat is open @@ -724,9 +720,6 @@ export function Chat() { } const messageAttachments = toChatMessageAttachments(result.uploadedAttachments) - if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) { - setPromptHistory((prev) => [...prev, sentMessage]) - } setHistoryIndex(-1) const messageContent = @@ -759,7 +752,6 @@ export function Chat() { chatFiles, activeWorkflowId, isExecuting, - promptHistory, getConversationId, addMessage, handleRunWorkflow, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts deleted file mode 100644 index 85acddb8a22..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { ChatContext } from '@/stores/panel' - -/** - * Mention folder types - */ -export type MentionFolderId = - | 'chats' - | 'workflows' - | 'knowledge' - | 'blocks' - | 'workflow-blocks' - | 'logs' - | 'integrations' - -/** - * Menu item category types for mention menu (includes folders + docs item) - */ -export type MentionCategory = MentionFolderId | 'docs' - -/** - * Configuration interface for folder types - */ -export interface FolderConfig { - /** Display title in menu */ - title: string - /** Data source key in useMentionData return */ - dataKey: string - /** Loading state key in useMentionData return */ - loadingKey: string - /** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */ - ensureLoadedKey?: string - /** Extract label from an item */ - getLabel: (item: TItem) => string - /** Extract unique ID from an item */ - getId: (item: TItem) => string - /** Empty state message */ - emptyMessage: string - /** No match message (when filtering) */ - noMatchMessage: string - /** Filter function for matching query */ - filterFn: (item: TItem, query: string) => boolean - /** Build the ChatContext object from an item */ - buildContext: (item: TItem, workflowId?: string | null) => ChatContext - /** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */ - useInsertFallback?: boolean -} - -/** - * Configuration for all folder types in the mention menu - */ -export const FOLDER_CONFIGS: Record = { - chats: { - title: 'Chats', - dataKey: 'pastChats', - loadingKey: 'isLoadingPastChats', - ensureLoadedKey: 'ensurePastChatsLoaded', - getLabel: (item) => item.title || 'New Chat', - getId: (item) => item.id, - emptyMessage: 'No past chats', - noMatchMessage: 'No matching chats', - filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'past_chat', - chatId: item.id, - label: item.title || 'New Chat', - }), - useInsertFallback: false, - }, - workflows: { - title: 'All workflows', - dataKey: 'workflows', - loadingKey: 'isLoadingWorkflows', - getLabel: (item) => item.name || 'Untitled Workflow', - getId: (item) => item.id, - emptyMessage: 'No workflows', - noMatchMessage: 'No matching workflows', - filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'workflow', - workflowId: item.id, - label: item.name || 'Untitled Workflow', - }), - useInsertFallback: true, - }, - knowledge: { - title: 'Knowledge Bases', - dataKey: 'knowledgeBases', - loadingKey: 'isLoadingKnowledge', - ensureLoadedKey: 'ensureKnowledgeLoaded', - getLabel: (item) => item.name || 'Untitled', - getId: (item) => item.id, - emptyMessage: 'No knowledge bases', - noMatchMessage: 'No matching knowledge bases', - filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'knowledge', - knowledgeId: item.id, - label: item.name || 'Untitled', - }), - useInsertFallback: false, - }, - blocks: { - title: 'Blocks', - dataKey: 'blocksList', - loadingKey: 'isLoadingBlocks', - ensureLoadedKey: 'ensureBlocksLoaded', - getLabel: (item) => item.name || item.id, - getId: (item) => item.id, - emptyMessage: 'No blocks found', - noMatchMessage: 'No matching blocks', - filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'blocks', - blockIds: [item.id], - label: item.name || item.id, - }), - useInsertFallback: false, - }, - 'workflow-blocks': { - title: 'Workflow Blocks', - dataKey: 'workflowBlocks', - loadingKey: 'isLoadingWorkflowBlocks', - // No ensureLoadedKey - workflow blocks auto-sync from store - getLabel: (item) => item.name || item.id, - getId: (item) => item.id, - emptyMessage: 'No blocks in this workflow', - noMatchMessage: 'No matching blocks', - filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), - buildContext: (item, workflowId) => ({ - kind: 'workflow_block', - workflowId: workflowId || '', - blockId: item.id, - label: item.name || item.id, - }), - useInsertFallback: true, - }, - logs: { - title: 'Logs', - dataKey: 'logsList', - loadingKey: 'isLoadingLogs', - ensureLoadedKey: 'ensureLogsLoaded', - getLabel: (item) => item.workflowName, - getId: (item) => item.id, - emptyMessage: 'No executions found', - noMatchMessage: 'No matching executions', - filterFn: (item, q) => - [item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'logs', - executionId: item.executionId || item.id, - label: item.workflowName, - }), - useInsertFallback: false, - }, - integrations: { - title: 'Integrations', - dataKey: 'integrations', - loadingKey: 'isLoadingIntegrations', - getLabel: (item) => item.name, - getId: (item) => item.blockType, - emptyMessage: 'No integrations', - noMatchMessage: 'No matching integrations', - filterFn: (item, q) => item.name.toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'integration', - blockType: item.blockType, - label: item.name, - }), - useInsertFallback: true, - }, -} - -/** - * Order of folders in the mention menu - */ -export const FOLDER_ORDER: MentionFolderId[] = [ - 'chats', - 'workflows', - 'knowledge', - 'blocks', - 'workflow-blocks', - 'integrations', - 'logs', -] - -/** - * Docs item configuration (special case - not a folder) - */ -export const DOCS_CONFIG = { - getLabel: () => 'Docs', - buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), -} as const - -/** - * Total number of items in root menu (folders + docs) - */ -export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 - -/** - * Slash command configuration - */ -export interface SlashCommand { - id: string - label: string -} - -export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [ - { id: 'fast', label: 'Fast' }, - { id: 'research', label: 'Research' }, - { id: 'actions', label: 'Actions' }, -] as const - -export const WEB_COMMANDS: readonly SlashCommand[] = [ - { id: 'search', label: 'Search' }, - { id: 'read', label: 'Read' }, - { id: 'scrape', label: 'Scrape' }, - { id: 'crawl', label: 'Crawl' }, -] as const - -export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] - -/** - * Scroll tolerance for mention menu positioning (in pixels) - */ -export const SCROLL_TOLERANCE = 8 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts index 4bf89ce3a0f..f17c6d0102e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts @@ -1,6 +1,5 @@ export { useContextManagement } from './use-context-management' export { useFileAttachments } from './use-file-attachments' export { useIntegrationAutoMention } from './use-integration-auto-mention' -export { useMentionData } from './use-mention-data' export { useMentionMenu } from './use-mention-menu' export { useMentionTokens } from './use-mention-tokens' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts deleted file mode 100644 index 77c6765d44c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ /dev/null @@ -1,365 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { useShallow } from 'zustand/react/shallow' -import { requestJson } from '@/lib/api/client/request' -import { listCopilotChatsContract } from '@/lib/api/contracts/copilot' -import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base' -import { listLogsContract } from '@/lib/api/contracts/logs' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' -import { type IntegrationDescriptor, listIntegrations } from '@/blocks/integration-matcher' -import { useWorkflows } from '@/hooks/queries/workflows' -import { usePermissionConfig } from '@/hooks/use-permission-config' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('useMentionData') - -/** - * Represents a past chat for mention suggestions - */ -export interface PastChat { - id: string - title: string | null - workflowId: string | null - updatedAt?: string -} - -/** - * Represents a workflow for mention suggestions - */ -export interface WorkflowItem { - id: string - name: string - color?: string -} - -/** - * Represents a knowledge base for mention suggestions - */ -export interface KnowledgeItem { - id: string - name: string -} - -/** - * Represents a block for mention suggestions - */ -export interface BlockItem { - id: string - name: string - iconComponent?: any - bgColor?: string -} - -/** - * Represents a workflow block for mention suggestions - */ -export interface WorkflowBlockItem { - id: string - name: string - type: string - iconComponent?: any - bgColor?: string -} - -/** - * Represents a log/execution for mention suggestions - */ -export interface LogItem { - id: string - executionId?: string - level: string - trigger: string | null - createdAt: string - workflowName: string -} - -interface UseMentionDataProps { - workflowId: string | null - workspaceId: string -} - -/** - * Return type for useMentionData hook - */ -export interface MentionDataReturn { - // Data arrays - pastChats: PastChat[] - workflows: WorkflowItem[] - knowledgeBases: KnowledgeItem[] - blocksList: BlockItem[] - workflowBlocks: WorkflowBlockItem[] - logsList: LogItem[] - integrations: readonly IntegrationDescriptor[] - - // Loading states - isLoadingPastChats: boolean - isLoadingWorkflows: boolean - isLoadingKnowledge: boolean - isLoadingBlocks: boolean - isLoadingWorkflowBlocks: boolean - isLoadingLogs: boolean - isLoadingIntegrations: boolean - - // Ensure loaded functions - ensurePastChatsLoaded: () => Promise - ensureKnowledgeLoaded: () => Promise - ensureBlocksLoaded: () => Promise - ensureLogsLoaded: () => Promise -} - -/** - * Custom hook to fetch and manage data for mention suggestions - * Loads data from APIs for chats, workflows, knowledge bases, blocks, and logs - * - * @param props - Configuration including workflow and workspace IDs - * @returns Mention data state and loading operations - */ -export function useMentionData(props: UseMentionDataProps): MentionDataReturn { - const { workflowId, workspaceId } = props - - const { config, isBlockAllowed } = usePermissionConfig() - - const [pastChats, setPastChats] = useState([]) - const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) - - const [knowledgeBases, setKnowledgeBases] = useState([]) - const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false) - - const [blocksList, setBlocksList] = useState([]) - const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) - - // Reset on permission changes and on block-overlay bumps (custom-block or - // block-visibility hydrate) so late preview reveals refresh the folder. - const blockOverlayVersion = useCustomBlockOverlayVersion() - useEffect(() => { - setBlocksList([]) - }, [config.allowedIntegrations, blockOverlayVersion]) - - const [logsList, setLogsList] = useState([]) - const [isLoadingLogs, setIsLoadingLogs] = useState(false) - - const [workflowBlocks, setWorkflowBlocks] = useState([]) - const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false) - - // Integrations are derived synchronously from the block registry via the - // shared auto-mention matcher singleton — no fetch, no loading state. The - // accessor returns a stable cached reference so no memoization is needed. - const integrations = listIntegrations() - - const blockKeys = useWorkflowStore( - useShallow(useCallback((state) => Object.keys(state.blocks), [])) - ) - - const { data: registryWorkflowList = [] } = useWorkflows(workspaceId) - const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' - - const workflows: WorkflowItem[] = registryWorkflowList - .filter((w) => w.workspaceId === workspaceId) - .sort((a, b) => { - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return dateB - dateA - }) - .map((w) => ({ - id: w.id, - name: w.name || 'Untitled Workflow', - })) - - /** - * Resets past chats when workflow changes - */ - useEffect(() => { - setPastChats([]) - setIsLoadingPastChats(false) - }, [workflowId]) - - /** - * Syncs workflow blocks from store - * Only re-runs when blocks are added/removed (not on position updates) - */ - useEffect(() => { - const syncWorkflowBlocks = async () => { - if (!workflowId || blockKeys.length === 0) { - setWorkflowBlocks([]) - return - } - - try { - // Fetch current blocks from store - const workflowStoreBlocks = useWorkflowStore.getState().blocks - - const { getBlockRegistry } = await import('@/blocks/registry') - const blockRegistry = getBlockRegistry() - const mapped = Object.values(workflowStoreBlocks).map((b: any) => { - const reg = (blockRegistry as any)[b.type] - return { - id: b.id, - name: b.name || b.id, - type: b.type, - iconComponent: reg?.icon, - bgColor: reg?.bgColor || '#6B7280', - } - }) - setWorkflowBlocks(mapped) - logger.debug('Synced workflow blocks for mention menu', { - count: mapped.length, - }) - } catch (error) { - logger.debug('Failed to sync workflow blocks:', error) - } - } - - syncWorkflowBlocks() - }, [blockKeys, workflowId]) - - /** - * Ensures past chats are loaded - */ - const ensurePastChatsLoaded = useCallback(async () => { - if (isLoadingPastChats || pastChats.length > 0) return - try { - setIsLoadingPastChats(true) - const data = await requestJson(listCopilotChatsContract, {}) - const items = data.chats - - const currentWorkflowChats = items.filter((c) => c.workflowId === workflowId) - - setPastChats( - currentWorkflowChats.map((c) => ({ - id: c.id, - title: c.title ?? null, - workflowId: c.workflowId ?? null, - updatedAt: c.updatedAt ?? undefined, - })) - ) - } catch { - } finally { - setIsLoadingPastChats(false) - } - }, [isLoadingPastChats, pastChats.length, workflowId]) - - /** - * Ensures knowledge bases are loaded - */ - const ensureKnowledgeLoaded = useCallback(async () => { - if (isLoadingKnowledge || knowledgeBases.length > 0) return - try { - setIsLoadingKnowledge(true) - const result = await requestJson(listKnowledgeBasesContract, { - query: { workspaceId, includeCounts: false }, - }) - const items = result.data - const sorted = [...items].sort((a, b) => { - const ta = new Date(a.updatedAt || a.createdAt || 0).getTime() - const tb = new Date(b.updatedAt || b.createdAt || 0).getTime() - return tb - ta - }) - setKnowledgeBases(sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))) - } catch { - } finally { - setIsLoadingKnowledge(false) - } - }, [isLoadingKnowledge, knowledgeBases.length, workspaceId]) - - /** - * Ensures blocks are loaded - */ - const ensureBlocksLoaded = useCallback(async () => { - if (isLoadingBlocks || blocksList.length > 0) return - try { - setIsLoadingBlocks(true) - const { getAllBlocks } = await import('@/blocks') - const all = getAllBlocks() - const regularBlocks = all - .filter( - (b: any) => - b.type !== 'starter' && - !b.hideFromToolbar && - b.category === 'blocks' && - isBlockAllowed(b.type) - ) - .map((b: any) => ({ - id: b.type, - name: b.name || b.type, - iconComponent: b.icon, - bgColor: b.bgColor, - })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - - const toolBlocks = all - .filter( - (b: any) => - b.type !== 'starter' && - !b.hideFromToolbar && - b.category === 'tools' && - isBlockAllowed(b.type) - ) - .map((b: any) => ({ - id: b.type, - name: b.name || b.type, - iconComponent: b.icon, - bgColor: b.bgColor, - })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - - setBlocksList([...regularBlocks, ...toolBlocks]) - } catch { - } finally { - setIsLoadingBlocks(false) - } - }, [isLoadingBlocks, blocksList.length, isBlockAllowed]) - - /** - * Ensures logs are loaded - */ - const ensureLogsLoaded = useCallback(async () => { - if (isLoadingLogs || logsList.length > 0) return - try { - setIsLoadingLogs(true) - const data = await requestJson(listLogsContract, { - query: { workspaceId, limit: 50 }, - }) - const items = data.data - const mapped = items.map((l) => ({ - id: l.id, - executionId: l.executionId || l.id, - level: l.level, - trigger: l.trigger || null, - createdAt: l.createdAt, - workflowName: l.workflow?.name ?? 'Untitled Workflow', - })) - setLogsList(mapped) - } catch { - } finally { - setIsLoadingLogs(false) - } - }, [isLoadingLogs, logsList.length, workspaceId]) - - return { - // State - pastChats, - isLoadingPastChats, - workflows, - isLoadingWorkflows, - knowledgeBases, - isLoadingKnowledge, - blocksList, - isLoadingBlocks, - logsList, - isLoadingLogs, - workflowBlocks, - isLoadingWorkflowBlocks, - integrations, - isLoadingIntegrations: false, - - // Operations - ensurePastChatsLoaded, - ensureKnowledgeLoaded, - ensureBlocksLoaded, - ensureLogsLoaded, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index ac69a0d6e58..ff77b9faace 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import type { ChatContext } from '@/stores/panel' +const SCROLL_TOLERANCE = 8 + interface UseMentionMenuProps { /** Current message text */ message: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 7c6b3313ac7..4c1d0c82d16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -1,9 +1,4 @@ import { escapeRegExp } from '@sim/utils/string' -import { - FOLDER_CONFIGS, - type MentionFolderId, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' -import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' import type { ChatContext } from '@/stores/panel' /** @@ -114,31 +109,6 @@ export function computeMentionHighlightRanges( return ranges } -/** - * Gets the data array for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - * Returns any[] since item types vary by folder and are used with dynamic config.filterFn - */ -export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] { - const config = FOLDER_CONFIGS[folderId] - return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || [] -} - -/** - * Gets the ensure loaded function for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - */ -export function getFolderEnsureLoaded( - mentionData: MentionDataReturn, - folderId: MentionFolderId -): (() => Promise) | undefined { - const config = FOLDER_CONFIGS[folderId] - if (!config.ensureLoadedKey) return undefined - return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as - | (() => Promise) - | undefined -} - /** * Extract specific ChatContext types for type-safe narrowing */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 04dfea0021a..db9692d5760 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -715,6 +715,13 @@ function shouldSkipPreviewBlockRender( /** Skip subBlockValues comparison in lightweight mode */ if (nextProps.data.lightweight) return true + if ( + prevProps.data.workflowMap !== nextProps.data.workflowMap || + prevProps.data.workflowLabelsReady !== nextProps.data.workflowLabelsReady + ) { + return false + } + const prevValues = prevProps.data.subBlockValues const nextValues = nextProps.data.subBlockValues diff --git a/apps/sim/ee/access-control/components/group-detail.test.tsx b/apps/sim/ee/access-control/components/group-detail.test.tsx new file mode 100644 index 00000000000..3ca858c2a35 --- /dev/null +++ b/apps/sim/ee/access-control/components/group-detail.test.tsx @@ -0,0 +1,161 @@ +/** @vitest-environment jsdom */ + +import { act, type ReactNode } from 'react' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { jsonResponse } from '@sim/testing/helpers/http' +import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' +import { providersModelsMock } from '@sim/testing/mocks/providers-models.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PermissionGroup } from '@/lib/api/contracts/permission-groups' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GroupDetail } from '@/ee/access-control/components/group-detail' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' + +vi.mock('@/lib/auth/auth-client', () => authClientMock) +vi.mock('next/navigation', () => nextNavigationMock) +vi.mock('@/providers/models', () => providersModelsMock) +vi.mock('@/providers/utils', () => providersUtilsMock) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children: ReactNode }) => <>{children}, +})) + +const group: PermissionGroup = { + id: 'group-1', + name: 'Engineering', + description: null, + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG }, + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + creatorName: null, + creatorEmail: null, + memberCount: 0, + isDefault: false, + workspaces: [], +} + +let root: Root +let container: HTMLDivElement +let client: QueryClient + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + providersUtilsMockFns.mockGetAllProviderIds.mockReturnValue(['openai', 'anthropic']) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(permissionGroupKeys.members('org-1', 'group-1'), []) + client.setQueryData(organizationKeys.roster('org-1'), { + members: [], + pendingInvitations: [], + workspaces: [], + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + client.clear() + container.remove() + providersUtilsMockFns.mockGetAllProviderIds.mockReset() + vi.useRealTimers() +}) + +function render() { + act(() => + root.render( + + + {}} + onDeleted={() => {}} + /> + + + ) + ) +} + +function expectNoProviderControls() { + expect(container.querySelector('[id^="provider-"]')).toBeNull() + expect(container.querySelector('input[placeholder="Search providers..."]')).toBeNull() + expect( + [...container.querySelectorAll('button')].some((button) => + /^(De)?select All$/i.test(button.textContent ?? '') + ) + ).toBe(false) +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) +} + +describe('provider permission policy availability', () => { + it('withholds provider edits until the server policy is known', () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => createDeferred().promise) + ) + render() + expectNoProviderControls() + }) + + it.each([ + { status: 503, body: { error: 'Policy temporarily unavailable' }, blacklist: ['openai'] }, + { status: 401, body: { error: 'Session expired' }, blacklist: [] }, + { status: 200, body: { blacklistedProviders: 'invalid' }, blacklist: ['openai'] }, + ])( + 'withholds edits after a $status response and recovers through retry', + async ({ status, body, blacklist }) => { + const retry = createDeferred() + let requests = 0 + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url !== '/api/settings/allowed-providers') + throw new Error(`Unexpected request: ${url}`) + requests++ + return requests === 1 ? Promise.resolve(jsonResponse(body, status)) : retry.promise + }) + ) + render() + await settle() + expect(container.querySelector('[role="alert"]')).not.toBeNull() + expectNoProviderControls() + const retryButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'Try again' + ) + expect(retryButton).toBeDefined() + act(() => retryButton!.click()) + await settle() + expect(requests).toBe(2) + expectNoProviderControls() + await act(async () => { + retry.resolve(jsonResponse({ blacklistedProviders: blacklist })) + }) + await settle() + expect(container.querySelector('[role="alert"]')).toBeNull() + const anthropic = container.querySelector('#provider-anthropic') + expect(anthropic?.getAttribute('aria-checked')).toBe('true') + expect(Boolean(container.querySelector('#provider-openai'))).toBe( + !blacklist.includes('openai') + ) + act(() => anthropic!.click()) + expect(anthropic?.getAttribute('aria-checked')).toBe('false') + } + ) +}) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 52230d0ba2e..16e819069bd 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -50,7 +50,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { MemberRow } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -775,7 +778,7 @@ export function GroupDetail({ viewingGroup.id ) const { data: roster } = useOrganizationRoster(organizationId) - const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: true }) + const blacklistedProviders = useBlacklistedProviders() // Recompute when custom (deploy-as-block) blocks or the viewer's block // visibility hydrate into the overlay. @@ -815,11 +818,10 @@ export function GroupDetail({ const visibleBlocks = useMemo(() => allBlocks.filter((b) => !b.hideFromToolbar), [allBlocks]) const allProviderIds = useMemo(() => { - const allIds = getAllProviderIds() - const blacklist = blacklistedProvidersData?.blacklistedProviders ?? [] - if (blacklist.length === 0) return allIds - return allIds.filter((id) => !blacklist.includes(id.toLowerCase())) - }, [blacklistedProvidersData]) + if (!blacklistedProviders.isSuccess) return [] + const blacklist = blacklistedProviders.data.blacklistedProviders + return getAllProviderIds().filter((id) => !blacklist.includes(id.toLowerCase())) + }, [blacklistedProviders.data, blacklistedProviders.isSuccess]) /** Maps every tool id to ALL block types that expose it (some tools are shared across blocks). */ const toolBlockTypes = useMemo(() => { @@ -1616,50 +1618,63 @@ export function GroupDetail({ )} - {configTab === 'providers' && ( -

-
- setSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> - void setStatusFilter(next)} - /> - setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)} - disabled={filteredProviders.length === 0} - > - {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} - -
- {filteredProviders.length === 0 ? ( - - No providers match your filters. - - ) : ( -
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} - /> - ))} + {configTab === 'providers' && + (blacklistedProviders.isError ? ( + void blacklistedProviders.refetch()} + variant='inline' + /> + ) : !blacklistedProviders.isSuccess ? ( + Loading providers + ) : ( +
+
+ setSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + void setStatusFilter(next)} + /> + + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed) + } + disabled={filteredProviders.length === 0} + > + {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} +
- )} -
- )} + {filteredProviders.length === 0 ? ( + + No providers match your filters. + + ) : ( +
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+ )} +
+ ))} {configTab === 'blocks' && (
diff --git a/apps/sim/hooks/queries/allowed-providers.ts b/apps/sim/hooks/queries/allowed-providers.ts index f49de382073..c9461ce2a26 100644 --- a/apps/sim/hooks/queries/allowed-providers.ts +++ b/apps/sim/hooks/queries/allowed-providers.ts @@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' -import type { ContractJsonResponse } from '@/lib/api/contracts' import { getAllowedProvidersContract } from '@/lib/api/contracts' /** @@ -15,26 +14,13 @@ export const allowedProvidersKeys = { export const BLACKLISTED_PROVIDERS_STALE_TIME = 5 * 60 * 1000 -type BlacklistedProvidersResponse = ContractJsonResponse - -async function fetchBlacklistedProviders( - signal: AbortSignal -): Promise { - try { - return await requestJson(getAllowedProvidersContract, { signal }) - } catch { - return { blacklistedProviders: [] } - } -} - /** * Hook to fetch the list of blacklisted provider IDs from the server. */ -export function useBlacklistedProviders({ enabled = true }: { enabled?: boolean } = {}) { +export function useBlacklistedProviders() { return useQuery({ queryKey: allowedProvidersKeys.blacklisted(), - queryFn: ({ signal }) => fetchBlacklistedProviders(signal), + queryFn: ({ signal }) => requestJson(getAllowedProvidersContract, { signal }), staleTime: BLACKLISTED_PROVIDERS_STALE_TIME, - enabled, }) } diff --git a/packages/testing/src/mocks/terminal-console.mock.ts b/packages/testing/src/mocks/terminal-console.mock.ts index 16fa06a87ca..cb31cb840b9 100644 --- a/packages/testing/src/mocks/terminal-console.mock.ts +++ b/packages/testing/src/mocks/terminal-console.mock.ts @@ -12,10 +12,16 @@ interface ConsoleEntryLike { [key: string]: unknown } +const EMPTY_ENTRIES: ConsoleEntryLike[] = [] + const entriesByWorkflow: Record = {} const mockGetWorkflowEntries = vi.fn((workflowId: string) => entriesByWorkflow[workflowId] ?? []) +const mockUseWorkflowConsoleEntries = vi.fn((workflowId?: string) => + workflowId ? (entriesByWorkflow[workflowId] ?? EMPTY_ENTRIES) : EMPTY_ENTRIES +) + const mockAddConsole = vi.fn((entry: ConsoleEntryLike) => { const stored = { ...entry, id: entry.id ?? `mock-${generateRandomString(16)}` } if (!entriesByWorkflow[entry.workflowId]) entriesByWorkflow[entry.workflowId] = [] @@ -54,6 +60,7 @@ const mockWaitForConsoleHydration = vi.fn(async () => undefined) export function resetTerminalConsoleMock(): void { for (const key of Object.keys(entriesByWorkflow)) delete entriesByWorkflow[key] mockGetWorkflowEntries.mockClear() + mockUseWorkflowConsoleEntries.mockClear() mockAddConsole.mockClear() mockUpdateConsole.mockClear() mockCancelRunningEntries.mockClear() @@ -93,6 +100,7 @@ const mockUseTerminalConsoleStore = Object.assign( */ export const terminalConsoleMockFns = { mockGetWorkflowEntries, + mockUseWorkflowConsoleEntries, mockAddConsole, mockUpdateConsole, mockCancelRunningEntries, @@ -120,6 +128,7 @@ export const terminalConsoleMockFns = { */ export const terminalConsoleMock = { useTerminalConsoleStore: mockUseTerminalConsoleStore, + useWorkflowConsoleEntries: mockUseWorkflowConsoleEntries, consolePersistence: mockConsolePersistence, loadExecutionPointer: mockLoadExecutionPointer, saveExecutionPointer: mockSaveExecutionPointer, diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts index f0e6dc0ab56..7a434f24c98 100644 --- a/scripts/check-application-graph.test.ts +++ b/scripts/check-application-graph.test.ts @@ -51,22 +51,20 @@ describe('a deferred edge into a forbidden tree', () => { */ it('is reported when a root defers the load of a forbidden module', () => { /** - * Walked from a module that defers the block registry — `const - * { getBlockRegistry } = await import('@/blocks/registry')`. A root's own + * Walked from a live chat payload module that defers `@/tools/params`. A root's own * deferred edges are checked before its static imports, so the reported chain * is that single deferred hop, whatever else the root reaches. */ - const root = - 'app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts' + const root = 'lib/mothership/chat/payload.ts' const violations = findViolations({ root, - forbidden: { 'blocks/': FORBIDDEN_PREFIXES['blocks/'] }, + forbidden: { 'tools/': FORBIDDEN_PREFIXES['tools/'] }, }) expect(violations).toHaveLength(1) - expect(violations[0].forbidden).toBe('blocks/registry.ts') + expect(violations[0].forbidden).toBe('tools/params.ts') expect(violations[0].reason).toContain('deferred') - expect(violations[0].path).toEqual([root, 'blocks/registry.ts']) + expect(violations[0].path).toEqual([root, 'tools/params.ts']) }) /** From bb4d256172804934d56a09ff274aa116234535d3 Mon Sep 17 00:00:00 2001 From: mzxchandra <129460234+mzxchandra@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:47:10 -0700 Subject: [PATCH 06/22] feat(forks): opt-in fork sync for new workflows (#8318) * feat(forks): opt-in fork sync for new workflows Workspace forks gain a lineage-wide policy for whether a NEWLY created workflow joins fork sync. Today a workflow joins the moment it is deployed, so deploying something experimental in a parent pushes it into every fork on the next sync with no step where anyone chose that. Opt-out remains the default, so no existing or new workspace changes behaviour until someone flips the toggle. The policy lives on workspace.fork_sync_new_workflows_excluded (default false, the historical behaviour); workflow.fork_sync_excluded is untouched including its default. It is uniform across a fork lineage: settable from any member, fanning out to every ancestor and descendant under an advisory lock keyed on the lineage root, which fork creation and unlink also take. It is forward-only - flipping it never rewrites an existing workflow's sync state - and each changed member records its own audit entry naming where the change was issued from. Genuinely new workflows (create, duplicate, admin/superuser import, a fork's starter) take the workspace policy. A copy (fork creation, promote-create) inherits the SOURCE workflow's flag, because it is the same logical workflow in another workspace; without that an opted-in workflow would copy into a fork already excluded and never sync again. The fork modal gains "Copy unsynced workflows" (off by default, shown only when the source has unsynced deployed workflows, disabled when the combined set would exceed the fork ceiling) so forking an opt-in workspace cannot silently produce an empty fork. The settings section becomes "Synced workflows" with the polarity flipped - checked means the workflow syncs - above a "Sync new workflows by default" toggle row that states its lineage-wide reach. The wire field stays forkSyncExcluded, so the tree owns the single inversion. Co-Authored-By: Claude Opus 5 (1M context) * fix(forks): break the fork lock cycle and derive review-round values once Round 3 review left 7 threads. Five were the same mistake: a guard added at one read site of a value while the other read sites kept the unguarded one. Each fix below is one derived value used everywhere, or a deleted duplicate path, rather than another guard at another call site. Lock-order deadlock (reported independently by both reviewers). `lineage.ts` already declared `fork-lineage` the coarsest fork lock, but ranked only the three advisory locks and said nothing about `lockForkRevision`, which takes FOR UPDATE on `workspace`. `createFork` took it first, so it held that row while waiting for `fork-lineage`, while `unlinkForkEdge` held `fork-lineage` and waited to UPDATE the same row. Hoist the lineage lock above it, and replace the partial contract with a rank table covering every lock in the module. All six fork transactions now acquire in ascending rank. Stale workspace rows in the synced-workflows list. `useWorkflows` and `useFolders` both set `placeholderData: keepPreviousData`, so a workspace switch served the previous workspace's rows with `isLoading: false` and a click posted workspace A's ids against B. Gate on `isPending || isPlaceholderData`, matching `custom-tools.tsx`. Fork modal submitted a value the switch showed as off. `copyUnsyncedWorkflows` had two readings and submit used the raw one. Derive it once from the request plus the limit, and read that at all four sites. Audit entries named workspaces by id. Return the name from the UPDATE and project `resourceName` from it, so a lineage-wide change no longer reads as one named workspace and N opaque identifiers. Also: give `setForkSyncDefault`'s multi-row UPDATE a deterministic row-lock order, delete the non-barrel re-export left behind when the workflow limit moved to `limits.ts`, and correct a TSDoc invariant that claimed the `fork-target` lock for both callers when only one holds it. Tests, per the repo's test-audit gate: add one `*.integration.ts` that races the two real lock sequences against real Postgres and asserts the pre-fix order deadlocks while the shipped order does not, so the check cannot pass vacuously. Drop the re-added contract-schema test (an identical file was pruned as low-signal in #8295), fold the fork-sync inheritance assertion into its sibling, and reduce the synced-workflows test to the pure tree builder whose checkbox stub no longer implements the polarity it asserts. Co-Authored-By: Claude Opus 5 (1M context) * test(forks): drive the lock-order suite through the production helpers cubic's round-4 finding on the new integration suite was right: it mirrored the lock SQL as string constants instead of executing production's, so a `createFork` regression to the pre-fix order would have left it green. Closed on both halves. The fixture now calls the real `setForkLockTimeout`, `acquireForkLineageLock` and `lockForkRevision`, against the tables the last of those actually locks, so a change to the advisory-lock key or to the revision lock's coverage is carried into the test rather than silently diverging from a copy. A third check pins why the pair conflicts at all: the revision lock really does hold the `workspace` row, which is the edge of the cycle. The order inside `createFork` is asserted where it lives, in `create-fork.test.ts`, on invocation order through the admission path. Order is the entire contract here, which is the case the retention bar keeps call ordering for. Verified red for the right reason: reordering the two acquisitions in `create-fork.ts` fails the new unit assertion, and the integration suite's negative control still fails in ~1.03s with the server reporting `deadlock detected` rather than a lock timeout. Co-Authored-By: Claude Opus 5 (1M context) * test(forks): scope the lock-order barrier to the unlink backend cubic was right that counting any Lock waiter on the database could let an unrelated session release the barrier before the unlink had blocked, leaving the pre-fix negative control to run with its cycle still open. A weak negative control is the failure mode this suite exists to avoid. The unlink transaction now publishes its own backend pid before it can block, and the barrier waits on exactly that pid. If it never blocks the race did not set up, so it throws rather than quietly proceeding and reporting a pass. Co-Authored-By: Claude Opus 5 (1M context) * test(forks): settle every lock-order barrier on the failure path Both reviewers independently caught the same real defect: a session that died before resolving its barrier left `raceForkAgainstUnlink` awaiting a deferred that would never settle, so a clean database error surfaced as a 30-second vitest timeout. That hides exactly the diagnostics a concurrency test exists to provide. Every barrier is now settled on the failure path as well as the happy one. The fork session releases `forkHoldsFirstLock` in a `finally`, the unlink session reports a `null` backend when it dies before `pg_backend_pid()` returns, and the barrier is skipped outright once a session has already failed, so the session's own error is what the test reports. Releasing the sessions and draining them moved into a `finally` too, along with closing both connections, so a throw inside the barrier cannot strand the fork transaction on `unlinkMayFinish`. Verified by simulating the failure they described - a fork session that throws before taking its first lock. It now fails in 1s with "simulated early connection failure" instead of timing out at 30s. Co-Authored-By: Claude Opus 5 (1M context) * test(forks): drop call-count assertions from the sync-default suite CLAUDE.md forbids tests that assert a mock was called. Three assertions in this suite were exactly that shape - `mock.calls).toHaveLength(0)` on the audit and analytics mocks - and they proved only what the mock itself decided, not anything about the use case. The no-op case now asserts the result instead: `projectAudit` maps over `changedWorkspaces` and `afterSuccess` returns early when it is empty, so an empty result IS "no audit, no analytics", pinned to the value the fan-out actually reads. The admission case keeps its rejection assertion, which is the real guarantee: admission runs before the transaction opens. What stays reads the CONTENT of the entries filed - each one carried by its own workspace id and name - which is a fan-out invariant type-check cannot see and the reason this suite exists. Confirmed it still goes red on the pre-fix code: reverting the audit-name guard fails with "expected 'root-ws' to be 'Name of root-ws'". Co-Authored-By: Claude Opus 5 (1M context) * revert(forks): drop the "Copy unsynced workflows" fork override Unchecking a workflow meant "keep it out of forking entirely", and the docs said so: never sent, never received, never copied into a new fork. The override made the last of those three conditional on a modal toggle. Sync participation was never at risk - copies inherited the source's flag, so an overridden copy landed unsynced and could not sync back - but "never copied into a new fork" stopped being unconditional, and that is the guarantee someone is relying on when they uncheck a workflow. It also conflated two different states. "Unsynced" covers both "I deliberately excluded this" and "this was never checked because the lineage default is off", and the override copied both. The first case is the one the guarantee exists for. This restores the single predicate: `forkSyncExcluded` workflows are invisible to fork creation, the diff preview, promote in both directions, and the mapping scan, with no caller able to lift it. Removed the toggle and its section, `copyUnsyncedWorkflows` from the request contract, `includeSyncExcluded` from `listDeployedWorkflows` and `loadSourceDeployedStates`, the `unsyncedDeployedWorkflowCount` field, and the split count query, which goes back to one count carrying the exclusion predicate. `lib/limits.ts` goes too. It existed only so a client component could read `MAX_FORK_DEPLOYED_WORKFLOWS` without pulling the database client into the browser bundle, and the override's toggle was the only client reader. The constant returns to `copy/deploy-bridge.ts`, where it is enforced. Unaffected, and still the point of the feature: the lineage-wide new-workflow default, its forward-only write, and the rule that a genuinely new workflow takes the workspace policy while a copy inherits its source. Co-Authored-By: Claude Opus 5 (1M context) * refactor(forks): stop projecting a column the source query already pins `listDeployedWorkflows` filters on `fork_sync_excluded = false` and then selected the same column, so every row it returned carried `false` by construction - `SELECT x ... WHERE x = false`. The field only ever varied while `includeSyncExcluded` could make that predicate drop out, which the previous commit removed, so it is scaffolding from the reverted override rather than anything load-bearing. Keeping it would not have been defensive either. If someone widens that predicate they have to revisit the projection anyway, and a constant field hides the coupling between the two instead of enforcing it. Dropped from the query and from `DeployedWorkflowSummary`. The two write sites now state the invariant they actually mean: a copy is not a new workflow, so it is written synced and never takes the target workspace's new-workflow default - which in an opt-out lineage would land a deliberately synced workflow unsynced on the other side. That explicit write is kept precisely because it is a semantic claim, not a read of something the query had already decided. Untouched: the target-side read in `promote-plan.ts`, which queries target workflows with no exclusion filter and genuinely varies. That is what keeps a promote from overwriting a target the user unchecked. Dropped the copy test for an unsynced source, an input no caller can now produce, and renamed its sibling to the property that still holds. Co-Authored-By: Claude Opus 5 (1M context) * test(knowledge): give the member-tombstone budget test its own timeout `finishes a pass within its page budget` times out against the shared 30s default on a loaded CI runner. It is a load flake, not a regression: the same commit went green on the `push` integration job and timed out on `migrate`, and it runs in ~4s locally. The cost is real work, not a hang. The test seeds `MEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN * 500 + 500` documents specifically so one pass cannot finish inside a single run's budget, then observes and re-lists all of it. That volume is the assertion, so trimming it to fit the default would stop proving the multi-run path. Given its own 120s budget instead, matching the per-test timeouts already used elsewhere in this directory, with a comment recording why. Unrelated to this branch's fork-sync work - the file is byte-identical to staging and arrived with the merge - but it was failing this PR's CI, and a flake left alone becomes one everybody learns to ignore. Co-Authored-By: Claude Opus 5 (1M context) * refactor(forks): render the fork-sync default with ChipSwitch CLAUDE.md makes the chip family the canonical control chrome, and `ChipSwitch` is what the equivalent settings row already uses (`inbox-enable-toggle.tsx`). Two knock-on details, both forced by the control rather than chosen. `ChipSwitch` is a Radix radio group over a string, so the boolean inversion now runs through named values instead of `!`: `exclude` and `sync` map onto the stored `forkSyncNewWorkflowsExcluded`. And it takes no `id`, so the `Label` drops its `htmlFor` and the group carries its own `aria-label` - the same pairing `inbox-enable-toggle.tsx` uses. It also reads better here. This row's "off" means new workflows stop syncing across the whole lineage, which a thumb position leaves the reader to infer from the label; naming both outcomes puts it on screen. No behavior change beyond the control: the same mutation, the same error toast, the same placeholder-data gate. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../docs/platform/enterprise/forks.mdx | 46 +- .../api/superuser/import-workflow/route.ts | 4 + .../api/v1/admin/workflows/import/route.ts | 5 + .../v1/admin/workspaces/[id]/import/route.ts | 5 + .../[id]/fork/sync-default/route.ts | 29 + .../application/lineage-details.ts | 6 +- .../application/operations.ts | 16 + .../application/recovery-and-mappings.ts | 4 + .../workspace-forking/application/revision.ts | 22 +- .../application/sync-default.test.ts | 137 + .../application/sync-default.ts | 163 + .../fork-sync-default-toggle.tsx | 81 + .../components/fork-sync/fork-sync-view.tsx | 14 +- .../fork-synced-workflows.test.ts | 59 + .../fork-synced-workflows.tsx} | 132 +- .../ee/workspace-forking/components/forks.tsx | 14 +- .../workspace-forking/hooks/workspace-fork.ts | 54 +- .../lib/copy/copy-workflows.test.ts | 54 + .../lib/copy/copy-workflows.ts | 24 +- .../workspace-forking/lib/create-fork.test.ts | 70 +- .../ee/workspace-forking/lib/create-fork.ts | 56 +- .../lineage/fork-lock-order.integration.ts | 284 + .../workspace-forking/lib/lineage/lineage.ts | 42 + .../workspace-forking/lib/lineage/unlink.ts | 20 +- .../lib/promote/promote-plan.ts | 9 + .../workspace-forking/lib/promote/promote.ts | 3 + .../workspace-forking/lib/promote/rollback.ts | 1 + .../lib/sync-default.test.ts | 177 + .../ee/workspace-forking/lib/sync-default.ts | 187 + apps/sim/lib/api/contracts/workspace-fork.ts | 33 + .../member-document-lifecycle.integration.ts | 11 +- apps/sim/lib/posthog/events.ts | 11 + .../orchestration/workflow-lifecycle.ts | 11 + .../lib/workflows/persistence/duplicate.ts | 5 + packages/audit/src/types.ts | 1 + .../0391_fork_sync_new_workflow_default.sql | 4 + .../db/migrations/meta/0391_snapshot.json | 29399 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 19 + packages/testing/src/mocks/audit.mock.ts | 1 + .../src/mocks/schema-tables.generated.ts | 1 + .../mocks/workspace-forking-lineage.mock.ts | 2 + 42 files changed, 31121 insertions(+), 102 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts create mode 100644 apps/sim/ee/workspace-forking/application/sync-default.test.ts create mode 100644 apps/sim/ee/workspace-forking/application/sync-default.ts create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx create mode 100644 apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts rename apps/sim/ee/workspace-forking/components/{fork-excluded-workflows/fork-excluded-workflows.tsx => fork-synced-workflows/fork-synced-workflows.tsx} (65%) create mode 100644 apps/sim/ee/workspace-forking/lib/lineage/fork-lock-order.integration.ts create mode 100644 apps/sim/ee/workspace-forking/lib/sync-default.test.ts create mode 100644 apps/sim/ee/workspace-forking/lib/sync-default.ts create mode 100644 packages/db/migrations/0391_fork_sync_new_workflow_default.sql create mode 100644 packages/db/migrations/meta/0391_snapshot.json diff --git a/apps/docs/content/docs/platform/enterprise/forks.mdx b/apps/docs/content/docs/platform/enterprise/forks.mdx index 4c0851c53a0..94334615944 100644 --- a/apps/docs/content/docs/platform/enterprise/forks.mdx +++ b/apps/docs/content/docs/platform/enterprise/forks.mdx @@ -57,7 +57,7 @@ Everything under **Copy resources** starts **selected**. That is usually what yo Click **Fork**. The child workspace is created immediately. Deployed workflows land as **drafts** in the child. Large content (table rows, knowledge base files, file blobs) may finish copying in the background — watch **Activity** on the source workspace. - Only **deployed** workflows are forked. Drafts and undeployed work stay in the parent. If the parent has nothing deployed, the child starts with a blank starter workflow. + Only **deployed** workflows are forked, and only the ones that are [synced](#synced-workflows). Drafts and undeployed work stay in the parent. If there is nothing to copy, the child starts with a blank starter workflow. ### 3. Open the parent edge (from the child) @@ -117,16 +117,35 @@ On success you will see a toast such as **Pushed to "…"** or **Pulled from " --- -## Excluded workflows +## Synced workflows -The **Excluded workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure. Check a workflow — or a whole folder at once — to keep it out of forking entirely. Think of it as a `.gitignore` for syncs: +The **Synced workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure, each with a checkbox. **Checked means the workflow syncs.** Uncheck one — or a whole folder at once — to keep it out of forking entirely. Think of an unchecked workflow as `.gitignore`d: - **Never sent** — pushes from this workspace do not carry it, the other side pulling from this workspace does not receive it, and creating a new fork does not copy it -- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side +- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side. It stays deployed and keeps serving, and a previously-synced counterpart on the other side keeps running on its last deployed version. -The setting belongs to **this workspace's copy** only. Excluding a workflow here does not exclude its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so un-excluding later resumes updating the same counterpart instead of creating a duplicate. +The checkbox list belongs to **this workspace's copy** only. Unchecking a workflow here does not unsync its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so re-checking later resumes updating the same counterpart instead of creating a duplicate. -**Example:** a staging fork excludes `Scratch experiment` so it can never reach production, and production excludes `Billing hotfix` so no push from staging can ever overwrite it. +On the sync page, unsynced workflows still appear in the **Deployed workflows** list, greyed out, with a tooltip naming which workspace they are unsynced in. The sync will not touch them. + +**Example:** a staging fork leaves `Scratch experiment` unchecked so it can never reach production, and production leaves `Billing hotfix` unchecked so no push from staging can ever overwrite it. + +### Sync new workflows by default + +Above the list, **Sync new workflows by default** decides where a **newly created** workflow starts: + +| Setting | A new workflow… | +|---------|-----------------| +| **On** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it | +| **Off** | starts outside fork sync — it arrives unchecked and only syncs after you check it | + +Three things to know: + +- **It applies to the whole fork lineage.** The toggle writes every workspace in the lineage — the root, every ancestor, every descendant — so a parent and its forks can never disagree about what "new" means. Any workspace admin in the lineage can change it, and each member gets its own audit entry naming the workspace the change came from. A new fork inherits the value at creation. +- **It is forward-only.** Flipping it never moves an existing workflow in or out of sync. The checkbox list above stays the record of what syncs. +- **"New" means genuinely new.** Creating, duplicating, or importing a workflow takes this setting, as does the blank starter workflow a fork gets when there is nothing to copy. A workflow that arrives as a **copy** — from a fork, or from a push or pull — inherits its source's own checkbox instead, so a workflow you deliberately synced never lands unsynced in the child. + +**Example:** a template workspace turns this off so every scratch workflow the team creates stays local, then checks only the handful meant to reach the forks. --- @@ -155,6 +174,8 @@ Expand a row for names of workflows and resources that were created, updated, or |--------|-----| | See Forks / create a fork | Admin on this workspace (+ feature available) | | Sync / edit mappings | Admin on **both** sides of the edge | +| Check / uncheck **Synced workflows** | Admin on the workspace those workflows live in | +| Change **Sync new workflows by default** | Admin on any one workspace in the lineage — the change applies to every member | | Rollback | Admin on the workspace the sync landed in | | Disconnect | Admin on **this** side only (you can disconnect even without access to the other workspace) | | Open the other workspace | You must be a member of that workspace | @@ -169,9 +190,9 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a | Resource | Fork | Sync | |----------|------|------| -| Deployed workflows | Copied as drafts (unless excluded) | Updated / created / archived (force overwrite) | +| Deployed workflows | Copied as drafts when [synced](#synced-workflows) | Updated / created / archived (force overwrite) | | Undeployed workflows | Not copied | Not synced | -| [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived | +| [Unsynced workflows](#synced-workflows) | Never | Never — not sent, not overwritten, not archived | | Files | Optional copy (default on) | Map or copy | | File folders referenced by workflows | Mirrored with their ancestor folders, even when empty | Map by canonical path | | Tables | Optional copy (default on) | Map or copy | @@ -191,11 +212,11 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a ### Workflows -Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits. Workflows marked [excluded](#excluded-workflows) never move in either direction. +Only **deployed** workflows move, and only the ones checked under [Synced workflows](#synced-workflows). An unsynced workflow never moves in either direction. | Feature | Behavior | |---|----------| -| **Fork** | Each deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. | +| **Fork** | Each synced deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. | | **Sync** | The change list shows what will be updated, created, or archived. The target is overwritten for those workflows. | **Example:** Parent has `Support triage` deployed and `WIP experiment` as a draft. The fork gets only `Support triage` as a draft. A later push updates the child from the parent’s latest deploy of `Support triage`. @@ -370,13 +391,16 @@ Schedules, webhooks, and triggers are not live in the child until you **deploy** - **Rollback ≠ undo copies** — Workflow versions roll back; copied resources can remain as orphans. - **Disconnect is permanent** — You cannot “reconnect” the same edge; you would fork again into a new workspace. - **No grandparent sync** — Only the direct parent↔child pair. +- **The sync default is lineage-wide** — **Sync new workflows by default** is one shared setting for the whole lineage, so changing it from a fork also changes it in the parent and every sibling fork. --- --- diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 7ec0ceb17bf..f84d160f6e5 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -18,6 +18,7 @@ import { } from '@/lib/workflows/persistence/utils' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('SuperUserImportWorkflow') @@ -149,6 +150,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { isDeployed: false, // Never copy deployment status runCount: 0, variables: sourceWorkflow.variables || {}, + // An imported workflow is a NEW workflow in the target workspace, so it takes that + // workspace's fork-sync policy rather than the column default. + forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, targetWorkspaceId), }) // Save using existing persistence logic diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 3f4290a8b0d..5e0b85791bc 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -41,6 +41,7 @@ import { notFoundResponse, } from '@/app/api/v1/admin/responses' import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types' +import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('AdminWorkflowImportAPI') @@ -128,6 +129,10 @@ export const POST = withRouteHandler( isDeployed: false, runCount: 0, variables: {}, + // An imported workflow is a NEW workflow in this workspace, so it takes the + // workspace's fork-sync policy. Without this it lands on the column default and + // silently joins fork sync in a workspace that opted out. + forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId), }) /** diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 33f94c17a08..12b26db0c28 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -62,6 +62,7 @@ import type { WorkspaceImportRequest, WorkspaceImportResponse, } from '@/app/api/v1/admin/types' +import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('AdminWorkspaceImportAPI') @@ -363,6 +364,10 @@ async function importSingleWorkflow( isDeployed: false, runCount: 0, variables: {}, + // An imported workflow is a NEW workflow in this workspace, so it takes the + // workspace's fork-sync policy. Without this it lands on the column default and + // silently joins fork sync in a workspace that opted out. + forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId), }) /** diff --git a/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts new file mode 100644 index 00000000000..1f9cdaf9d27 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts @@ -0,0 +1,29 @@ +import { updateForkSyncDefaultContract } from '@/lib/api/contracts/workspace-fork' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default' + +export const PUT = defineInternalJsonRoute({ + contract: updateForkSyncDefaultContract, + auth: internalSessionAuth, + operation: forkOperations.syncDefault, + /** + * Rated, unlike its sibling fork routes. This is the one that writes workspaces the + * caller may not administer, under the feature's coarsest advisory lock, so an admin of + * any single lineage member could otherwise loop it and starve fork creation across the + * whole lineage. + */ + rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-sync-default' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + present: ({ excludeNewWorkflows, changedWorkspaces }) => ({ + excludeNewWorkflows, + workspacesUpdated: changedWorkspaces.length, + }), + useCase: setForkSyncDefault, +}) diff --git a/apps/sim/ee/workspace-forking/application/lineage-details.ts b/apps/sim/ee/workspace-forking/application/lineage-details.ts index d7656da859c..a2143b28b91 100644 --- a/apps/sim/ee/workspace-forking/application/lineage-details.ts +++ b/apps/sim/ee/workspace-forking/application/lineage-details.ts @@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm' import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' +import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' /** * Annotates a lineage node with whether the viewer holds any access to it (explicit @@ -34,10 +35,12 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({ context: { userId: string } }) { const { workspaceId } = input - const [rawParent, rawChildren, run] = await Promise.all([ + const [rawParent, rawChildren, run, forkSyncNewWorkflowsExcluded] = await Promise.all([ getForkParent(workspaceId), getForkChildren(workspaceId), getUndoableRunForTarget(db, workspaceId), + // Lineage-uniform, so this workspace's own value is the lineage's value. + resolveForkSyncExclusionForNewWorkflow(db, workspaceId), ]) const [parent, children] = await Promise.all([ @@ -71,6 +74,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({ createdAt: child.createdAt.toISOString(), })), undoableRun, + forkSyncNewWorkflowsExcluded, } }, }) diff --git a/apps/sim/ee/workspace-forking/application/operations.ts b/apps/sim/ee/workspace-forking/application/operations.ts index bdc5871fe89..27759c9e9b5 100644 --- a/apps/sim/ee/workspace-forking/application/operations.ts +++ b/apps/sim/ee/workspace-forking/application/operations.ts @@ -98,4 +98,20 @@ export const forkOperations = { id: 'workspaces.fork.exclusions', oauthScope: 'api:write', }), + /** + * Admin on the CALLING workspace is sufficient, and the write then fans out to every + * ancestor and descendant, because the default is meaningless unless it is uniform + * across a lineage. Flipping it to "sync new workflows" restores the historical + * behaviour rather than granting anything new, and it never moves an existing workflow + * in or out of sync - so each member records its own audit entry rather than the write + * being restricted to one workspace. + * + * permission-group-exempt: the new-workflow fork-sync default is workspace configuration governed by the admin role. + */ + syncDefault: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.sync_default', + oauthScope: 'api:write', + }), } as const diff --git a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts index 0b8a5898a79..eb519e299bc 100644 --- a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts +++ b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts @@ -52,6 +52,10 @@ export const updateWorkspaceForkMappings = defineForkUseCase< input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId return db.transaction(async (tx) => { await setForkLockTimeout(tx) + // Rank 4 - see the rank table on `acquireForkLineageLock`. Unlike promote and + // rollback this takes no rank-3 target lock: it rewrites only this edge's mapping + // rows, never the target's workflows, so nothing contends with a sync into the + // target. Skipping a higher rank is not an ordering violation. await acquireForkEdgeLock(tx, edge.childWorkspaceId) const [currentEdge] = await tx .select({ parentId: workspace.forkedFromWorkspaceId }) diff --git a/apps/sim/ee/workspace-forking/application/revision.ts b/apps/sim/ee/workspace-forking/application/revision.ts index cb3fb69beeb..4ca0fb3b96d 100644 --- a/apps/sim/ee/workspace-forking/application/revision.ts +++ b/apps/sim/ee/workspace-forking/application/revision.ts @@ -140,7 +140,15 @@ export async function loadForkPreviewRevision( } } -/** Locks normalized graph rows as well as workflow metadata, including realtime-only writes. */ +/** + * Locks normalized graph rows as well as workflow metadata, including realtime-only writes. + * + * Rank 5 - the heaviest acquirer in the fork module, and the one the rank table on + * `acquireForkLineageLock` exists for. It takes `FOR UPDATE` on the `workspace` rows, so + * any caller that also needs the rank-2 lineage lock must take that one FIRST; doing it + * the other way round deadlocks against `unlinkForkEdge`, which holds the lineage key and + * then updates the same `workspace` row. + */ export async function lockForkRevision(tx: DbTransaction, scope: ForkRevisionScope): Promise { const workspaceIds = [ ...new Set([ @@ -199,11 +207,21 @@ export async function assertForkSourceVersions( sourceWorkspaceId: string, expected: ReadonlyMap ): Promise { + // Verify exactly the workflows that were ADMITTED, rather than re-deriving the source + // predicate here. Re-deriving it duplicated `listDeployedWorkflows`'s filter, so the day + // a caller admitted a different set - "Copy unsynced workflows" admits sync-excluded + // workflows - this query returned fewer rows and every such fork failed on a phantom + // size mismatch. Keying off `expected` cannot drift from the admitted set by construction. + if (expected.size === 0) return + const admittedIds = sql.join( + [...expected.keys()].map((id) => sql`${id}`), + sql`, ` + ) const rows = await tx.execute<{ workflowId: string; id: string; digest: string }>(sql` SELECT w.id AS "workflowId", d.id, md5(d.state::text) AS digest FROM ${workflow} w JOIN ${workflowDeploymentVersion} d ON d.workflow_id = w.id AND d.is_active = true WHERE w.workspace_id = ${sourceWorkspaceId} AND w.is_deployed = true - AND w.archived_at IS NULL AND w.fork_sync_excluded = false + AND w.archived_at IS NULL AND w.id IN (${admittedIds}) `) if ( rows.length !== expected.size || diff --git a/apps/sim/ee/workspace-forking/application/sync-default.test.ts b/apps/sim/ee/workspace-forking/application/sync-default.test.ts new file mode 100644 index 00000000000..953001acd26 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-default.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' +import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' +import { + workspaceAuthorizationMock, + workspaceAuthorizationMockFns, +} from '@sim/testing/mocks/workspace-authorization.mock' +import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock' +import { workspaceForkingLineageMock } from '@sim/testing/mocks/workspace-forking-lineage.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hoisted = vi.hoisted(() => ({ + resolveRootId: vi.fn(), + resolveLineage: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) +vi.mock('@/ee/workspace-forking/lib/sync-default', () => ({ + resolveForkLineageRootId: hoisted.resolveRootId, + resolveForkLineageWorkspaceIds: hoisted.resolveLineage, +})) + +import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default' + +const principal = createSessionPrincipal({ userId: 'actor-1' }) +const LINEAGE = ['root-ws', 'fork-a', 'fork-b', 'grandchild'] + +beforeEach(() => { + resetDbChainMock() + vi.clearAllMocks() + permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'fork-a', + name: 'Fork A', + organizationId: null, + allowPersonalApiKeys: true, + }) + workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) + hoisted.resolveRootId.mockResolvedValue('root-ws') + hoisted.resolveLineage.mockResolvedValue(LINEAGE) + // The use case's `UPDATE ... RETURNING` over the global @sim/db mock: the rows it returns + // are the lineage members whose value actually changed, each with the name the audit + // entry is filed under. + dbChainMockFns.returning.mockResolvedValue(LINEAGE.map((id) => ({ id, name: `Name of ${id}` }))) +}) + +const run = (excludeNewWorkflows: boolean) => + setForkSyncDefault.execute({ + principal, + input: { workspaceId: 'fork-a', excludeNewWorkflows }, + }) + +describe('setForkSyncDefault', () => { + it('writes every lineage member when issued from a mid-lineage fork', async () => { + await expect(run(true)).resolves.toMatchObject({ + excludeNewWorkflows: true, + changedWorkspaces: LINEAGE.map((id) => ({ id, name: `Name of ${id}` })), + }) + }) + + /** + * One entry per member, because the default genuinely changed for all of them. A single + * entry on the calling workspace would leave the other members' admins with no record. + */ + it('records one entry per CHANGED member, naming where the change was issued from', async () => { + await run(true) + const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry) + expect(audited).toHaveLength(LINEAGE.length) + expect(audited.map((entry) => entry.resourceId).sort()).toEqual([...LINEAGE].sort()) + for (const entry of audited) { + expect(entry.action).toBe('workspace.fork_sync_default_changed') + // Filed in the workspace it describes. Without an explicit workspaceId the wrapper + // defaults it to the caller's workspace, so every entry would pile into one log and + // the other members' admins would see nothing. + expect(entry.workspaceId).toBe(entry.resourceId) + // The member's OWN name, not a raw id and not the caller's name. A lineage-wide + // change that named only the calling workspace left every other member's audit + // entry reading as an opaque identifier. + expect(entry.resourceName).toBe(`Name of ${entry.resourceId}`) + expect(entry.metadata).toMatchObject({ + forkSyncNewWorkflowsExcluded: true, + originWorkspaceId: 'fork-a', + originWorkspaceName: 'Fork A', + }) + } + }) + + /** + * `projectAudit` maps over `changedWorkspaces` and `afterSuccess` returns early when it + * is empty, so an empty result IS "no audit, no analytics". Asserting the result rather + * than the mocks' call counts keeps this pinned to the value the fan-out reads. + */ + it('reports no changed members when the value already matched everywhere', async () => { + dbChainMockFns.returning.mockResolvedValue([]) + await expect(run(true)).resolves.toMatchObject({ changedWorkspaces: [] }) + }) + + /** + * The update only touches members whose value differs, so auditing the whole lineage + * would file a change record against a workspace that already held the requested value. + */ + it('records nothing for a member that already held the requested value', async () => { + dbChainMockFns.returning.mockResolvedValue([{ id: 'fork-b', name: 'Fork B' }]) + await expect(run(true)).resolves.toMatchObject({ + changedWorkspaces: [{ id: 'fork-b', name: 'Fork B' }], + }) + const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry) + expect(audited.map((entry) => entry.resourceId)).toEqual(['fork-b']) + }) + + /** + * The fan-out reaches workspaces the caller may not administer, so the admission check + * is the only thing standing between a non-admin and a lineage-wide write. Assert it + * rejects rather than trusting that the wrapper was wired up. + */ + it('rejects a caller who fails workspace admission', async () => { + workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockRejectedValue( + new Error('forbidden') + ) + await expect(run(true)).rejects.toThrow('forbidden') + }) + + it('carries the chosen value through, so turning the default back on is symmetric', async () => { + await expect(run(false)).resolves.toMatchObject({ excludeNewWorkflows: false }) + const [entry] = auditMockFns.mockRecordAudit.mock.calls[0] + expect(entry.metadata).toMatchObject({ forkSyncNewWorkflowsExcluded: false }) + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/sync-default.ts b/apps/sim/ee/workspace-forking/application/sync-default.ts new file mode 100644 index 00000000000..1b8ea739670 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-default.ts @@ -0,0 +1,163 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { compareStrings } from '@sim/utils/string' +import { and, inArray, isNull, ne, sql } from 'drizzle-orm' +import { captureServerEvent } from '@/lib/posthog/server' +import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' +import { + acquireForkLineageLock, + setForkLockTimeout, +} from '@/ee/workspace-forking/lib/lineage/lineage' +import { + resolveForkLineageRootId, + resolveForkLineageWorkspaceIds, +} from '@/ee/workspace-forking/lib/sync-default' + +export interface SetForkSyncDefaultInput { + workspaceId: string + excludeNewWorkflows: boolean +} + +export interface SetForkSyncDefaultResult { + excludeNewWorkflows: boolean + /** + * Exactly the lineage members whose value changed, empty when it already matched + * everywhere. The audit fan-out projects one entry per element, and the surface derives + * its `workspacesUpdated` count from the length - one source of truth, so the count can + * never disagree with the entries actually filed. + */ + changedWorkspaces: Array<{ id: string; name: string }> +} + +/** + * Set whether newly created workflows start OUTSIDE fork sync, for an entire fork lineage. + * + * Admin on the calling workspace is enough: the default is meaningless unless it is + * uniform across a lineage, so the write fans out to every ancestor and descendant. It is + * forward-only - no existing workflow's `forkSyncExcluded` is touched, so flipping it can + * never move a workflow in or out of sync behind an admin's back. + * + * Serialized on the lineage-root advisory lock, which fork creation also takes, so a fork + * created concurrently cannot inherit a stale value. + */ +export const setForkSyncDefault = defineForkUseCase< + typeof forkOperations.syncDefault, + SetForkSyncDefaultInput, + SetForkSyncDefaultResult +>({ + operation: forkOperations.syncDefault, + async execute({ input }) { + return db.transaction(async (tx) => { + await setForkLockTimeout(tx) + const rootId = await resolveForkLineageRootId(tx, input.workspaceId) + // Rank 2, and the only fork lock this transaction takes before its rank-6 row + // locks - see the rank table on `acquireForkLineageLock`. + await acquireForkLineageLock(tx, rootId) + // Re-resolve under the lock and refuse if the root moved. An unlink committing + // between the read and the lock would leave us holding the OLD lineage's key while + // writing the new one's members, so a second write rooted at the new lineage could + // run concurrently over the same rows. Mirrors the organization re-check `createFork` + // performs under its own lock. + if ((await resolveForkLineageRootId(tx, input.workspaceId)) !== rootId) { + throw new ForkError( + 'The fork lineage changed while this request was being applied. Try again.', + 409 + ) + } + // Resolve the membership AFTER the lock: a fork created a moment ago must be + // included, and one being created right now is blocked on the same key. + const lineageWorkspaceIds = await resolveForkLineageWorkspaceIds(tx, input.workspaceId) + if (lineageWorkspaceIds.length === 0) { + return { excludeNewWorkflows: input.excludeNewWorkflows, changedWorkspaces: [] } + } + // Rank 6: take the member row locks in sorted id order BEFORE the update. A bare + // multi-row `UPDATE ... WHERE id IN (...)` acquires its row locks in whatever order + // the plan produces, so it is unordered against any other multi-workspace writer + // that takes no lineage lock - `lockWorkspaceRowsForPayerChanges` on the + // organization-attach path is one today. `revision.ts` locks this same table with an + // explicit `ORDER BY id FOR UPDATE` for exactly this reason; Drizzle's + // `.update().where(inArray(...))` cannot express ORDER BY, so this is the same + // lock-then-update shape. + const memberIds = sql.join( + [...lineageWorkspaceIds].sort(compareStrings).map((id) => sql`${id}`), + sql`, ` + ) + await tx.execute( + sql`SELECT id FROM ${workspace} WHERE id IN (${memberIds}) ORDER BY id FOR UPDATE` + ) + const changed = await tx + .update(workspace) + .set({ forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows, updatedAt: new Date() }) + .where( + and( + inArray(workspace.id, lineageWorkspaceIds), + // Re-assert liveness at write time: `resolveForkLineageWorkspaceIds` filtered + // archived members when it read, but a workspace can be archived between that + // read and this update, and a policy write must never touch one. + isNull(workspace.archivedAt), + ne(workspace.forkSyncNewWorkflowsExcluded, input.excludeNewWorkflows) + ) + ) + // Return the NAME too, so the audit fan-out can identify each member the way a + // reader knows it. Projecting a bare id as `resourceName` made every entry but the + // caller's read as a raw identifier in the audit log. + .returning({ id: workspace.id, name: workspace.name }) + return { + excludeNewWorkflows: input.excludeNewWorkflows, + // The members whose value ACTUALLY changed, not every member considered. Auditing + // the whole lineage would file a change record against a workspace that already + // held the requested value, making its history claim something that did not happen. + changedWorkspaces: changed, + } + }) + }, + /** + * One entry per workspace whose value actually changed. Every such member gets its own + * record, because the default genuinely moved for each of them and a single entry on the + * calling workspace would leave the others' admins with no trace. A member that already + * held the requested value gets nothing - it did not change. + */ + projectAudit: ({ input, context, result }) => + result.changedWorkspaces.map((member) => ({ + action: AuditAction.WORKSPACE_FORK_SYNC_DEFAULT_CHANGED, + // File each entry in the workspace it describes, not the caller's. The audit + // wrapper defaults `workspaceId` to the initiating workspace, which would land + // every entry in one log and leave the other members' admins with no record of + // their own workspace changing - the exact gap this per-member fan-out exists + // to close. + workspaceId: member.id, + resourceType: AuditResourceType.WORKSPACE, + resourceId: member.id, + // The member's own name, read back from the UPDATE. Falling back to the id for + // every member but the caller made a lineage-wide change read as one named + // workspace and N opaque identifiers. + resourceName: member.name, + description: input.excludeNewWorkflows + ? 'New workflows no longer sync to forks by default' + : 'New workflows sync to forks by default', + metadata: { + forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows, + // Which workspace in the lineage the admin changed it from, so a member's own + // log explains why its behaviour moved without an action taken on it. + originWorkspaceId: context.workspace.id, + originWorkspaceName: context.workspace.name, + workspacesChanged: result.changedWorkspaces.length, + }, + })), + afterSuccess({ context, input, result }) { + if (result.changedWorkspaces.length === 0) return + captureServerEvent( + context.userId, + 'fork_sync_default_updated', + { + workspace_id: input.workspaceId, + fork_sync_new_workflows_excluded: input.excludeNewWorkflows, + workspaces_updated: result.changedWorkspaces.length, + }, + { groups: { workspace: input.workspaceId } } + ) + }, +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx new file mode 100644 index 00000000000..7e3fa15cf97 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx @@ -0,0 +1,81 @@ +'use client' + +import { ChipSwitch, Label, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useUpdateForkSyncDefault } from '@/ee/workspace-forking/hooks/workspace-fork' + +/** Both outcomes named, so "off" does not have to be inferred from the label. */ +const FORK_SYNC_DEFAULT_OPTIONS = [ + { value: 'sync', label: 'Sync' }, + { value: 'exclude', label: "Don't sync" }, +] as const + +interface ForkSyncDefaultToggleProps { + workspaceId: string + /** The lineage's stored policy: true means new workflows start outside fork sync. */ + excludeNewWorkflows: boolean + /** + * True while the value on screen is not this workspace's own. Covers the first load AND + * the placeholder window after a workspace switch: `useForkLineage` sets + * `placeholderData: keepPreviousData`, so it serves the PREVIOUS workspace's policy with + * `isLoading: false`. Rendering then would show one workspace's value under another's + * name, and a click would write that stale value to the newly selected lineage. + */ + loading: boolean +} + +/** + * Whether a newly created workflow in this lineage joins fork sync automatically. + * + * Positive polarity, matching the checkbox list below it - on means new workflows sync, + * which is the historical default. The stored column is the negative + * `forkSyncNewWorkflowsExcluded`, so this component owns that inversion. + * + * The description is not decoration: this writes to every workspace in the lineage, and + * without it the control reads as a local preference. That is the "prevents a + * misunderstanding" case `sim-ui-copy.md` reserves supporting copy for. + * + * Forward-only - no existing workflow's checkbox moves, so flipping it can never silently + * pull a workflow into or out of sync. + */ +export function ForkSyncDefaultToggle({ + workspaceId, + excludeNewWorkflows, + loading, +}: ForkSyncDefaultToggleProps) { + const updateDefault = useUpdateForkSyncDefault() + + // Render nothing until the lineage resolves, matching the workflow list below. A + // placeholder would have to guess a value, and guessing `false` selects "Sync" - the + // opposite of the truth for an opt-in lineage, which then visibly snaps once the real + // value lands. Disabled-but-wrong is worse than absent for a cross-workspace policy. + if (loading) return null + + return ( +
+
+ {/* No `htmlFor`: `ChipSwitch` is a radio group and takes no id, so the group + carries its own `aria-label`. Matches `inbox-enable-toggle.tsx`. */} + +

+ Applies to every workspace in this fork lineage. +

+
+ + updateDefault.mutate( + { workspaceId, body: { excludeNewWorkflows: value === 'exclude' } }, + { + onError: (error) => + toast.error(getErrorMessage(error, 'Failed to update the fork sync default')), + } + ) + } + /> +
+ ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index d4309868a56..df4cf34dc98 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -812,20 +812,20 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp controller.inlineSecretCount > 0 || controller.triggerUrlChanges.length > 0 - // Excluded workflows render greyed in the change list. Orient each name's tooltip - // to WHERE it is excluded (that's the only place it can be re-included): the sync's - // source is this workspace on push and the other workspace on pull. + // Unsynced workflows render greyed in the change list. Orient each name's tooltip + // to WHERE it is unsynced (that's the only place it can be re-selected, under Synced + // workflows): the sync's source is this workspace on push and the other on pull. const excludedRows = [ ...(controller.direction === 'push' ? controller.excludedSourceWorkflows : controller.excludedTargetWorkflows - ).map((name) => ({ name, tooltip: 'Excluded from sync' })), + ).map((name) => ({ name, tooltip: 'Not synced' })), ...(controller.direction === 'push' ? controller.excludedTargetWorkflows : controller.excludedSourceWorkflows ).map((name) => ({ name, - tooltip: `Excluded from sync in "${controller.otherWorkspaceName}"`, + tooltip: `Not synced in "${controller.otherWorkspaceName}"`, })), ] @@ -862,8 +862,8 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp {/* Always shown once the diff loads so the user sees the section even with nothing deployed - an empty change list means the source has no deployed workflows (every deployed workflow appears here, changed or not), so the muted state nudges a deploy. - Sync-excluded workflows list greyed at the end, with a tooltip naming where the - exclusion lives - the sync will not touch them. */} + Unsynced workflows list greyed at the end, with a tooltip naming which workspace + they are unsynced in - the sync will not touch them. */} {controller.hasDiff ? ( {controller.workflowChanges.length + excludedRows.length > 0 ? ( diff --git a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts new file mode 100644 index 00000000000..6e75b29b2c1 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { buildForkSyncWorkflowTree } from '@/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows' + +const wf = (over: Record) => ({ + id: 'w', + name: 'W', + isDeployed: true, + archivedAt: null, + folderId: null, + forkSyncExcluded: false, + ...over, +}) + +describe('buildForkSyncWorkflowTree', () => { + it('lists only deployed, non-archived workflows — the only ones that sync', () => { + const tree = buildForkSyncWorkflowTree( + [ + wf({ id: 'live', name: 'Live' }), + wf({ id: 'draft', name: 'Draft', isDeployed: false }), + wf({ id: 'gone', name: 'Gone', archivedAt: new Date() }), + ] as never, + [] + ) + expect(tree.rootWorkflows.map((w) => w.id)).toEqual(['live']) + }) + + it('falls a workflow whose folder was deleted back to root so it stays selectable', () => { + const tree = buildForkSyncWorkflowTree([wf({ id: 'orphan', folderId: 'missing' })] as never, []) + expect(tree.rootWorkflows.map((w) => w.id)).toEqual(['orphan']) + }) + + it('prunes folders with no deployed workflows anywhere beneath them', () => { + const folders = [ + { id: 'f-empty', name: 'Empty', parentId: null, sortOrder: 0 }, + { id: 'f-full', name: 'Full', parentId: null, sortOrder: 1 }, + ] + const tree = buildForkSyncWorkflowTree( + [wf({ id: 'a', folderId: 'f-full' })] as never, + folders as never + ) + expect(tree.folders.map((f) => f.id)).toEqual(['f-full']) + }) + + /** + * The folder-level select-all writes this list verbatim, so a subtree that misses a + * nested workflow silently leaves it out of a "sync all in this folder" click. + */ + it('collects nested descendants into the parent folder select-all list', () => { + const folders = [ + { id: 'parent', name: 'Parent', parentId: null, sortOrder: 0 }, + { id: 'child', name: 'Child', parentId: 'parent', sortOrder: 0 }, + ] + const tree = buildForkSyncWorkflowTree( + [wf({ id: 'top', folderId: 'parent' }), wf({ id: 'nested', folderId: 'child' })] as never, + folders as never + ) + expect([...tree.folders[0].descendantWorkflowIds].sort()).toEqual(['nested', 'top']) + }) +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx similarity index 65% rename from apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx rename to apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx index 80d50291557..b6014a11c5b 100644 --- a/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx @@ -4,7 +4,7 @@ import { useId, useMemo, useState } from 'react' import { Checkbox, ChevronDown, cn, OverflowText, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { useUpdateForkExcludedWorkflows } from '@/ee/workspace-forking/hooks/workspace-fork' +import { useUpdateForkSyncedWorkflows } from '@/ee/workspace-forking/hooks/workspace-fork' import { useFolders } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' import type { WorkflowFolder } from '@/stores/folders/types' @@ -16,16 +16,16 @@ const INDENT_PER_LEVEL = 20 /** Guide-line offset within a level: the horizontal center of the `sm` checkbox. */ const GUIDE_OFFSET = 7 -interface ExcludedWorkflowItem { +interface SyncWorkflowItem { id: string name: string } -interface ExcludedTreeFolder { +interface SyncTreeFolder { id: string name: string - children: ExcludedTreeFolder[] - workflows: ExcludedWorkflowItem[] + children: SyncTreeFolder[] + workflows: SyncWorkflowItem[] /** Every deployed workflow id in this folder's subtree, for the folder-level select-all. */ descendantWorkflowIds: string[] } @@ -36,10 +36,10 @@ interface ExcludedTreeFolder { * a workflow whose folder was deleted falls into the root bucket so it stays selectable. * Folders sort like the sidebar (sortOrder, then name); workflows keep the list order. */ -export function buildExcludedWorkflowTree( +export function buildForkSyncWorkflowTree( workflows: WorkflowMetadata[], folders: WorkflowFolder[] -): { folders: ExcludedTreeFolder[]; rootWorkflows: ExcludedWorkflowItem[] } { +): { folders: SyncTreeFolder[]; rootWorkflows: SyncWorkflowItem[] } { const folderById = new Map(folders.map((folder) => [folder.id, folder])) const childFolders = new Map() for (const folder of folders) { @@ -49,12 +49,12 @@ export function buildExcludedWorkflowTree( else childFolders.set(parentId, [folder]) } - const workflowsByFolder = new Map() + const workflowsByFolder = new Map() for (const workflow of workflows) { if (!workflow.isDeployed || workflow.archivedAt) continue const folderId = workflow.folderId && folderById.has(workflow.folderId) ? workflow.folderId : null - const item: ExcludedWorkflowItem = { id: workflow.id, name: workflow.name } + const item: SyncWorkflowItem = { id: workflow.id, name: workflow.name } const bucket = workflowsByFolder.get(folderId) if (bucket) bucket.push(item) else workflowsByFolder.set(folderId, [item]) @@ -63,10 +63,10 @@ export function buildExcludedWorkflowTree( const sortFolders = (list: WorkflowFolder[]) => [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - const buildFolder = (folder: WorkflowFolder): ExcludedTreeFolder | null => { + const buildFolder = (folder: WorkflowFolder): SyncTreeFolder | null => { const children = sortFolders(childFolders.get(folder.id) ?? []) .map(buildFolder) - .filter((child): child is ExcludedTreeFolder => child !== null) + .filter((child): child is SyncTreeFolder => child !== null) const own = workflowsByFolder.get(folder.id) ?? [] if (children.length === 0 && own.length === 0) return null return { @@ -84,54 +84,70 @@ export function buildExcludedWorkflowTree( return { folders: sortFolders(childFolders.get(null) ?? []) .map(buildFolder) - .filter((folder): folder is ExcludedTreeFolder => folder !== null), + .filter((folder): folder is SyncTreeFolder => folder !== null), rootWorkflows: workflowsByFolder.get(null) ?? [], } } -interface ForkExcludedWorkflowsProps { +interface ForkSyncedWorkflowsProps { workspaceId: string } /** - * The Forks page's "Excluded workflows" section body: the workspace's deployed - * workflows in their sidebar folder structure, each with a checkbox. Checked = - * excluded - the workflow never syncs to or from a fork and is not copied into - * new forks. A folder's checkbox toggles its whole subtree at once (tri-state - * while partially excluded). Toggles apply immediately. + * The Forks page's "Synced workflows" section body: the workspace's deployed workflows + * in their sidebar folder structure, each with a checkbox. Checked = SYNCED - the + * workflow participates in promote in both directions and is copied into new forks. + * Unchecked leaves it in this workspace only; it stays deployed and serving, and a + * previously-synced counterpart in another workspace keeps running on its last deployed + * version rather than being archived. A folder's checkbox toggles its whole subtree at + * once (tri-state while partially synced). Toggles apply immediately. + * + * The wire field stays `forkSyncExcluded` (matching the column), so this component owns + * the single inversion between the stored flag and what the user reads. */ -export function ForkExcludedWorkflows({ workspaceId }: ForkExcludedWorkflowsProps) { +export function ForkSyncedWorkflows({ workspaceId }: ForkSyncedWorkflowsProps) { const workflowsQuery = useWorkflows(workspaceId) const foldersQuery = useFolders(workspaceId) - const updateExcluded = useUpdateForkExcludedWorkflows() + const updateSynced = useUpdateForkSyncedWorkflows() const workflows = workflowsQuery.data const folders = foldersQuery.data - const excludedIds = useMemo( + const syncedIds = useMemo( () => - new Set((workflows ?? []).filter((workflow) => workflow.forkSyncExcluded).map((w) => w.id)), + new Set((workflows ?? []).filter((workflow) => !workflow.forkSyncExcluded).map((w) => w.id)), [workflows] ) const tree = useMemo( - () => buildExcludedWorkflowTree(workflows ?? [], folders ?? []), + () => buildForkSyncWorkflowTree(workflows ?? [], folders ?? []), [workflows, folders] ) - const toggle = (workflowIds: string[], excluded: boolean) => { + const toggle = (workflowIds: string[], synced: boolean) => { // Send only real transitions so a folder select-all never writes no-op rows. - const changed = workflowIds.filter((id) => excludedIds.has(id) !== excluded) + const changed = workflowIds.filter((id) => syncedIds.has(id) !== synced) if (changed.length === 0) return - updateExcluded.mutate( - { workspaceId, body: { workflowIds: changed, forkSyncExcluded: excluded } }, + updateSynced.mutate( + { workspaceId, body: { workflowIds: changed, forkSyncExcluded: !synced } }, { onError: (error) => - toast.error(getErrorMessage(error, 'Failed to update excluded workflows')), + toast.error(getErrorMessage(error, 'Failed to update synced workflows')), } ) } - if (workflowsQuery.isLoading || foldersQuery.isLoading) return null + // `useWorkflows` and `useFolders` both set `placeholderData: keepPreviousData`, so during + // a workspace switch they serve the PREVIOUS workspace's rows with `isLoading: false`. + // Gating on loading alone rendered workspace A's workflows under workspace B's id, and a + // click then posted A's workflow ids against B. `sim-react-performance.md`: "Never carry + // placeholder data between protected resource keys ... an explicit loading state is + // truthful." Matches `custom-tools.tsx` and `integration-skills-section.tsx`. + const isLoading = + workflowsQuery.isPending || + workflowsQuery.isPlaceholderData || + foldersQuery.isPending || + foldersQuery.isPlaceholderData + if (isLoading) return null if (tree.folders.length === 0 && tree.rootWorkflows.length === 0) { return ( @@ -144,47 +160,41 @@ export function ForkExcludedWorkflows({ workspaceId }: ForkExcludedWorkflowsProp return (
{tree.folders.map((folder) => ( - ))} {tree.rootWorkflows.map((workflow) => ( - ))}
) } -interface ExcludedFolderRowProps { - folder: ExcludedTreeFolder +interface SyncFolderRowProps { + folder: SyncTreeFolder level: number - excludedIds: ReadonlySet - onToggle: (workflowIds: string[], excluded: boolean) => void + syncedIds: ReadonlySet + onToggle: (workflowIds: string[], synced: boolean) => void disabled: boolean } -function ExcludedFolderRow({ - folder, - level, - excludedIds, - onToggle, - disabled, -}: ExcludedFolderRowProps) { +function SyncFolderRow({ folder, level, syncedIds, onToggle, disabled }: SyncFolderRowProps) { const [expanded, setExpanded] = useState(true) const total = folder.descendantWorkflowIds.length - const selectedCount = folder.descendantWorkflowIds.filter((id) => excludedIds.has(id)).length + const selectedCount = folder.descendantWorkflowIds.filter((id) => syncedIds.has(id)).length const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' return ( @@ -195,7 +205,7 @@ function ExcludedFolderRow({ > onToggle(folder.descendantWorkflowIds, headerState !== true)} disabled={disabled} @@ -230,21 +240,21 @@ function ExcludedFolderRow({ />
{folder.children.map((child) => ( - ))} {folder.workflows.map((workflow) => ( - @@ -256,21 +266,15 @@ function ExcludedFolderRow({ ) } -interface ExcludedWorkflowRowProps { - workflow: ExcludedWorkflowItem +interface SyncWorkflowRowProps { + workflow: SyncWorkflowItem level: number - excludedIds: ReadonlySet - onToggle: (workflowIds: string[], excluded: boolean) => void + syncedIds: ReadonlySet + onToggle: (workflowIds: string[], synced: boolean) => void disabled: boolean } -function ExcludedWorkflowRow({ - workflow, - level, - excludedIds, - onToggle, - disabled, -}: ExcludedWorkflowRowProps) { +function SyncWorkflowRow({ workflow, level, syncedIds, onToggle, disabled }: SyncWorkflowRowProps) { const itemId = useId() return (
)} diff --git a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts index 614b59a333c..40017c8c5d3 100644 --- a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts +++ b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts @@ -3,6 +3,7 @@ import { requestJson } from '@/lib/api/client/request' import { type ForkWorkspaceBody, forkWorkspaceContract, + type GetForkLineageResponse, getForkDiffContract, getForkLineageContract, getForkMappingContract, @@ -14,9 +15,11 @@ import { type UnlinkForkBody, type UpdateForkExcludedWorkflowsBody, type UpdateForkMappingBody, + type UpdateForkSyncDefaultBody, unlinkForkContract, updateForkExcludedWorkflowsContract, updateForkMappingContract, + updateForkSyncDefaultContract, } from '@/lib/api/contracts/workspace-fork' import type { WorkspacesResponse } from '@/lib/api/contracts/workspaces' import { backgroundWorkKeys } from '@/ee/workspace-forking/hooks/background-work' @@ -213,11 +216,12 @@ export function useRollbackFork() { } /** - * Toggle "Exclude from sync" for a batch of workflows (one request per folder or - * row click in the Excluded workflows tree). Optimistically flips the flag in the + * Toggle fork-sync participation for a batch of workflows (one request per folder or + * row click in the Synced workflows tree). Optimistically flips the flag in the * workspace's cached workflow list so the tree responds instantly, then reconciles. + * Callers pass the stored `forkSyncExcluded` value; the tree owns the inversion. */ -export function useUpdateForkExcludedWorkflows() { +export function useUpdateForkSyncedWorkflows() { const queryClient = useQueryClient() return useMutation({ mutationFn: (vars: { workspaceId: string; body: UpdateForkExcludedWorkflowsBody }) => @@ -251,3 +255,47 @@ export function useUpdateForkExcludedWorkflows() { }, }) } + +/** + * Set whether newly created workflows sync to forks, for the whole fork lineage. + * + * The write reaches every ancestor and descendant, so the optimistic patch is + * deliberately scoped to THIS workspace's cached lineage - it is the only member whose + * value we can honestly claim to know before the server answers. Sibling members refresh + * from the invalidation below (or on their next load), rather than being optimistically + * rewritten from a page that cannot see them. + */ +export function useUpdateForkSyncDefault() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (vars: { workspaceId: string; body: UpdateForkSyncDefaultBody }) => + requestJson(updateForkSyncDefaultContract, { + params: { id: vars.workspaceId }, + body: vars.body, + }), + onMutate: async (vars) => { + const lineageKey = forkKeys.lineage(vars.workspaceId) + await queryClient.cancelQueries({ queryKey: lineageKey }) + const snapshot = queryClient.getQueryData(lineageKey) + if (snapshot) { + queryClient.setQueryData(lineageKey, { + ...snapshot, + forkSyncNewWorkflowsExcluded: vars.body.excludeNewWorkflows, + }) + } + return { snapshot } + }, + onError: (_error, vars, context) => { + if (context?.snapshot) { + queryClient.setQueryData(forkKeys.lineage(vars.workspaceId), context.snapshot) + } + }, + onSettled: (_data, _error, vars) => { + // Every lineage member's value moved, so refresh all cached lineages rather than + // just this workspace's. The fork modal's preflight counts are unaffected (the + // policy governs future workflows only), but its copy set is read per-open anyway. + queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) + queryClient.invalidateQueries({ queryKey: forkKeys.resources(vars.workspaceId) }) + }, + }) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index 00052299e3e..997a3eeb5f4 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -653,3 +653,57 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { expect(subBlocks.text?.value).toBe('true') }) }) + +describe('copyWorkflowStateIntoTarget fork-sync inheritance', () => { + const sourceState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as never + + /** `create` mode inserts the target row; capture exactly what it writes. */ + function stubCreateTx(captured: Record[]) { + return { + insert: () => ({ + values: (row: Record) => { + captured.push(row) + return Promise.resolve() + }, + }), + } as unknown as DbOrTx + } + + const createParams = (forkSyncExcluded: boolean) => ({ + targetWorkflowId: 'wf-new', + targetWorkspaceId: 'ws-target', + userId: 'target-user', + mode: 'create' as const, + now: new Date('2026-07-01'), + sourceState, + sourceMeta: { + name: 'Prod', + description: null, + folderId: null, + sortOrder: 0, + forkSyncExcluded, + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + nameRegistry: buildWorkflowNameRegistry([]), + resolveBlockId: (_targetWorkflowId: string, sourceBlockId: string) => `tgt-${sourceBlockId}`, + }) + + /** + * The regression the whole opt-in feature hinges on. A copy is not a new workflow, so it + * must never take the TARGET workspace's new-workflow default. In an opt-out lineage that + * default is "excluded", so taking it here would land a deliberately synced workflow's + * copy already excluded, and sync would never update it again. + */ + it('writes a synced copy rather than taking the target workspace default', async () => { + const rows: Record[] = [] + await copyWorkflowStateIntoTarget({ ...createParams(false), tx: stubCreateTx(rows) } as never) + expect(rows[0].forkSyncExcluded).toBe(false) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 50e3607de35..26747e82e85 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -207,9 +207,14 @@ export async function resolveForkFolderMapping({ * `MAX_FOLDERS_PER_WORKSPACE`, so a fork that mirrors a large source tree into an * already-populated target could otherwise leave the target unreadable. * - * Runs inside the fork transaction, after the `fork-target` advisory lock, so it is - * atomic against every other fork/promote into this target and a refusal rolls the - * whole copy back. It does NOT take the folder mutation lock: that helper resets the + * Runs inside the caller's transaction, so a refusal rolls the whole copy back. What + * makes it atomic differs by caller: `promoteFork` holds `fork-target` and `fork-edge`, + * which serializes it against every other promote into the same target. `createFork` + * holds neither - it does not need to, because the target workspace is being created in + * this same transaction and nothing else can reach it yet. (An earlier revision of this + * comment asserted the `fork-target` lock for both, which was true of only one.) + * + * It does NOT take the folder mutation lock in either case: that helper resets the * transaction's `lock_timeout`, which the fork sets deliberately, so an ordinary * concurrent `createFolder` can still slip a row in between the count and the insert. */ @@ -414,6 +419,18 @@ export interface CopyWorkflowStateParams { * the child starts undeployed and private (going public is an explicit act there). */ isPublicApi?: boolean + /** + * The source's own fork-sync participation, inherited verbatim by the copy. A copied + * workflow is the same logical workflow in another workspace, so it must NOT take the + * target workspace's new-workflow default: in an opt-in workspace that would land an + * explicitly-selected source's copy already excluded, and sync would never update it + * again. + * + * Required, not optional: a caller that forgot it would silently ATTACH the copy to + * sync via the column default, with no UI or audit signal. The type system carries + * that, not a comment. + */ + forkSyncExcluded: boolean } /** source workflow id -> target workflow id, for `workflow-selector` references */ workflowIdMap: Map @@ -722,6 +739,7 @@ export async function copyWorkflowStateIntoTarget( // Deployment visibility follows the source on sync (a public source stays public in // the target); fork-create omits the field, so the child starts private. ...(sourceMeta.isPublicApi !== undefined ? { isPublicApi: sourceMeta.isPublicApi } : {}), + forkSyncExcluded: sourceMeta.forkSyncExcluded, }) } else { await tx diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 5999cac6f80..7f69ba1f00b 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -1,7 +1,10 @@ import { workspace } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { workflowsPersistenceUtilsMock } from '@sim/testing/mocks/workflows-persistence-utils.mock' -import { workspaceForkingLineageMock } from '@sim/testing/mocks/workspace-forking-lineage.mock' +import { + workspaceForkingLineageMock, + workspaceForkingLineageMockFns, +} from '@sim/testing/mocks/workspace-forking-lineage.mock' import { workspaceForkingMappingStoreMock, workspaceForkingMappingStoreMockFns, @@ -19,6 +22,8 @@ const { mockFinishBackgroundWork, mockScheduleForkContentCopy, mockCollectReferencedFileFolderPaths, + mockResolveForkSyncExclusionForNewWorkflow, + mockLockForkRevision, } = vi.hoisted(() => ({ mockSumForkCopyBytes: vi.fn(), mockAssertForkStorageHeadroom: vi.fn(), @@ -29,6 +34,9 @@ const { mockFinishBackgroundWork: vi.fn(), mockScheduleForkContentCopy: vi.fn(), mockCollectReferencedFileFolderPaths: vi.fn(() => new Set()), + // Historical opt-out default unless a test opts the lineage in. + mockResolveForkSyncExclusionForNewWorkflow: vi.fn(async () => false), + mockLockForkRevision: vi.fn(async () => {}), })) vi.mock('@/lib/workflows/defaults', () => ({ @@ -72,6 +80,21 @@ vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ loadSourceDeployedStates: mockLoadSourceDeployedStates, })) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) +vi.mock('@/ee/workspace-forking/application/revision', () => ({ + lockForkRevision: mockLockForkRevision, + assertForkPreviewFresh: vi.fn(async () => {}), + assertForkSourceVersions: vi.fn(async () => {}), +})) +vi.mock('@/lib/workspaces/operations/receipts', () => ({ + findWorkspaceOperationReceipt: vi.fn(async () => null), + insertWorkspaceOperationReceipt: vi.fn(async () => {}), + lockWorkspaceOperationRequest: vi.fn(async () => {}), +})) +vi.mock('@/ee/workspace-forking/lib/sync-default', () => ({ + // The lineage root is resolved before the fork tx; a standalone source is its own root. + resolveForkLineageRootId: vi.fn(async (_executor: unknown, workspaceId: string) => workspaceId), + resolveForkSyncExclusionForNewWorkflow: mockResolveForkSyncExclusionForNewWorkflow, +})) vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ reconcileForkBlockPairs: vi.fn(), toForkBlockPairs: vi.fn(() => []), @@ -209,15 +232,56 @@ describe('createFork storage headroom gate', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) - it('preserves the source workspace personal API-key policy in the child', async () => { + /** + * Both inherited workspace policies in one fork. `forkSyncNewWorkflowsExcluded` is set + * to `true` (not the default) so the assertion cannot pass on a hardcoded `false`: the + * new-workflow fork-sync default is lineage-uniform, and a child that did not inherit it + * would disagree with its parent from the moment it exists. + */ + it('gives the child the source workspace personal API-key and fork-sync policies', async () => { + mockResolveForkSyncExclusionForNewWorkflow.mockResolvedValue(true) + const result = await createFork(forkParams()) expect(result.workspace.allowPersonalApiKeys).toBe(false) expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ allowPersonalApiKeys: false }) + expect.objectContaining({ + allowPersonalApiKeys: false, + forkSyncNewWorkflowsExcluded: true, + }) ) }) + /** + * The deadlock guard, asserted where the order actually lives. `fork-lock-order.integration.ts` + * proves this ORDER is the correct one against real Postgres; this proves `createFork` + * follows it. Without this, reordering these two calls would leave every check green: + * the integration suite drives the helpers itself and never calls `createFork`. + * + * `lockForkRevision` takes `FOR UPDATE` on the source `workspace` row, which + * `unlinkForkEdge` updates while holding the lineage key. Taking it first closes a + * genuine cycle. Ranks 2 then 5 in the table on `acquireForkLineageLock`. + */ + it('takes the lineage lock before the revision lock, closing the unlink deadlock', async () => { + await createFork({ + ...forkParams(), + admission: { + workspaceId: 'src-ws', + requestId: 'req-1', + requestHash: 'hash-1', + previewFingerprint: 'fp-1', + choices: {}, + }, + }) + + const lineageAt = + workspaceForkingLineageMockFns.mockAcquireForkLineageLock.mock.invocationCallOrder[0] + const revisionAt = mockLockForkRevision.mock.invocationCallOrder[0] + expect(lineageAt).toBeDefined() + expect(revisionAt).toBeDefined() + expect(lineageAt).toBeLessThan(revisionAt) + }) + it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => { mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]), diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 6b26e233324..f117239aa2e 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -59,7 +59,10 @@ import { import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map' import { copyForkWorkflowMcpAttachments } from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments' import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' -import { setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage' +import { + acquireForkLineageLock, + setForkLockTimeout, +} from '@/ee/workspace-forking/lib/lineage/lineage' import { type ForkBlockPair, reconcileForkBlockPairs, @@ -72,6 +75,10 @@ import { } from '@/ee/workspace-forking/lib/mapping/mapping-store' import { deriveForkBlockId } from '@/ee/workspace-forking/lib/remap/block-identity' import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' +import { + resolveForkLineageRootId, + resolveForkSyncExclusionForNewWorkflow, +} from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('WorkspaceForkCreate') @@ -167,6 +174,11 @@ export async function createFork(params: CreateForkParams): Promise lineage; unlink: lineage -> workspace row) and deadlock. + // See the rank table on `acquireForkLineageLock`. + // + // It is also what makes the child's inherited new-workflow fork-sync default correct: + // `setForkSyncDefault` fans that value out across the lineage under this same key, so + // a fork created mid-change cannot inherit a stale default. + await acquireForkLineageLock(tx, lineageRootId) + // The root was resolved before this transaction, so an unlink committing in between + // would leave us holding the OLD lineage's key while inserting into the new one - a + // concurrent policy write rooted at the new lineage could then miss this child + // entirely. Re-check under the lock and refuse if it moved, exactly as + // `setForkSyncDefault` does. The caller retries against the new lineage. + if ((await resolveForkLineageRootId(tx, source.id)) !== lineageRootId) { + throw new ForkError( + 'The source workspace changed fork lineage while this fork was being created. Try again.', + 409 + ) + } + if (admission) { await lockForkRevision(tx, { sourceWorkspaceId: source.id }) await assertForkPreviewFresh(tx, { sourceWorkspaceId: source.id }, admission) await assertForkSourceVersions(tx, source.id, sourceVersionIds) @@ -271,6 +311,12 @@ export async function createFork(params: CreateForkParams): Promise 0 ? parts.join(' | ') : getErrorMessage(error) +} + +interface CheckResult { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string +} +const report: CheckResult[] = [] + +describe('fork lock ordering in PostgreSQL', () => { + const testSchema = `fork_lock_${generateShortId() + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase()}` + let setup: ReturnType + const connections: ReturnType[] = [] + + /** + * A drizzle executor pinned to this suite's schema, on its own backend. The raw client + * comes back too so a race can close its own sessions; `connections` is the backstop that + * closes anything a failing test left open. + */ + function connect(): { executor: DbOrTx; client: ReturnType } { + const client = postgres(databaseUrl, { + max: 1, + connection: { search_path: testSchema }, + onnotice: () => {}, + }) + connections.push(client) + return { executor: drizzle(client, { schema }) as DbOrTx, client } + } + + beforeAll(async () => { + setup = postgres(databaseUrl, { max: 1, onnotice: () => {} }) + await setup.unsafe(`CREATE SCHEMA ${testSchema}`) + // Exactly the tables `lockForkRevision` locks, so the production helper runs unmodified. + await setup.unsafe(` + SET search_path TO ${testSchema}; + CREATE TABLE workspace (id text PRIMARY KEY, updated_at timestamp DEFAULT now()); + CREATE TABLE workflow (id text PRIMARY KEY, workspace_id text); + CREATE TABLE workflow_blocks (id text PRIMARY KEY, workflow_id text); + CREATE TABLE workflow_edges (id text PRIMARY KEY, workflow_id text); + CREATE TABLE workflow_subflows (id text PRIMARY KEY, workflow_id text); + CREATE TABLE workflow_deployment_version ( + id text PRIMARY KEY, workflow_id text, is_active boolean DEFAULT true + ); + INSERT INTO workspace (id) VALUES ('${SOURCE_WORKSPACE_ID}'); + INSERT INTO workflow (id, workspace_id) VALUES ('wf-1', '${SOURCE_WORKSPACE_ID}'); + INSERT INTO workflow_deployment_version (id, workflow_id) VALUES ('dv-1', 'wf-1'); + `) + }) + + afterAll(async () => { + await Promise.all(connections.map((client) => client.end({ timeout: 5 }))) + await setup.unsafe(`DROP SCHEMA IF EXISTS ${testSchema} CASCADE`) + await setup.end({ timeout: 5 }) + const path = process.env.FORK_LOCK_REPORT_PATH + if (path) { + const { writeFile } = await import('node:fs/promises') + await writeFile(path, JSON.stringify({ suite: 'fork-lock-order', checks: report }, null, 2)) + } + }) + + /** + * Waits until THIS unlink transaction's own backend is blocked on a lock, so the fork's + * second acquisition races a real holder rather than an arbitrary sleep. + * + * Filtered to one pid on purpose. Counting any Lock waiter on the database would let an + * unrelated session (a parallel suite, a leftover connection) release the barrier before + * the unlink had blocked, so the pre-fix control could proceed with its cycle still open + * and stop being a reliable negative. + * + * Both orders reach this state - pre-fix the unlink blocks on the row write, shipped it + * blocks on the lineage lock - which is what lets one barrier serve both. Never blocking + * means the race did not set up, so it throws rather than quietly proceeding. + */ + async function waitForBlockedBackend(pid: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const [row] = await setup` + SELECT count(*)::int AS blocked FROM pg_stat_activity + WHERE pid = ${pid} AND wait_event_type = 'Lock'` + if ((row?.blocked ?? 0) > 0) return + await sleep(50) + } + throw new Error(`Unlink backend ${pid} never blocked on a lock; the race did not set up`) + } + + /** + * Races the fork's lock sequence against the unlink's, holding the fork's first lock + * while the unlink runs so the two contend exactly as they do in production. + * + * `forkTakesLineageFirst` is the whole experiment: `true` is the shipped order, `false` + * reproduces the pre-fix one. Returns the error that aborted a session, or null. + */ + async function raceForkAgainstUnlink(forkTakesLineageFirst: boolean): Promise { + const { executor: forkDb, client: forkClient } = connect() + const { executor: unlinkDb, client: unlinkClient } = connect() + const forkHoldsFirstLock = createDeferred() + // `null` means the unlink never got far enough to report a backend, so there is nothing + // to wait on. Every barrier here is settled on the failure path as well as the happy + // one: an unsettled deferred turns a clean database error into a test timeout, which + // hides the very diagnostics a concurrency test exists to give. + const unlinkBackendPid = createDeferred() + const unlinkMayFinish = createDeferred() + let failure: string | null = null + + // The two locks `createFork` takes, as production takes them: rank 2 then rank 5. + const takeLineageLock = (tx: DbTransaction) => acquireForkLineageLock(tx, SOURCE_WORKSPACE_ID) + const takeRevisionLock = (tx: DbTransaction) => + lockForkRevision(tx, { sourceWorkspaceId: SOURCE_WORKSPACE_ID }) + + const forkSession = forkDb + .transaction(async (tx) => { + await setForkLockTimeout(tx) + await (forkTakesLineageFirst ? takeLineageLock(tx) : takeRevisionLock(tx)) + forkHoldsFirstLock.resolve() + // Only reach for the second lock once the unlink is demonstrably blocked, so the + // pre-fix cycle is closed rather than merely likely. + await unlinkMayFinish.promise + await (forkTakesLineageFirst ? takeRevisionLock(tx) : takeLineageLock(tx)) + }) + .catch((error: unknown) => { + failure ??= describeFailure(error) + }) + // Releases the barrier below if this session died before taking its first lock. + // `createDeferred`'s resolve is a no-op once settled, so the happy path is unchanged. + .finally(() => forkHoldsFirstLock.resolve()) + + const unlinkSession = forkHoldsFirstLock.promise + .then(() => + unlinkDb + .transaction(async (tx) => { + await setForkLockTimeout(tx) + // Publish this transaction's backend before it can block, so the barrier waits + // on exactly this session rather than on whatever else the database is doing. + const [self] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + unlinkBackendPid.resolve(Number(self?.pid)) + // `unlinkForkEdge` takes the lineage lock, then writes the workspace row. + await takeLineageLock(tx) + await tx + .update(workspace) + .set({ updatedAt: new Date() }) + .where(eq(workspace.id, SOURCE_WORKSPACE_ID)) + }) + .catch((error: unknown) => { + failure ??= describeFailure(error) + }) + ) + // Covers a connection that drops before `pg_backend_pid()` returns. + .finally(() => unlinkBackendPid.resolve(null)) + + try { + await forkHoldsFirstLock.promise + const unlinkPid = await unlinkBackendPid.promise + // A session that already failed cannot go on to block, so skip the barrier and let + // its error be what the test reports. + if (failure === null && unlinkPid !== null) await waitForBlockedBackend(unlinkPid) + } finally { + // Both sessions must be released and drained even when the barrier itself threw, + // or the fork transaction sits on `unlinkMayFinish` and the suite hangs. + unlinkMayFinish.resolve() + await Promise.all([forkSession, unlinkSession]) + await Promise.all([forkClient.end({ timeout: 5 }), unlinkClient.end({ timeout: 5 })]) + } + return failure + } + + async function check(name: string, run: () => Promise) { + const startedAt = Date.now() + try { + await run() + report.push({ name, status: 'passed', durationMs: Date.now() - startedAt }) + } catch (error) { + report.push({ + name, + status: 'failed', + durationMs: Date.now() - startedAt, + error: getErrorMessage(error), + }) + throw error + } + } + + it('completes both transactions when fork takes the lineage lock first (shipped order)', async () => { + await check('shipped-order-no-deadlock', async () => { + expect(await raceForkAgainstUnlink(true)).toBeNull() + }) + }) + + /** + * The negative control. Without it the test above could pass because the locks never + * actually contend - this proves the pair genuinely conflicts, so the shipped order's + * success is the ordering's doing and not an artifact of the fixture. + */ + it('deadlocks when fork takes the revision lock first (the reported pre-fix order)', async () => { + await check('pre-fix-order-deadlocks', async () => { + const failure = await raceForkAgainstUnlink(false) + expect(failure).toMatch(/deadlock detected|canceling statement due to lock timeout/i) + }) + }) + + /** + * Guards the fixture itself. The race above only proves the two sessions conflict; this + * pins WHY - `lockForkRevision` really does hold the `workspace` row, which is the edge + * of the cycle. A fixture that drifted (a renamed table, a helper that stopped locking) + * would otherwise still deadlock for some unrelated reason and look healthy. + */ + it('holds the workspace row through the production revision lock', async () => { + await check('revision-lock-covers-workspace-row', async () => { + const { executor: probe } = connect() + const locked = await probe.transaction(async (tx) => { + await setForkLockTimeout(tx) + await lockForkRevision(tx, { sourceWorkspaceId: SOURCE_WORKSPACE_ID }) + const rows = await tx.execute<{ count: number }>(sql` + SELECT count(*)::int AS count FROM pg_locks l + JOIN pg_class c ON c.oid = l.relation + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE l.pid = pg_backend_pid() AND c.relname = 'workspace' + AND n.nspname = ${testSchema}`) + return Number(rows[0]?.count ?? 0) + }) + expect(locked).toBeGreaterThan(0) + }) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts b/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts index b25f4689b92..7cc85525ca1 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts @@ -117,6 +117,48 @@ export async function acquireForkEdgeLock( await acquireAdvisoryXactLock(tx, 'fork_edge', `fork-edge:${childWorkspaceId}`) } +/** + * Serialize writes that must see one consistent view of a whole fork lineage, keyed by + * the lineage ROOT so every member contends on the same key. + * + * Two writers need it: setting the new-workflow fork-sync default (which fans out to + * every member) and fork creation (which inherits the source's value). Without it a fork + * created while the default was being changed could inherit a stale value, and the + * "every member agrees" invariant would be merely likely rather than guaranteed. + * + * ## Lock order for the whole fork module + * + * Every fork transaction acquires in ASCENDING rank and never reacquires a lower rank + * afterwards. Cite the rank at each acquisition site. + * + * | Rank | Lock | Where | + * | --- | --- | --- | + * | 1 | `lockWorkspaceOperationRequest` (per-request idempotency key) | `lib/workspaces/operation-receipts` | + * | 2 | {@link acquireForkLineageLock} (coarsest fork lock) | here | + * | 3 | {@link acquireForkTargetLock} | here | + * | 4 | {@link acquireForkEdgeLock} | here | + * | 5 | `lockForkRevision` (a `resource_folders:*` advisory lock per workspace, then `FOR UPDATE` on `workspace`, `workflow`, `workflow_blocks`, `workflow_edges`, `workflow_subflows` and `FOR SHARE` on the active `workflow_deployment_version` rows) | `application/revision.ts` | + * | 6 | remaining row locks, taken in sorted id order | various | + * + * Rank 5 is the trap this table exists for. Ranking only the three advisory locks here + * said nothing about `lockForkRevision`, and `createFork` took it BEFORE this lock - so + * it held `FOR UPDATE` on the source `workspace` row while waiting on `fork-lineage`, + * while `unlinkForkEdge` held `fork-lineage` and waited to UPDATE that same row. A + * genuine cycle, reported independently by two reviewers. + * + * Do NOT assume a uniform 10s bound from {@link setForkLockTimeout} across a whole + * transaction: `lockForkRevision` reaches `acquireFolderMutationLock` + * (`lib/folders/locks.ts`), which re-sets `lock_timeout` to 5s mid-transaction, and the + * MCP server lock re-sets it to 3s. Pre-existing and wider than forks; recorded here so + * the contract does not assert something false. + */ +export async function acquireForkLineageLock( + tx: DbTransaction, + rootWorkspaceId: string +): Promise { + await acquireAdvisoryXactLock(tx, 'fork_lineage', `fork-lineage:${rootWorkspaceId}`) +} + /** * Serialize every promote/rollback whose TARGET is this workspace. Sibling forks * promote into the same parent on different edge locks, so the edge lock alone does diff --git a/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts b/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts index f4fcaafdd49..4e193b9eda2 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts @@ -10,9 +10,11 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { acquireForkEdgeLock, + acquireForkLineageLock, type ForkEdge, setForkLockTimeout, } from '@/ee/workspace-forking/lib/lineage/lineage' +import { resolveForkLineageRootId } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('ForkUnlink') @@ -27,9 +29,11 @@ export interface UnlinkForkResult { * block map, dependent values, and promote-run undo points. Both workspaces are * left untouched; only the association and its metadata are removed. * - * Runs in one transaction under the edge advisory lock, which every promote and - * rollback on the edge also holds, so an in-flight sync either finishes before the - * unlink or re-resolves the edge afterwards and fails with "not a direct fork edge". + * Runs in one transaction under the lineage and edge advisory locks. Every promote and + * rollback on the edge holds the edge lock, so an in-flight sync either finishes before + * the unlink or re-resolves the edge afterwards and fails with "not a direct fork edge"; + * the lineage lock additionally serializes against fork creation and the lineage-wide + * fork-sync default write, which both key on the root this unlink is about to change. * The edge is re-verified inside the lock; a concurrently-dissolved edge is an * idempotent success rather than an error. */ @@ -39,8 +43,18 @@ export async function unlinkForkEdge( ): Promise { const { childWorkspaceId, parentWorkspaceId } = edge + // Resolved before the transaction: the walk is several round trips, and issuing them + // from inside the tx would hold this connection while checking out more. + const lineageRootId = await resolveForkLineageRootId(db, childWorkspaceId) + const unlinked = await db.transaction(async (tx) => { await setForkLockTimeout(tx) + // Severing this edge changes which lineage the child belongs to, and therefore which + // key `setForkSyncDefault` and `createFork` lock on. Taking the lineage lock first + // (the documented ordering) keeps an unlink from splitting a lineage underneath a + // policy write that already enumerated its members. + // Ranks 2 then 4 - see the rank table on `acquireForkLineageLock`. + await acquireForkLineageLock(tx, lineageRootId) await acquireForkEdgeLock(tx, childWorkspaceId) const updated = await tx diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts index b2237c1057f..ec56622d718 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts @@ -42,6 +42,14 @@ export interface ForkPromotePlanItem { sortOrder: number /** Source's public-API flag, carried onto the written target (see copyWorkflowStateIntoTarget). */ isPublicApi: boolean + /** + * The sync participation a promote-created target is written with. Always false: + * `listDeployedWorkflows` admits only synced sources, so a promoted workflow is one. + * Stated explicitly so a created target can never take the TARGET workspace's + * new-workflow default, which in an opt-out lineage would land a deliberately synced + * workflow unsynced on the other side. + */ + forkSyncExcluded: boolean } } @@ -213,6 +221,7 @@ export function buildForkPromotePlanItems(params: { folderId: source.folderId, sortOrder: source.sortOrder, isPublicApi: source.isPublicApi, + forkSyncExcluded: false, }, }) } diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.ts index 8dc68bf4cfb..ffacacf5c1b 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.ts @@ -619,6 +619,9 @@ export async function promoteFork(params: PromoteForkParams): Promise { await setForkLockTimeout(tx) + // Ranks 3 then 4 - see the rank table on `acquireForkLineageLock`. await acquireForkTargetLock(tx, targetWorkspaceId) await acquireForkEdgeLock(tx, edge.childWorkspaceId) diff --git a/apps/sim/ee/workspace-forking/lib/sync-default.test.ts b/apps/sim/ee/workspace-forking/lib/sync-default.test.ts new file mode 100644 index 00000000000..8ffdce9cf35 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/sync-default.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { DbOrTx } from '@/lib/db/types' +import { + collectForkLineageWorkspaceIds, + type ForkLineageReader, + MAX_FORK_LINEAGE_WORKSPACES, + resolveForkSyncExclusionForNewWorkflow, + walkToForkLineageRoot, +} from '@/ee/workspace-forking/lib/sync-default' + +/** An in-memory lineage: workspace id -> the workspace it was forked from (null = a root). */ +function reader( + parents: Record, + archived: readonly string[] = [] +): ForkLineageReader { + return { + getParentId: async (workspaceId) => parents[workspaceId] ?? null, + // Deliberately does NOT filter archived children: the real reader must not either, or + // an archived middle workspace blocks descent and splits the lineage. A fake that + // filtered here would reproduce that bug and let the test pass. + getChildIds: async (parentIds) => + Object.entries(parents) + .filter(([, parentId]) => parentId !== null && parentIds.includes(parentId)) + .map(([id]) => id), + liveAmong: async (ids) => ids.filter((id) => !archived.includes(id)), + } +} + +describe('walkToForkLineageRoot', () => { + it('returns the workspace itself when it is not a fork', async () => { + await expect(walkToForkLineageRoot(reader({ root: null }), 'root')).resolves.toBe('root') + }) + + it('climbs through intermediate forks to the original workspace', async () => { + const parents = { root: null, mid: 'root', leaf: 'mid' } + await expect(walkToForkLineageRoot(reader(parents), 'leaf')).resolves.toBe('root') + }) + + /** `forkedFromWorkspaceId` is ON DELETE SET NULL, and unlink clears it. */ + it('treats a severed chain as its own root rather than failing', async () => { + await expect(walkToForkLineageRoot(reader({ unlinked: null }), 'unlinked')).resolves.toBe( + 'unlinked' + ) + }) + + /** + * Absorbing a cycle would return a caller-dependent root (`a`->`b`, `b`->`a`), so two + * callers would lock different keys for one lineage and lose mutual exclusion. + */ + it('refuses a corrupted parent cycle rather than picking a caller-dependent root', async () => { + const parents = { a: 'b', b: 'a' } + await expect(walkToForkLineageRoot(reader(parents), 'a')).rejects.toThrow(/cycle/) + await expect(walkToForkLineageRoot(reader(parents), 'b')).rejects.toThrow(/cycle/) + }) + + /** Archival preserves the parent link, so the root must not depend on who asks. */ + it('resolves the same root from either side of an archived intermediate', async () => { + const parents = { root: null, mid: 'root', leaf: 'mid' } + const r = reader(parents, ['mid']) + await expect(walkToForkLineageRoot(r, 'leaf')).resolves.toBe('root') + await expect(walkToForkLineageRoot(r, 'root')).resolves.toBe('root') + }) +}) + +describe('collectForkLineageWorkspaceIds', () => { + const family = { root: null, forkA: 'root', forkB: 'root', grandchild: 'forkA' } + + it('reaches ancestors AND descendants when issued from a mid-lineage fork', async () => { + const members = await collectForkLineageWorkspaceIds(reader(family), 'forkA') + expect([...members].sort()).toEqual(['forkA', 'forkB', 'grandchild', 'root']) + }) + + it('returns the same set no matter which member it is issued from', async () => { + const fromRoot = await collectForkLineageWorkspaceIds(reader(family), 'root') + const fromLeaf = await collectForkLineageWorkspaceIds(reader(family), 'grandchild') + expect([...fromRoot].sort()).toEqual([...fromLeaf].sort()) + }) + + it('always includes the calling workspace, even when it is standalone', async () => { + const members = await collectForkLineageWorkspaceIds(reader({ solo: null }), 'solo') + expect(members).toEqual(['solo']) + }) + + it('propagates the cycle refusal rather than writing a partial lineage', async () => { + await expect(collectForkLineageWorkspaceIds(reader({ a: 'b', b: 'a' }), 'a')).rejects.toThrow( + /cycle/ + ) + }) + + /** + * An archived member is walked for topology so the root agrees from every side, but it is + * never returned - a policy write must not touch a workspace the UI reports as gone. + */ + it('traverses an archived member but excludes it from the writable set', async () => { + const parents = { root: null, mid: 'root', leaf: 'mid' } + const members = await collectForkLineageWorkspaceIds(reader(parents, ['mid']), 'leaf') + expect([...members].sort()).toEqual(['leaf', 'root']) + }) + + /** + * The regression that matters: archival is a soft delete, so a deleted template in the + * middle must not stop the walk. If it does, every member computes a DIFFERENT write set + * (`root -> [root]`, `leaf -> [leaf, root]`, `child -> [child, root]`) and the + * "every member agrees" invariant breaks with no error. + */ + it('reaches descendants BEYOND an archived member, from every member', async () => { + const parents = { root: null, mid: 'root', leaf: 'mid', child: 'leaf' } + const r = reader(parents, ['mid']) + const expected = ['child', 'leaf', 'root'] + for (const from of ['root', 'leaf', 'child']) { + expect([...(await collectForkLineageWorkspaceIds(r, from))].sort()).toEqual(expected) + } + }) + + it('refuses a lineage larger than the cap rather than loading it', async () => { + const parents: Record = { root: null } + for (let i = 0; i < MAX_FORK_LINEAGE_WORKSPACES + 10; i++) parents[`fork-${i}`] = 'root' + await expect(collectForkLineageWorkspaceIds(reader(parents), 'root')).rejects.toThrow( + /exceeds 500 workspaces/ + ) + }) +}) + +/** Stands in for the single-row policy lookup the resolver issues. */ +function policyExecutor(rows: Array<{ excluded: boolean }>): DbOrTx { + const chain = { + select: () => chain, + from: () => chain, + where: () => chain, + limit: async () => rows, + } + return chain as unknown as DbOrTx +} + +describe('resolveForkSyncExclusionForNewWorkflow', () => { + /** + * A personal, workspace-less workflow participates in no fork edge, so the policy must + * never be what keeps it out - and the resolver must not issue a query for it either. + */ + it.each([null, undefined, ''])('returns false for a workspace-less workflow (%p)', async (id) => { + let queried = false + const executor = { + select: () => { + queried = true + return executor + }, + } as unknown as DbOrTx + await expect(resolveForkSyncExclusionForNewWorkflow(executor, id)).resolves.toBe(false) + expect(queried).toBe(false) + }) + + it('returns the stored policy when the workspace opts in', async () => { + await expect( + resolveForkSyncExclusionForNewWorkflow(policyExecutor([{ excluded: true }]), 'ws-1') + ).resolves.toBe(true) + }) + + it('returns false for the historical opt-out default', async () => { + await expect( + resolveForkSyncExclusionForNewWorkflow(policyExecutor([{ excluded: false }]), 'ws-1') + ).resolves.toBe(false) + }) + + /** + * Falling back to "included" is the safe direction: a workflow that should have been + * excluded is visible in the Forks checkbox list and can be cleared, whereas one wrongly + * excluded silently stops syncing. + */ + it('falls back to included when the workspace row cannot be read', async () => { + await expect( + resolveForkSyncExclusionForNewWorkflow(policyExecutor([]), 'ws-deleted-mid-flight') + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/sync-default.ts b/apps/sim/ee/workspace-forking/lib/sync-default.ts new file mode 100644 index 00000000000..c7c903b934a --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/sync-default.ts @@ -0,0 +1,187 @@ +import { workspace } from '@sim/db/schema' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' + +/** + * Hard ceiling on how many workspaces one lineage traversal will visit. A fork lineage is a + * template workspace plus its forks - tens at most in real use, so this is far above any + * plausible shape while bounding a pathological or corrupted parent chain to a fixed number + * of queries. Mirrors the bounded-load discipline of `MAX_FORK_DEPLOYED_WORKFLOWS` in + * `copy/deploy-bridge.ts`. + */ +export const MAX_FORK_LINEAGE_WORKSPACES = 500 + +/** + * The two reads a lineage traversal needs, injected so the walk itself is pure and testable + * without a database. Both must return only LIVE (non-archived) workspaces. + */ +export interface ForkLineageReader { + /** + * The workspace a fork was created from, or null when it is not a fork. + * + * Resolves through ARCHIVED ancestors. Archival preserves `forkedFromWorkspaceId`, so + * stopping at one would make the root depend on which member asked: for `R -> M -> L` + * with `M` archived, `L` would report `M` as the root while `R` reported `R`, and the + * two would take different advisory locks for the same lineage. + */ + getParentId(workspaceId: string): Promise + /** + * Workspaces forked directly from any of `parentIds`, ARCHIVED ONES INCLUDED. + * + * Archival is a soft delete that leaves children's `forkedFromWorkspaceId` intact, so + * filtering archived members here would stop the walk at one and split a lineage into + * disjoint sets per caller: for `R -> M(archived) -> L -> C`, `R` would see only itself + * while `L` and `C` each saw a different pair. {@link ForkLineageReader.liveAmong} is + * what keeps archived members out of the writable set. + */ + getChildIds(parentIds: string[]): Promise + /** + * Which of `workspaceIds` are live. Archived members are traversed for topology but + * never written. Batched deliberately: this runs inside the lineage-locked transaction, + * where a per-member round trip would hold the lock for up to + * {@link MAX_FORK_LINEAGE_WORKSPACES} queries. + */ + liveAmong(workspaceIds: string[]): Promise +} + +export function createForkLineageReader(executor: DbOrTx): ForkLineageReader { + return { + async getParentId(workspaceId) { + const [row] = await executor + .select({ parentId: workspace.forkedFromWorkspaceId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + return row?.parentId ?? null + }, + async liveAmong(workspaceIds) { + if (workspaceIds.length === 0) return [] + const rows = await executor + .select({ id: workspace.id }) + .from(workspace) + .where(and(inArray(workspace.id, workspaceIds), isNull(workspace.archivedAt))) + return rows.map((row) => row.id) + }, + async getChildIds(parentIds) { + const rows = await executor + .select({ id: workspace.id }) + .from(workspace) + .where(inArray(workspace.forkedFromWorkspaceId, parentIds)) + return rows.map((row) => row.id) + }, + } +} + +/** + * The root of a fork lineage: the first ancestor that is not itself a fork. + * + * `forkedFromWorkspaceId` is ON DELETE SET NULL and an unlink clears it, so a severed chain + * simply makes that workspace its own root. + * + * A cycle is corrupted data and is REFUSED rather than absorbed. Terminating on the visited + * set would return a caller-dependent root - for `A -> B -> A`, `A` resolves to `B` while + * `B` resolves to `A` - so two callers would take different advisory locks for the same + * lineage and lose the mutual exclusion the lock exists to provide. + */ +export async function walkToForkLineageRoot( + reader: ForkLineageReader, + workspaceId: string +): Promise { + let currentId = workspaceId + const seen = new Set([currentId]) + for (let hop = 0; hop < MAX_FORK_LINEAGE_WORKSPACES; hop++) { + const parentId = await reader.getParentId(currentId) + if (!parentId) return currentId + if (seen.has(parentId)) { + throw new ForkError( + `Fork lineage for workspace ${workspaceId} contains a cycle and cannot be resolved`, + 409 + ) + } + seen.add(parentId) + currentId = parentId + } + throw new ForkError('Fork lineage is too deep to resolve', 400) +} + +/** + * Every live workspace in a fork lineage, resolved from ANY member: walk up to the root, + * then expand descendants one level at a time. + * + * Batched breadth-first rather than a recursive CTE - lineages are shallow, so this costs a + * handful of queries and stays unit-testable. The visited set dedupes, so a corrupted parent + * cycle terminates instead of looping, and the member cap bounds the worst case. + * + * Includes `workspaceId` itself when it is live, so a caller writing the returned set never + * silently skips the workspace it was asked about. An archived caller is walked for topology + * but excluded from the result like any other archived member. + */ +export async function collectForkLineageWorkspaceIds( + reader: ForkLineageReader, + workspaceId: string +): Promise { + const rootId = await walkToForkLineageRoot(reader, workspaceId) + // Seed with the root ALONE. Seeding the calling workspace too would mark it visited + // before the walk reaches it, so it would be skipped as a frontier node and its own + // descendants never expanded - `workspaceId` is added at the end instead. + const members = new Set([rootId]) + let frontier = [rootId] + while (frontier.length > 0) { + const next: string[] = [] + for (const childId of await reader.getChildIds(frontier)) { + if (members.has(childId)) continue + if (members.size >= MAX_FORK_LINEAGE_WORKSPACES) { + throw new ForkError(`Fork lineage exceeds ${MAX_FORK_LINEAGE_WORKSPACES} workspaces`, 400) + } + members.add(childId) + next.push(childId) + } + frontier = next + } + members.add(workspaceId) + // Archived members are part of the topology - they must be walked so the root is the same + // from every member - but they are never written. Returning them would let a policy write + // touch workspaces the lineage UI reports as gone. + const live = new Set(await reader.liveAmong([...members])) + return [...members].filter((id) => live.has(id)) +} + +/** {@link walkToForkLineageRoot} against the database. */ +export function resolveForkLineageRootId(executor: DbOrTx, workspaceId: string): Promise { + return walkToForkLineageRoot(createForkLineageReader(executor), workspaceId) +} + +/** {@link collectForkLineageWorkspaceIds} against the database. */ +export function resolveForkLineageWorkspaceIds( + executor: DbOrTx, + workspaceId: string +): Promise { + return collectForkLineageWorkspaceIds(createForkLineageReader(executor), workspaceId) +} + +/** + * Whether a workflow created right now in this workspace should start OUTSIDE fork sync, + * read from the workspace's `forkSyncNewWorkflowsExcluded` policy. + * + * Returns `false` for a personal, workspace-less workflow: it participates in no fork edge + * at all, so the flag must never be what keeps it out. Also `false` for a workspace that + * cannot be read (archived or deleted mid-flight), which is the historical opt-out behaviour + * and the safe direction - a workflow that should have been excluded is visible in the Forks + * page's checkbox list and can be cleared, while one wrongly excluded silently stops syncing. + */ +export async function resolveForkSyncExclusionForNewWorkflow( + executor: DbOrTx, + workspaceId: string | null | undefined +): Promise { + if (!workspaceId) return false + const [row] = await executor + .select({ excluded: workspace.forkSyncNewWorkflowsExcluded }) + .from(workspace) + // Archived workspaces fall through to the documented `false` default rather than + // returning their stored policy: an archive racing a workflow insert must not mark the + // new workflow excluded on the strength of a workspace that is already gone. + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + return row?.excluded ?? false +} diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index b9afda09bc2..3f8176b97c2 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -114,6 +114,12 @@ export const getForkLineageContract = defineRouteContract({ direction: forkDirectionSchema, }) .nullable(), + /** + * Whether a newly created workflow here starts outside fork sync. Uniform across the + * lineage, so this workspace's own value is the lineage's value. Defaulted so a new + * client tolerates an old server's response during rollout. + */ + forkSyncNewWorkflowsExcluded: z.boolean().default(false), }), }, }) @@ -954,6 +960,33 @@ export const updateForkExcludedWorkflowsContract = defineRouteContract({ }), }, }) +export const updateForkSyncDefaultBodySchema = z.object({ + /** + * True makes newly created workflows start outside fork sync (participation is opt-in); + * false restores the default, where a workflow joins sync as soon as it is deployed. + * Applies to the whole fork lineage and never changes an existing workflow. + */ + excludeNewWorkflows: z.boolean(), +}) +export const updateForkSyncDefaultContract = defineRouteContract({ + method: 'PUT', + path: '/api/workspaces/[id]/fork/sync-default', + params: workspaceIdParamsSchema, + body: updateForkSyncDefaultBodySchema, + response: { + mode: 'json', + schema: z.object({ + excludeNewWorkflows: z.boolean(), + /** Lineage members whose value changed; 0 when it already matched everywhere. */ + workspacesUpdated: z.number().int(), + }), + }, +}) +export type UpdateForkSyncDefaultBody = z.input +export type UpdateForkSyncDefaultResponse = z.output< + typeof updateForkSyncDefaultContract.response.schema +> + export type UpdateForkExcludedWorkflowsBody = z.input export type UpdateForkExcludedWorkflowsResponse = z.output< typeof updateForkExcludedWorkflowsContract.response.schema diff --git a/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts b/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts index d231b378c92..2786e141a00 100644 --- a/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts @@ -360,6 +360,15 @@ describe('member document lifecycle in PostgreSQL', () => { expect((await tombstonedIds()).size).toBe(0) }) + /** + * Seeds `MEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN * 500 + 500` documents so the pass + * genuinely exceeds one run's budget - that volume is the assertion, not incidental - then + * observes and re-lists all of it. ~4s locally, but the inserts and the table-wide re-list + * are contention-bound, so a loaded CI runner has pushed it past the shared 30s default + * (seen green on the `push` job and timing out on `migrate` for the same commit). Given its + * own budget rather than trimmed, since trimming the row count would stop proving the + * multi-run path. + */ it('finishes a pass within its page budget while listings re-stamp every observed document', async () => { const pageBudget = MEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN * 500 const total = pageBudget + 500 @@ -404,7 +413,7 @@ describe('member document lifecycle in PostgreSQL', () => { await run() } expect(await tombstonedIds()).toEqual(new Set([firstInEveryOrder.id])) - }) + }, 120_000) it('resumes a resurrection walk from where the deadline stopped it, not from the first document', async () => { const observedAgain = Array.from({ length: 700 }, (_, index) => ({ diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 543b788ea77..4fb7752b34b 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -768,6 +768,17 @@ export interface PostHogEventMap { fork_sync_excluded: boolean } + /** + * The lineage-wide "do new workflows sync to forks?" default was changed from the Forks + * settings. `workspace_id` is where the admin changed it; the write fans out to the + * whole lineage. + */ + fork_sync_default_updated: { + workspace_id: string + fork_sync_new_workflows_excluded: boolean + workspaces_updated: number + } + workflow_schedule_created: { workflow_id: string workspace_id: string diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index e53a0ccec89..25ef97be6bf 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -14,6 +14,7 @@ import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { nextWorkflowSortOrder } from '@/lib/workflows/sort-order' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('WorkflowLifecycle') @@ -206,6 +207,7 @@ export async function createWorkflowInTransaction(tx: DbOrTx, params: PerformCre isDeployed: false, runCount: 0, variables: {}, + forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(tx, params.workspaceId), } if (!params.deduplicate) { await tx.insert(workflow).values(row) @@ -269,6 +271,14 @@ export async function performCreateWorkflowTransition( try { await db.transaction(async (tx) => { + // Read inside the insert transaction, not before it. The policy is forward-only and + // governs a default the owner can see and change in the Forks list, so this does not + // need the lineage lock that `setForkSyncDefault` takes - but snapshotting it before + // the transaction widened the window for no reason. + const forkSyncExcluded = await resolveForkSyncExclusionForNewWorkflow( + tx, + params.workspaceId + ) await tx.insert(workflow).values({ id: workflowId, userId: params.userId, @@ -283,6 +293,7 @@ export async function performCreateWorkflowTransition( isDeployed: false, runCount: 0, variables: {}, + forkSyncExcluded, }) await saveWorkflowToNormalizedTables( diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 30b394c3f42..a39d4d285fb 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -29,6 +29,7 @@ import { } from '@/lib/workflows/persistence/remap-internal-ids' import { nextWorkflowSortOrder } from '@/lib/workflows/sort-order' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' import type { Variable } from '@/stores/variables/types' import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types' @@ -213,6 +214,10 @@ export async function duplicateWorkflow( isDeployed: false, runCount: 0, locked: false, + // A duplicate is a NEW workflow, so it takes the target workspace's new-workflow + // fork-sync policy rather than inheriting the source's participation. Only a + // fork/promote copy - the same logical workflow in another workspace - inherits. + forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(tx, targetWorkspaceId), // Duplicate variables with new IDs and new workflowId variables: (() => { const sourceVars = (source.variables as Record) || {} diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index c1a917089ef..d9c7f8c76c3 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -251,6 +251,7 @@ export const AuditAction = { WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted', WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', WORKSPACE_FORK_UNLINKED: 'workspace.fork_unlinked', + WORKSPACE_FORK_SYNC_DEFAULT_CHANGED: 'workspace.fork_sync_default_changed', WORKSPACE_EXPORTED: 'workspace.exported', // SCIM directory provisioning SCIM_CONNECTION_ENABLED: 'scim_connection.enabled', diff --git a/packages/db/migrations/0391_fork_sync_new_workflow_default.sql b/packages/db/migrations/0391_fork_sync_new_workflow_default.sql new file mode 100644 index 00000000000..7a0b15c32aa --- /dev/null +++ b/packages/db/migrations/0391_fork_sync_new_workflow_default.sql @@ -0,0 +1,4 @@ +-- migration-safe: one ADD COLUMN with a constant default on "workspace"; non-rewriting in modern +-- Postgres. Every existing workspace lands on false, which is the historical opt-out behaviour, +-- so no row changes meaning and no serving query changes shape. No backfill needed. +ALTER TABLE "workspace" ADD COLUMN "fork_sync_new_workflows_excluded" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0391_snapshot.json b/packages/db/migrations/meta/0391_snapshot.json new file mode 100644 index 00000000000..90e7e3bf8ab --- /dev/null +++ b/packages/db/migrations/meta/0391_snapshot.json @@ -0,0 +1,29399 @@ +{ + "id": "4bf7c7c5-a022-4a87-9d13-b889c5ba86f0", + "prevId": "ec1a0202-f006-4e59-bf4c-b322b4e93d18", + "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_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 + }, + "oauth_config": { + "name": "oauth_config", + "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.agent_memory_turn": { + "name": "agent_memory_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_order": { + "name": "execution_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "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()" + } + }, + "indexes": { + "agent_memory_turn_invocation_unique": { + "name": "agent_memory_turn_invocation_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memory_turn_workflow_idx": { + "name": "agent_memory_turn_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_memory_turn_memory_id_memory_id_fk": { + "name": "agent_memory_turn_memory_id_memory_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memory_turn_workflow_id_workflow_id_fk": { + "name": "agent_memory_turn_workflow_id_workflow_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "workflow", + "columnsFrom": ["workflow_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": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_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\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "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 + }, + "surface": { + "name": "surface", + "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 + }, + "browser_download_started_at": { + "name": "browser_download_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_started_at": { + "name": "execution_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_settled_at": { + "name": "execution_settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_owner_token": { + "name": "execution_owner_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_lease_expires_at": { + "name": "execution_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_revoked_at": { + "name": "execution_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_workflow_execution_id": { + "name": "client_workflow_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_processes": { + "name": "sandbox_processes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_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 + }, + "organization_id": { + "name": "organization_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 + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "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_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "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_organization_request_stops": { + "name": "copilot_organization_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_organization_request_stops_user_id_user_id_fk": { + "name": "copilot_organization_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_organization_request_stops_organization_id_organization_id_fk": { + "name": "copilot_organization_request_stops_organization_id_organization_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_organization_request_stops_user_id_organization_id_stream_id_pk": { + "name": "copilot_organization_request_stops_user_id_organization_id_stream_id_pk", + "columns": ["user_id", "organization_id", "stream_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_request_stops": { + "name": "copilot_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_request_stops_user_id_user_id_fk": { + "name": "copilot_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_request_stops_workspace_id_workspace_id_fk": { + "name": "copilot_request_stops_workspace_id_workspace_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_request_stops_user_id_workspace_id_stream_id_pk": { + "name": "copilot_request_stops_user_id_workspace_id_stream_id_pk", + "columns": ["user_id", "workspace_id", "stream_id"] + } + }, + "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 + }, + "tool_execution_version": { + "name": "tool_execution_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_admission_closed_at": { + "name": "tool_admission_closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_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_chat_started_at_idx": { + "name": "copilot_runs_chat_started_at_idx", + "columns": [ + { + "expression": "chat_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_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" + }, + "copilot_runs_organization_id_organization_id_fk": { + "name": "copilot_runs_organization_id_organization_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_service_usage": { + "name": "copilot_service_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 8)", + "primaryKey": false, + "notNull": false + }, + "worker_origin": { + "name": "worker_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_service_usage_pending_idx": { + "name": "copilot_service_usage_pending_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "delivered_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_task_subscriptions": { + "name": "copilot_task_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_task_subscriptions_execution_idx": { + "name": "copilot_task_subscriptions_execution_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_task_subscriptions_task_idx": { + "name": "copilot_task_subscriptions_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_task_subscriptions_chat_id_copilot_chats_id_fk": { + "name": "copilot_task_subscriptions_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_task_subscriptions", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_task_subscriptions_workspace_id_workspace_id_fk": { + "name": "copilot_task_subscriptions_workspace_id_workspace_id_fk", + "tableFrom": "copilot_task_subscriptions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_task_subscriptions_user_id_user_id_fk": { + "name": "copilot_task_subscriptions_user_id_user_id_fk", + "tableFrom": "copilot_task_subscriptions", + "tableTo": "user", + "columnsFrom": ["user_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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "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 + }, + "last_refreshed_at": { + "name": "last_refreshed_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": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "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": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "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_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "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_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_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_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "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_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by 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_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_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 + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "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": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "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 + }, + "trace_child_runs": { + "name": "trace_child_runs", + "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": { + "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_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "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 + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "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_kb_source_modified_idx": { + "name": "doc_kb_source_modified_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "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_processing_recovery_idx": { + "name": "doc_processing_recovery_idx", + "columns": [ + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_processing_recovery_idx": { + "name": "doc_connector_processing_recovery_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_connector_processing_status_idx": { + "name": "doc_connector_processing_status_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "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_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_idx": { + "name": "doc_connector_reconciliation_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"source_seen_at\", '-infinity'::timestamp)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_v2_idx": { + "name": "doc_connector_reconciliation_v2_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "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_connector_tombstone_idx": { + "name": "doc_connector_tombstone_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NULL AND (\"document\".\"deleted_at\" IS NOT NULL OR \"document\".\"content_hash\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_live_idx": { + "name": "doc_connector_live_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": 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": true, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "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_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": {} + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": 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": true, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_keyword_search": { + "name": "embedding_keyword_search", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "embedding_keyword_search_kb_idx": { + "name": "embedding_keyword_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_keyword_search_document_idx": { + "name": "embedding_keyword_search_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_keyword_search_content_idx": { + "name": "embedding_keyword_search_content_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_keyword_search_id_embedding_id_fk": { + "name": "embedding_keyword_search_id_embedding_id_fk", + "tableFrom": "embedding_keyword_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding_keyword_tin": { + "name": "embedding_keyword_tin", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_keyword_tin_document_idx": { + "name": "embedding_keyword_tin_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "embedding_keyword_tin_id_embedding_id_fk": { + "name": "embedding_keyword_tin_id_embedding_id_fk", + "tableFrom": "embedding_keyword_tin", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding_search": { + "name": "embedding_search", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "binary": { + "name": "binary", + "type": "bit(1536)", + "primaryKey": false, + "notNull": false + }, + "binary_384": { + "name": "binary_384", + "type": "bit(384)", + "primaryKey": false, + "notNull": false + }, + "binary_768": { + "name": "binary_768", + "type": "bit(768)", + "primaryKey": false, + "notNull": false + }, + "binary_1024": { + "name": "binary_1024", + "type": "bit(1024)", + "primaryKey": false, + "notNull": false + }, + "binary_3072": { + "name": "binary_3072", + "type": "bit(3072)", + "primaryKey": false, + "notNull": false + }, + "vector": { + "name": "vector", + "type": "halfvec(1536)", + "primaryKey": false, + "notNull": false + }, + "vector_384": { + "name": "vector_384", + "type": "halfvec(384)", + "primaryKey": false, + "notNull": false + }, + "vector_512": { + "name": "vector_512", + "type": "halfvec(512)", + "primaryKey": false, + "notNull": false + }, + "vector_768": { + "name": "vector_768", + "type": "halfvec(768)", + "primaryKey": false, + "notNull": false + }, + "vector_1024": { + "name": "vector_1024", + "type": "halfvec(1024)", + "primaryKey": false, + "notNull": false + }, + "vector_3072": { + "name": "vector_3072", + "type": "halfvec(3072)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_search_kb_idx": { + "name": "embedding_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_search_document_lookup_idx": { + "name": "embedding_search_document_lookup_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"embedding_search\".\"enabled\"", + "concurrently": true, + "method": "btree", + "with": {} + }, + "embedding_search_cosine_hnsw_idx": { + "name": "embedding_search_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_512_cosine_hnsw_idx": { + "name": "embedding_search_512_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_512", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_384_cosine_hnsw_idx": { + "name": "embedding_search_384_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_768_cosine_hnsw_idx": { + "name": "embedding_search_768_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_1024_cosine_hnsw_idx": { + "name": "embedding_search_1024_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_3072_cosine_hnsw_idx": { + "name": "embedding_search_3072_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_3072", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "embedding_search_id_embedding_id_fk": { + "name": "embedding_search_id_embedding_id_fk", + "tableFrom": "embedding_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_search_width_check": { + "name": "embedding_search_width_check", + "value": "num_nonnulls(\"binary\", \"binary_384\", \"binary_768\", \"binary_1024\", \"binary_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "organization_id": { + "name": "organization_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 + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_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": {} + }, + "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": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"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_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "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 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "member_tombstone_cursor": { + "name": "member_tombstone_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "member_resurrection_cursor": { + "name": "member_resurrection_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detach_reserved_bytes": { + "name": "detach_reserved_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "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": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS 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" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewed_at": { + "name": "scope_renewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_cursor": { + "name": "scope_renewal_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_started_at": { + "name": "scope_renewal_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_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": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_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 + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "processing_dispatch_failed": { + "name": "processing_dispatch_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_renewed": { + "name": "observations_renewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + }, + "kcmsl_database_failure_class_check": { + "name": "kcmsl_database_failure_class_check", + "value": "\"knowledge_connector_member_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_partition": { + "name": "knowledge_connector_partition", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "partition_key": { + "name": "partition_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_served_at": { + "name": "last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure": { + "name": "failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "permission_cursor": { + "name": "permission_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_attempts": { + "name": "permission_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permission_retry_at": { + "name": "permission_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permission_last_served_at": { + "name": "permission_last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_started_at": { + "name": "permission_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_failure": { + "name": "permission_failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcp_content_due_idx": { + "name": "kcp_content_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcp_permission_due_idx": { + "name": "kcp_permission_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_partition_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_partition_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_partition", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcp_pk": { + "name": "kcp_pk", + "columns": ["connector_id", "partition_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcp_partition_key_check": { + "name": "kcp_partition_key_check", + "value": "octet_length(\"knowledge_connector_partition\".\"partition_key\") BETWEEN 1 AND 1024" + }, + "kcp_context_check": { + "name": "kcp_context_check", + "value": "jsonb_typeof(\"knowledge_connector_partition\".\"context\") = 'object' AND octet_length(\"knowledge_connector_partition\".\"context\"::text) <= 16384" + }, + "kcp_status_check": { + "name": "kcp_status_check", + "value": "\"knowledge_connector_partition\".\"status\" IN ('pending', 'complete', 'blocked')" + }, + "kcp_cursor_check": { + "name": "kcp_cursor_check", + "value": "(\"knowledge_connector_partition\".\"cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"cursor\") <= 393216) AND (\"knowledge_connector_partition\".\"permission_cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"permission_cursor\") <= 393216)" + }, + "kcp_attempts_check": { + "name": "kcp_attempts_check", + "value": "\"knowledge_connector_partition\".\"attempts\" >= 0 AND \"knowledge_connector_partition\".\"permission_attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_grant": { + "name": "knowledge_connector_permission_grant", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kcpg_subject_idx": { + "name": "kcpg_subject_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kcpg_snapshot_fk": { + "name": "kcpg_snapshot_fk", + "tableFrom": "knowledge_connector_permission_grant", + "tableTo": "knowledge_connector_permission_snapshot", + "columnsFrom": ["connector_id"], + "columnsTo": ["connector_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcpg_pk": { + "name": "kcpg_pk", + "columns": ["connector_id", "group_key", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcpg_group_check": { + "name": "kcpg_group_check", + "value": "length(\"knowledge_connector_permission_grant\".\"group_key\") BETWEEN 1 AND 255" + }, + "kcpg_subject_check": { + "name": "kcpg_subject_check", + "value": "\"knowledge_connector_permission_grant\".\"subject_token\" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_snapshot": { + "name": "knowledge_connector_permission_snapshot", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "kcps_connector_fk": { + "name": "kcps_connector_fk", + "tableFrom": "knowledge_connector_permission_snapshot", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcps_revision_check": { + "name": "kcps_revision_check", + "value": "\"knowledge_connector_permission_snapshot\".\"revision\" > 0" + } + }, + "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_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "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": { + "kcsl_database_failure_class_check": { + "name": "kcsl_database_failure_class_check", + "value": "\"knowledge_connector_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_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": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_projection_dirty": { + "name": "knowledge_projection_dirty", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content": { + "name": "content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marked_at": { + "name": "marked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "knowledge_projection_dirty_marked_at_idx": { + "name": "knowledge_projection_dirty_marked_at_idx", + "columns": [ + { + "expression": "marked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_projection_dirty_document_id_document_id_fk": { + "name": "knowledge_projection_dirty_document_id_document_id_fk", + "tableFrom": "knowledge_projection_dirty", + "tableTo": "document", + "columnsFrom": ["document_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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "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_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "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 + }, + "storage_version": { + "name": "storage_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "encrypted_context_summary": { + "name": "encrypted_context_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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()" + }, + "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.memory_artifact": { + "name": "memory_artifact", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "memory_artifact_key_idx": { + "name": "memory_artifact_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_artifact_memory_id_memory_id_fk": { + "name": "memory_artifact_memory_id_memory_id_fk", + "tableFrom": "memory_artifact", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_artifact_key_execution_large_values_key_fk": { + "name": "memory_artifact_key_execution_large_values_key_fk", + "tableFrom": "memory_artifact", + "tableTo": "execution_large_values", + "columnsFrom": ["key"], + "columnsTo": ["key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_artifact_memory_id_key_pk": { + "name": "memory_artifact_memory_id_key_pk", + "columns": ["memory_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_item": { + "name": "memory_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "memory_item_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "append_key": { + "name": "append_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_status": { + "name": "provenance_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_entries": { + "name": "provenance_entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_item_append_unique": { + "name": "memory_item_append_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "append_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_item_sequence_idx": { + "name": "memory_item_sequence_idx", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_item_memory_id_memory_id_fk": { + "name": "memory_item_memory_id_memory_id_fk", + "tableFrom": "memory_item", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_item_kind_check": { + "name": "memory_item_kind_check", + "value": "\"memory_item\".\"kind\" IN ('message', 'exchange')" + }, + "memory_item_provenance_status_check": { + "name": "memory_item_provenance_status_check", + "value": "\"memory_item\".\"provenance_status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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_resource_effects": { + "name": "mothership_resource_effects", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effect_id": { + "name": "effect_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_resource_effects_chat_id_copilot_chats_id_fk": { + "name": "mothership_resource_effects_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_resource_effects", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mothership_resource_effects_chat_id_effect_id_pk": { + "name": "mothership_resource_effects_chat_id_effect_id_pk", + "columns": ["chat_id", "effect_id"] + } + }, + "uniqueConstraints": {}, + "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": {}, + "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.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_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 + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "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 + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_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 + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "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 + }, + "require_sso": { + "name": "require_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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" + }, + "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_access_request_settings": { + "name": "organization_access_request_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "allow_requests": { + "name": "allow_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_access_request_settings_organization_id_organization_id_fk": { + "name": "organization_access_request_settings_organization_id_organization_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_access_request_settings_updated_by_user_id_fk": { + "name": "organization_access_request_settings_updated_by_user_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_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": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.organization_search_history": { + "name": "organization_search_history", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "queries": { + "name": "queries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "organization_search_history_user_idx": { + "name": "organization_search_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_history_organization_id_organization_id_fk": { + "name": "organization_search_history_organization_id_organization_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_history_user_id_user_id_fk": { + "name": "organization_search_history_user_id_user_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_history_organization_id_user_id_pk": { + "name": "organization_search_history_organization_id_user_id_pk", + "columns": ["organization_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_invocation": { + "name": "organization_search_invocation", + "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": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_types": { + "name": "source_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_invocation_org_created_idx": { + "name": "organization_search_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_invocation_user_idx": { + "name": "organization_search_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_invocation_organization_id_organization_id_fk": { + "name": "organization_search_invocation_organization_id_organization_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_invocation_user_id_user_id_fk": { + "name": "organization_search_invocation_user_id_user_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_invocation_result_count_bounds": { + "name": "organization_search_invocation_result_count_bounds", + "value": "\"organization_search_invocation\".\"result_count\" BETWEEN 0 AND 100" + }, + "organization_search_invocation_source_types_bounds": { + "name": "organization_search_invocation_source_types_bounds", + "value": "cardinality(\"organization_search_invocation\".\"source_types\") <= 100" + } + }, + "isRLSEnabled": false + }, + "public.organization_search_mcp_invocation": { + "name": "organization_search_mcp_invocation", + "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": false + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_mcp_invocation_org_created_idx": { + "name": "organization_search_mcp_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_mcp_invocation_user_idx": { + "name": "organization_search_mcp_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_search_mcp_invocation_org_fk": { + "name": "org_search_mcp_invocation_org_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_search_mcp_invocation_user_fk": { + "name": "org_search_mcp_invocation_user_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_mcp_invocation_tool_check": { + "name": "organization_search_mcp_invocation_tool_check", + "value": "\"organization_search_mcp_invocation\".\"tool_name\" IN ('search', 'read_document', 'chat')" + }, + "organization_search_mcp_invocation_outcome_check": { + "name": "organization_search_mcp_invocation_outcome_check", + "value": "\"organization_search_mcp_invocation\".\"outcome\" IN ('success', 'error', 'cancelled', 'rate_limited')" + }, + "organization_search_mcp_invocation_duration_check": { + "name": "organization_search_mcp_invocation_duration_check", + "value": "\"organization_search_mcp_invocation\".\"duration_ms\" >= 0" + }, + "organization_search_mcp_invocation_client_name_check": { + "name": "organization_search_mcp_invocation_client_name_check", + "value": "length(\"organization_search_mcp_invocation\".\"client_name\") <= 256" + }, + "organization_search_mcp_invocation_auth_check": { + "name": "organization_search_mcp_invocation_auth_check", + "value": "(\"organization_search_mcp_invocation\".\"auth_kind\" = 'oauth_access_token' AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NOT NULL)\n OR (\"organization_search_mcp_invocation\".\"auth_kind\" IN ('personal_api_key', 'workspace_api_key') AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NULL AND \"organization_search_mcp_invocation\".\"client_name\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.organization_secret": { + "name": "organization_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_secret_shared_unique": { + "name": "organization_secret_shared_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_member_unique": { + "name": "organization_secret_member_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_owner_idx": { + "name": "organization_secret_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_id_organization_secret_source_id_fk": { + "name": "organization_secret_source_id_organization_secret_source_id_fk", + "tableFrom": "organization_secret", + "tableTo": "organization_secret_source", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_secret_owner_user_id_user_id_fk": { + "name": "organization_secret_owner_user_id_user_id_fk", + "tableFrom": "organization_secret", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_secret_source": { + "name": "organization_secret_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "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": { + "organization_secret_source_org_unique": { + "name": "organization_secret_source_org_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_organization_id_organization_id_fk": { + "name": "organization_secret_source_organization_id_organization_id_fk", + "tableFrom": "organization_secret_source", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_secret_source_mode_check": { + "name": "organization_secret_source_mode_check", + "value": "\"organization_secret_source\".\"mode\" IN ('organization', 'member')" + } + }, + "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_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_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": "\"outbox_event\".\"status\" = 'pending'", + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "oauth_config": { + "name": "oauth_config", + "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_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_access_request": { + "name": "permission_access_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_id": { + "name": "requester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "jsonb", + "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()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "permission_access_request_pending_unique": { + "name": "permission_access_request_pending_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"permission_access_request\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_org_queue_idx": { + "name": "permission_access_request_org_queue_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_requester_idx": { + "name": "permission_access_request_requester_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_access_request_organization_id_organization_id_fk": { + "name": "permission_access_request_organization_id_organization_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_requester_id_user_id_fk": { + "name": "permission_access_request_requester_id_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["requester_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_decided_by_user_id_fk": { + "name": "permission_access_request_decided_by_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "permission_access_request_status_check": { + "name": "permission_access_request_status_check", + "value": "\"permission_access_request\".\"status\" in ('pending', 'fulfilled', 'declined', 'cancelled', 'closed')" + } + }, + "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 + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "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_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 + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_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": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_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": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "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 + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "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.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_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": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "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": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "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": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "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": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": {} + } + }, + "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 + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "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.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "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": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + }, + "slack_app_custom_credentials_check": { + "name": "slack_app_custom_credentials_check", + "value": "\"slack_app\".\"kind\" = 'shared' OR (\"slack_app\".\"client_id\" IS NOT NULL AND \"slack_app\".\"encrypted_client_secret\" IS NOT NULL AND \"slack_app\".\"encrypted_signing_secret\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_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": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "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'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "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": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "primary_provider_id": { + "name": "primary_provider_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": { + "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 + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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 + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "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": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "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" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "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" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_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_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_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, + "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_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "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 + }, + "knowledge_base_id": { + "name": "knowledge_base_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 + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "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": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "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": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_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_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "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 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "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()" + }, + "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'" + }, + "credit_balance": { + "name": "credit_balance", + "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'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_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, + "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": {} + }, + "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_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "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": "\"workflow\".\"archived_at\" IS NULL", + "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 + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": 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 + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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_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_activity_idx": { + "name": "workflow_execution_logs_workspace_activity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_duration_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "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 + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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 + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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 + }, + "fork_sync_new_workflows_excluded": { + "name": "fork_sync_new_workflows_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "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_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_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_build": { + "name": "workspace_file_search_build", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_file_search_build_file_idx": { + "name": "workspace_file_search_build_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_build_cleanup_idx": { + "name": "workspace_file_search_build_cleanup_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_build\".\"expires_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_chunk": { + "name": "workspace_file_search_chunk", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_start": { + "name": "line_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fragment": { + "name": "fragment", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "overlap": { + "name": "overlap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_chunk_line_idx": { + "name": "workspace_file_search_chunk_line_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "line_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_chunk_content_idx": { + "name": "workspace_file_search_chunk_content_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + } + }, + "foreignKeys": { + "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_chunk", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_chunk_pk": { + "name": "workspace_file_search_chunk_pk", + "columns": ["build_id", "ordinal"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_search_chunk_content_size": { + "name": "workspace_file_search_chunk_content_size", + "value": "octet_length(\"workspace_file_search_chunk\".\"content\") <= 8192" + }, + "workspace_file_search_chunk_position": { + "name": "workspace_file_search_chunk_position", + "value": "\"workspace_file_search_chunk\".\"ordinal\" >= 0 AND \"workspace_file_search_chunk\".\"line_start\" > 0 AND \"workspace_file_search_chunk\".\"overlap\" BETWEEN 0 AND 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_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": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_revision": { + "name": "workspace_file_search_revision", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "handoff_expires_at": { + "name": "handoff_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_revision_workspace_status_idx": { + "name": "workspace_file_search_revision_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": {} + }, + "workspace_file_search_revision_build_idx": { + "name": "workspace_file_search_revision_build_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_pending_idx": { + "name": "workspace_file_search_revision_pending_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_active_idx": { + "name": "workspace_file_search_revision_active_idx", + "columns": [ + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_revision_file_id_workspace_files_id_fk": { + "name": "workspace_file_search_revision_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_version": { + "name": "workspace_file_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "workspace_file_version_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_user_ids": { + "name": "author_user_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "restored_from_version": { + "name": "restored_from_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_status": { + "name": "secret_provenance_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_entries": { + "name": "secret_provenance_entries", + "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": { + "workspace_file_version_file_version_unique": { + "name": "workspace_file_version_file_version_unique", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_key_unique": { + "name": "workspace_file_version_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_id_idx": { + "name": "workspace_file_version_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_superseded_idx": { + "name": "workspace_file_version_workspace_superseded_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "superseded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_version\".\"superseded_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_version_file_id_workspace_files_id_fk": { + "name": "workspace_file_version_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_version_workspace_id_workspace_id_fk": { + "name": "workspace_file_version_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_version_provenance_status_check": { + "name": "workspace_file_version_provenance_status_check", + "value": "\"workspace_file_version\".\"secret_provenance_status\" IS NULL OR \"workspace_file_version\".\"secret_provenance_status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "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 + }, + "organization_id": { + "name": "organization_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_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "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": "date_trunc('milliseconds', now())" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "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_workspace_active_keyset_idx": { + "name": "workspace_files_workspace_active_keyset_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "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_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "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_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "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()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "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 + }, + "public.workspace_visit": { + "name": "workspace_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visited_at": { + "name": "visited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_visit_workspace_idx": { + "name": "workspace_visit_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_visit_user_id_user_id_fk": { + "name": "workspace_visit_user_id_user_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_visit_workspace_id_workspace_id_fk": { + "name": "workspace_visit_workspace_id_workspace_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_visit_user_id_workspace_id_pk": { + "name": "workspace_visit_user_id_workspace_id_pk", + "columns": ["user_id", "workspace_id"] + } + }, + "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_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "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", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "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.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "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.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "organization_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "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", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_file_version_source": { + "name": "workspace_file_version_source", + "schema": "public", + "values": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"] + }, + "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", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "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 11c601420ce..059e4744afa 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2731,6 +2731,13 @@ "when": 1790562554468, "tag": "0390_table_rows_version_at_commit", "breakpoints": true + }, + { + "idx": 391, + "version": "7", + "when": 1790647979450, + "tag": "0391_fork_sync_new_workflow_default", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 6e44cb9d231..24121b475e6 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2003,6 +2003,25 @@ export const workspace = pgTable( (): AnyPgColumn => workspace.id, { onDelete: 'set null' } ), + /** + * Whether a newly created workflow in this workspace starts OUTSIDE fork sync. + * + * `false` (the default) preserves the historical opt-out behaviour: a workflow + * joins fork sync as soon as it is deployed. `true` makes participation opt-in - + * a new workflow lands with `workflow.forkSyncExcluded` set, and an admin selects + * it deliberately on the Forks settings page. + * + * Uniform across a fork lineage: settable from any member, and the write fans out + * to every ancestor and descendant so a parent and its forks can never disagree + * about what "new" means. Every member holds the same value so reads stay local. + * A fork inherits it at creation, like `allowPersonalApiKeys` above. + * + * Forward-only. Flipping it never rewrites an existing workflow's + * `forkSyncExcluded`; the Forks page's checkbox list stays the record of what syncs. + */ + forkSyncNewWorkflowsExcluded: boolean('fork_sync_new_workflows_excluded') + .notNull() + .default(false), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index f36e5fde1c3..8a89a318ebc 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -191,6 +191,7 @@ const AuditAction = { WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted', WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', WORKSPACE_FORK_UNLINKED: 'workspace.fork_unlinked', + WORKSPACE_FORK_SYNC_DEFAULT_CHANGED: 'workspace.fork_sync_default_changed', WORKSPACE_EXPORTED: 'workspace.exported', SCIM_CONNECTION_ENABLED: 'scim_connection.enabled', SCIM_CONNECTION_DISABLED: 'scim_connection.disabled', diff --git a/packages/testing/src/mocks/schema-tables.generated.ts b/packages/testing/src/mocks/schema-tables.generated.ts index 5c09960aae0..9b74648d7e6 100644 --- a/packages/testing/src/mocks/schema-tables.generated.ts +++ b/packages/testing/src/mocks/schema-tables.generated.ts @@ -497,6 +497,7 @@ export const GENERATED_SCHEMA_TABLES = { 'archivedAt', 'organizationAssignedAt', 'forkedFromWorkspaceId', + 'forkSyncNewWorkflowsExcluded', 'createdAt', 'updatedAt', ], diff --git a/packages/testing/src/mocks/workspace-forking-lineage.mock.ts b/packages/testing/src/mocks/workspace-forking-lineage.mock.ts index 2e7009219fd..4d0b9f8bf65 100644 --- a/packages/testing/src/mocks/workspace-forking-lineage.mock.ts +++ b/packages/testing/src/mocks/workspace-forking-lineage.mock.ts @@ -26,6 +26,7 @@ export const workspaceForkingLineageMockFns = { mockSetForkLockTimeout: vi.fn(), mockAcquireForkEdgeLock: vi.fn(), mockAcquireForkTargetLock: vi.fn(), + mockAcquireForkLineageLock: vi.fn(), } /** @@ -44,4 +45,5 @@ export const workspaceForkingLineageMock = { setForkLockTimeout: workspaceForkingLineageMockFns.mockSetForkLockTimeout, acquireForkEdgeLock: workspaceForkingLineageMockFns.mockAcquireForkEdgeLock, acquireForkTargetLock: workspaceForkingLineageMockFns.mockAcquireForkTargetLock, + acquireForkLineageLock: workspaceForkingLineageMockFns.mockAcquireForkLineageLock, } From 61a51696b40863d0ae902536cb79a54ceb48b820 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 22:00:27 -0700 Subject: [PATCH 07/22] fix(tables): write the cell state when a resumed run throws (#8401) * fix(tables): write the cell state when a resumed run throws runResumeAndCellTerminal only wrote the cell terminal after a resume returned, so a resume that threw left the cell on its last partial running state, where it could not even be cancelled. The resume manager now records, on the error it rethrows, when the attempt kept its pause resumable (admission refused, run buffer unavailable). The resume job mirrors that onto the cell: back to paused when the pause was kept, failed otherwise. A failed cell write is logged without masking the resume error, which is still rethrown. * fix(tables): mirror what a failed resume did instead of inferring it from the error An admission refusal can also mean the execution already finished, so treating every refusal as a kept pause wrote paused over a completed cell. markResumeAttemptFailed and markResumeFailed now return what their transaction actually did (pause still resumable / execution failed), the manager records that outcome on the rethrown error, and the resume job writes paused, error, or nothing. * fix(tables): type the terminal execution log statuses against the persisted vocabulary * fix(tables): report a failed resume's outcome through a hook instead of the error The outcome rode on the thrown error through a module WeakMap, so it was lost when draining queued resumes threw after the settle. startResumeExecution now takes onAttemptFailed, called right after the settle transaction; a hook failure is logged and never replaces the attempt's error. --- apps/sim/background/resume-execution.ts | 22 +++ .../resume-governed-subject.test.ts | 49 ++++++ .../human-in-the-loop-manager.test.ts | 165 +++++++++++++++++- .../executor/human-in-the-loop-manager.ts | 72 ++++++-- 4 files changed, 295 insertions(+), 13 deletions(-) diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index 0d763e391ca..db86c516b1a 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { task, timeout } from '@trigger.dev/sdk' import { @@ -21,6 +22,7 @@ import { classifyWorkflowCellTerminalResult } from '@/lib/table/workflow-cell-re import type { CellResumeContext } from '@/lib/table/workflow-columns' import { createResumeAttemptTimeoutController, + type FailedResumeOutcome, PauseResumeManager, } from '@/lib/workflows/executor/human-in-the-loop-manager' import { RESUME_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits' @@ -359,6 +361,25 @@ async function buildResumeCellWriters( return { cellOnBlockComplete, writeCellTerminal } } +/** + * A resume that throws never reaches the terminal write in + * {@link runResumeAndCellTerminal}, which would leave the cell showing its last + * partial `running` state. Mirror what the failed attempt did to the execution: + * a pause that stayed resumable goes back to paused, and a failed execution + * fails the cell. + */ +async function writeFailedResumeCellTerminal( + writers: CellWriters, + outcome: FailedResumeOutcome, + error: unknown +): Promise { + if (outcome === 'pause_retained') { + await writers.writeCellTerminal('paused', null) + } else { + await writers.writeCellTerminal('error', getErrorMessage(error, 'Resume execution failed')) + } +} + async function runResumeAndCellTerminal( payload: ResumeExecutionPayload, pausedExecution: Awaited>, @@ -375,6 +396,7 @@ async function runResumeAndCellTerminal( resumeInput: payload.resumeInput, userId: payload.userId, onBlockComplete: writers.cellOnBlockComplete, + onAttemptFailed: (outcome, error) => writeFailedResumeCellTerminal(writers, outcome, error), abortSignal: signal, }) diff --git a/apps/sim/background/resume-governed-subject.test.ts b/apps/sim/background/resume-governed-subject.test.ts index 4f6a62778e4..d7589d6eb45 100644 --- a/apps/sim/background/resume-governed-subject.test.ts +++ b/apps/sim/background/resume-governed-subject.test.ts @@ -51,6 +51,7 @@ vi.mock('@/executor/execution/snapshot', () => ({ ExecutionSnapshot: { fromJSON: hoisted.snapshotFromJson }, })) +import type { FailedResumeOutcome } from '@/lib/workflows/executor/human-in-the-loop-manager' import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' const mocks = { @@ -217,4 +218,52 @@ describe('resuming a paused table cell', () => { const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] expect(cascadePayload.capabilityGovernedUserId).toBe('requesting-member') }, 20_000) + + describe('when the resume throws', () => { + /** The execution state the last cell write persisted. */ + function lastCellExecutionState() { + const [, payload] = mocks.writeWorkflowGroupState.mock.calls.at(-1) ?? [] + return payload?.executionState + } + + /** Fails the resume the way the manager does: settle, report the outcome, rethrow. */ + function failResume(outcome: FailedResumeOutcome, error: Error) { + mocks.startResumeExecution.mockImplementationOnce( + async ({ + onAttemptFailed, + }: { + onAttemptFailed?: (outcome: FailedResumeOutcome, error: unknown) => Promise + }) => { + await onAttemptFailed?.(outcome, error) + throw error + } + ) + } + + it('marks the cell failed when the resume failed the execution', async () => { + const runFailure = new Error('writeLedger: Unique constraint violation') + failResume('execution_failed', runFailure) + + await expect(executeResumeJob(PAYLOAD)).rejects.toBe(runFailure) + + expect(lastCellExecutionState()).toMatchObject({ + status: 'error', + executionId: 'parent-execution-1', + error: 'writeLedger: Unique constraint violation', + }) + }, 20_000) + + it('puts the cell back to paused when the pause stayed resumable', async () => { + const admissionRefusal = new Error('Execution can no longer be resumed') + failResume('pause_retained', admissionRefusal) + + await expect(executeResumeJob(PAYLOAD)).rejects.toBe(admissionRefusal) + + expect(lastCellExecutionState()).toMatchObject({ + status: 'pending', + executionId: 'parent-execution-1', + jobId: 'paused-parent-execution-1', + }) + }, 20_000) + }) }) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index d407d5c9525..95205271c98 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -18,7 +18,7 @@ import { largeValueMetadataMock, largeValueMetadataMockFns, } from '@sim/testing/mocks/large-value-metadata.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { terminalExecutionLogFields } from '@/lib/logs/execution/cancellation' @@ -61,6 +61,7 @@ vi.mock('@/lib/execution/payloads/large-value-metadata', () => largeValueMetadat import { createResumeAttemptTimeoutController, + type FailedResumeOutcome, PauseResumeManager, requireResumeDeploymentVersion, updateResumeOutputInAggregationBuffers, @@ -84,7 +85,7 @@ if (!humanInTheLoopLogger) { } interface PauseResumeManagerInternals { - markResumeFailed: (...args: unknown[]) => Promise + markResumeFailed: (...args: unknown[]) => Promise runResumeExecution: (...args: unknown[]) => Promise } @@ -210,6 +211,166 @@ describe('queued resume attempt deadlines', () => { }) }) +describe('what a failed resume did to its paused execution', () => { + type StartResumeArgs = Parameters[0] + const pausedExecution = { + id: 'paused-execution-1', + workflowId: 'workflow-1', + executionId: 'parent-execution-1', + pausePoints: { + 'context-1': { contextId: 'context-1', blockId: 'hitl-1' }, + }, + executionSnapshot: createSnapshotSeed(), + metadata: {}, + } as StartResumeArgs['pausedExecution'] + const resumeArgs: StartResumeArgs = { + resumeEntryId: 'resume-entry-1', + resumeExecutionId: 'resume-execution-1', + pausedExecution, + contextId: 'context-1', + resumeInput: { approved: true }, + userId: 'user-1', + } + const attemptArgs = { + resumeEntryId: 'resume-entry-1', + pausedExecutionId: 'paused-execution-1', + parentExecutionId: 'parent-execution-1', + contextId: 'context-1', + failureReason: 'Execution can no longer be resumed', + preserveForRetry: true, + } + + beforeEach(() => { + resetDbChainMock() + }) + + it('reports the pause still resumable when a refused attempt leaves it paused', async () => { + queueTableRows(workflowExecutionLogs, [{ status: 'paused' }]) + queueTableRows(pausedExecutions, [{ automaticResumeRetryCount: 0, status: 'paused' }]) + + await expect(PauseResumeManager.markResumeAttemptFailed(attemptArgs)).resolves.toBe(true) + }) + + it.each(['completed', 'failed', 'cancelled'])( + 'reports the pause not resumable when the execution is already %s', + async (logStatus) => { + queueTableRows(workflowExecutionLogs, [{ status: logStatus }]) + queueTableRows(pausedExecutions, [{ automaticResumeRetryCount: 0, status: 'paused' }]) + + await expect(PauseResumeManager.markResumeAttemptFailed(attemptArgs)).resolves.toBe(false) + } + ) + + it.each([ + { logStatus: 'running', pauseStatus: 'paused', executionFailed: true }, + { logStatus: 'cancelled', pauseStatus: 'paused', executionFailed: false }, + { logStatus: 'running', pauseStatus: 'cancelling', executionFailed: false }, + ])( + 'reports the execution failed: $executionFailed for a $logStatus log and $pauseStatus pause', + async ({ logStatus, pauseStatus, executionFailed }) => { + queueTableRows(workflowExecutionLogs, [{ status: logStatus }]) + queueTableRows(pausedExecutions, [{ status: pauseStatus }]) + const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals + + await expect(managerInternals.markResumeFailed(attemptArgs)).resolves.toBe(executionFailed) + } + ) + + /** Resume args that collect every outcome the manager reports. */ + function argsReportingOutcomes(onAttemptFailed?: () => Promise) { + const outcomes: FailedResumeOutcome[] = [] + return { + outcomes, + args: { + ...resumeArgs, + onAttemptFailed: async (outcome: FailedResumeOutcome) => { + outcomes.push(outcome) + await onAttemptFailed?.() + }, + }, + } + } + + it.each([ + { stillResumable: true, reported: ['pause_retained'] }, + { stillResumable: false, reported: [] }, + ])( + 'reports a refused attempt as $reported when the pause is resumable: $stillResumable', + async ({ stillResumable, reported }) => { + const markResumeAttemptFailedSpy = vi + .spyOn(PauseResumeManager, 'markResumeAttemptFailed') + .mockResolvedValueOnce(stillResumable) + const { outcomes, args } = argsReportingOutcomes() + + try { + await expect(PauseResumeManager.startResumeExecution(args)).rejects.toMatchObject({ + name: 'ResumeAdmissionError', + }) + expect(outcomes).toEqual(reported) + } finally { + markResumeAttemptFailedSpy.mockRestore() + } + } + ) + + describe('when the resumed run fails', () => { + const rawError = new Error('Block failed') + const spies: { mockRestore: () => void }[] = [] + + function failRun(options: { executionFailed: boolean; queuedResumesError?: Error }) { + const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals + spies.push( + vi.spyOn(managerInternals, 'runResumeExecution').mockRejectedValueOnce(rawError), + vi + .spyOn(managerInternals, 'markResumeFailed') + .mockResolvedValueOnce(options.executionFailed), + options.queuedResumesError + ? vi + .spyOn(PauseResumeManager, 'processQueuedResumes') + .mockRejectedValueOnce(options.queuedResumesError) + : vi.spyOn(PauseResumeManager, 'processQueuedResumes').mockResolvedValueOnce() + ) + } + + afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore() + }) + + it.each([ + { executionFailed: true, reported: ['execution_failed'] }, + { executionFailed: false, reported: [] }, + ])( + 'reports $reported when it failed the execution: $executionFailed', + async ({ executionFailed, reported }) => { + failRun({ executionFailed }) + const { outcomes, args } = argsReportingOutcomes() + + await expect(PauseResumeManager.startResumeExecution(args)).rejects.toBe(rawError) + expect(outcomes).toEqual(reported) + } + ) + + it('reports the outcome before draining queued resumes, which may throw', async () => { + failRun({ executionFailed: true, queuedResumesError: new Error('queue drain failed') }) + const { outcomes, args } = argsReportingOutcomes() + + await expect(PauseResumeManager.startResumeExecution(args)).rejects.toThrow( + 'queue drain failed' + ) + expect(outcomes).toEqual(['execution_failed']) + }) + + it('rethrows the run failure when the outcome handler fails', async () => { + failRun({ executionFailed: true }) + const { args } = argsReportingOutcomes(async () => { + throw new Error('Database unavailable') + }) + + await expect(PauseResumeManager.startResumeExecution(args)).rejects.toBe(rawError) + }) + }) +}) + describe('resume failure diagnostic projection', () => { beforeEach(() => { resetDbChainMock() diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 8125ead3911..732240b12f9 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -38,6 +38,7 @@ import { terminalExecutionLogFields, } from '@/lib/logs/execution/cancellation' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { @@ -88,6 +89,19 @@ const execDb = dbFor('exec') const logger = createLogger('HumanInTheLoopManager') const RUN_BUFFER_UNAVAILABLE_ERROR = 'Run buffer temporarily unavailable' const RESUMABLE_PAUSED_STATUSES = ['paused', 'partially_resumed'] as const +/** + * Statuses of a finished execution's log, the same set the attempt-failure + * `CASE` preserves; a resume can never claim a log in one of them. + */ +const TERMINAL_EXECUTION_LOG_STATUSES = [ + 'cancelled', + 'failed', + 'completed', +] as const satisfies readonly PersistedWorkflowExecutionStatus[] + +function isTerminalExecutionLogStatus(status: string): boolean { + return (TERMINAL_EXECUTION_LOG_STATUSES as readonly string[]).includes(status) +} const CANCELLABLE_PAUSED_STATUSES = ['paused', 'partially_resumed'] as const const AUTOMATIC_RESUME_INTERVENTION_PREFIX = 'Automatic resume requires manual intervention: ' const PAUSED_CANCELLATION_QUEUE_FAILURE_REASON = 'Paused execution cancellation requested' @@ -134,6 +148,13 @@ class ResumeAdmissionError extends Error { } } +/** + * What a failed resume attempt did to its paused execution, as reported by the + * transaction that settled the attempt: the pause stayed resumable, or the + * resumed run failed the execution. + */ +export type FailedResumeOutcome = 'pause_retained' | 'execution_failed' + /** Matches the paused execution mode to the deployment recorded on its durable root log. */ export function requireResumeDeploymentVersion( useDraftState: unknown, @@ -419,6 +440,13 @@ interface StartResumeExecutionArgs { sendEvent?: (event: ExecutionEvent) => void onStream?: (streamingExec: StreamingExecution) => Promise onBlockComplete?: (blockId: string, data: BlockCompletionCallbackData) => Promise + /** + * Called once a failed attempt is settled, so a caller mirroring the run's + * state (a table cell) follows the execution. Not called when the attempt + * changed neither (the execution had already finished or was cancelled). A + * throw is logged and never replaces the attempt's error. + */ + onAttemptFailed?: (outcome: FailedResumeOutcome, error: unknown) => Promise abortSignal?: AbortSignal } @@ -864,6 +892,7 @@ export class PauseResumeManager { sendEvent, onStream, onBlockComplete, + onAttemptFailed, abortSignal, } = args @@ -986,8 +1015,9 @@ export class PauseResumeManager { } catch (error) { const message = toError(error).message await releaseExecutionSlot(resumeEntryId) + let outcome: FailedResumeOutcome | undefined if (error instanceof ResumeAdmissionError) { - await PauseResumeManager.markResumeAttemptFailed({ + const pauseResumable = await PauseResumeManager.markResumeAttemptFailed({ resumeEntryId, pausedExecutionId: pausedExecution.id, parentExecutionId: pausedExecution.executionId, @@ -996,22 +1026,33 @@ export class PauseResumeManager { preserveForRetry: true, retryable: error.retryable, }) + if (pauseResumable) outcome = 'pause_retained' } else if (message === RUN_BUFFER_UNAVAILABLE_ERROR) { - await PauseResumeManager.markResumeAttemptFailed({ + const pauseResumable = await PauseResumeManager.markResumeAttemptFailed({ resumeEntryId, pausedExecutionId: pausedExecution.id, parentExecutionId: pausedExecution.executionId, contextId, failureReason: message, }) + if (pauseResumable) outcome = 'pause_retained' } else { - await PauseResumeManager.markResumeFailed({ + const executionFailed = await PauseResumeManager.markResumeFailed({ resumeEntryId, pausedExecutionId: pausedExecution.id, parentExecutionId: pausedExecution.executionId, contextId, failureReason: message, }) + if (executionFailed) outcome = 'execution_failed' + } + if (outcome && onAttemptFailed) { + await onAttemptFailed(outcome, error).catch((hookError: unknown) => { + logger.error( + 'Failed to report a failed resume attempt', + projectResolvedSecretDiagnosticError(hookError, undefined, { resumeExecutionId }) + ) + }) } logger.error( 'Resume execution failed', @@ -2192,10 +2233,10 @@ export class PauseResumeManager { parentExecutionId: string contextId: string failureReason: string - }): Promise { + }): Promise { const now = new Date() - await execDb.transaction(async (tx) => { + return execDb.transaction(async (tx) => { const executionLog = await tx .select({ status: workflowExecutionLogs.status }) .from(workflowExecutionLogs) @@ -2224,7 +2265,7 @@ export class PauseResumeManager { .set({ status: 'cancelled', updatedAt: now, nextResumeAt: null }) .where(eq(pausedExecutions.id, args.pausedExecutionId)) } - return + return false } await tx @@ -2235,7 +2276,7 @@ export class PauseResumeManager { }) .where(eq(pausedExecutions.id, args.pausedExecutionId)) - if (pausedExecution?.status === 'cancelling') return + if (pausedExecution?.status === 'cancelling') return false await tx .update(workflowExecutionLogs) @@ -2246,6 +2287,8 @@ export class PauseResumeManager { sql`${workflowExecutionLogs.status} != 'cancelled'` ) ) + + return true }) } @@ -2257,10 +2300,10 @@ export class PauseResumeManager { failureReason: string preserveForRetry?: boolean retryable?: boolean - }): Promise { + }): Promise { const now = new Date() - await execDb.transaction(async (tx) => { + return execDb.transaction(async (tx) => { const executionLog = await tx .select({ status: workflowExecutionLogs.status }) .from(workflowExecutionLogs) @@ -2317,7 +2360,7 @@ export class PauseResumeManager { .set({ status: 'cancelled', updatedAt: now, nextResumeAt: null }) .where(eq(pausedExecutions.id, args.pausedExecutionId)) } - return + return false } await tx @@ -2348,7 +2391,7 @@ export class PauseResumeManager { }) .where(eq(pausedExecutions.id, args.pausedExecutionId)) - if (pausedExecution?.status === 'cancelling') return + if (pausedExecution?.status === 'cancelling') return false await tx .update(workflowExecutionLogs) @@ -2369,6 +2412,13 @@ export class PauseResumeManager { )` ) ) + + return ( + pausedExecution !== undefined && + isResumablePausedStatus(pausedExecution.status) && + executionLog !== undefined && + !isTerminalExecutionLogStatus(executionLog.status) + ) }) } From b08cd5f35f0dba2248e0128f210c1cfe0d3a6a5a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 22:00:31 -0700 Subject: [PATCH 08/22] fix(function): report code-placeholder compiler invariants as server failures (#8402) * fix(function): report code-placeholder compiler invariants as server failures Overlapping source edits and exhausted parser-sentinel/Python-marker allocation are broken compiler invariants, not problems in the user's code, but they threw CodePlaceholderCompileError and the Function route answered them with 422 like a user syntax error. They now throw a sibling CodePlaceholderInvariantError, which the route's instanceof check does not match, so it falls through to the existing 500 path. User placeholder and syntax errors keep their 422. * fix(function): keep an exhausted JavaScript sentinel space a user compile error User code can occupy every sentinel a short placeholder could take, so running out of them is the code's fault, not a compiler invariant. --- .../code-placeholders/compiler.test.ts | 31 +++++++++++++++++++ .../lib/execution/code-placeholders/python.ts | 3 +- .../lib/execution/code-placeholders/shared.ts | 14 ++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index 40c0afde1c0..65fe05ef593 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -6,6 +6,11 @@ import { type CodePlaceholderRuntimeBinding, compileCodePlaceholders, } from '@/lib/execution/code-placeholders' +import { + applySourceEdits, + CodePlaceholderCompileError, + CodePlaceholderInvariantError, +} from '@/lib/execution/code-placeholders/shared' import { CodeLanguage } from '@/lib/execution/languages' const installedGlobals = new Set() @@ -1543,3 +1548,29 @@ describe('python true positives survive the dot guard', () => { expect(await directReadNames(code, CodeLanguage.Python)).toEqual(['API_KEY']) }) }) + +describe('compiler failure classification', () => { + it('reports overlapping source edits as a compiler invariant, not a user compile error', () => { + const applyOverlappingEdits = () => + applySourceEdits('return value', [ + { start: 0, end: 6, text: 'yield' }, + { start: 3, end: 9, text: 'x' }, + ]) + + expect(applyOverlappingEdits).toThrow(CodePlaceholderInvariantError) + expect(applyOverlappingEdits).not.toThrow(CodePlaceholderCompileError) + }) + + it('reports an exhausted JavaScript sentinel space as a user compile error', async () => { + /** Every `$xyz$` sentinel a five-character `{{a}}` could take, packed under the code cap. */ + const alphabet = [...'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'] + const payloads = alphabet.flatMap((a) => + alphabet.flatMap((b) => alphabet.map((c) => `${a}${b}${c}`)) + ) + const code = `const taken = '$${payloads.join('$')}$'\nreturn {{a}}` + + await expect( + compileCodePlaceholders({ code, language: CodeLanguage.JavaScript, params: { a: 1 } }) + ).rejects.toBeInstanceOf(CodePlaceholderCompileError) + }) +}) diff --git a/apps/sim/lib/execution/code-placeholders/python.ts b/apps/sim/lib/execution/code-placeholders/python.ts index 2bee46e596f..eb8dc973d0d 100644 --- a/apps/sim/lib/execution/code-placeholders/python.ts +++ b/apps/sim/lib/execution/code-placeholders/python.ts @@ -2,6 +2,7 @@ import { sha256Hex } from '@sim/security/hash' import { applySourceEdits, CodePlaceholderCompileError, + CodePlaceholderInvariantError, createCodePlaceholderCompilationContext, isOffsetInRanges, type SourceEdit, @@ -243,7 +244,7 @@ function createSentinel(code: string): string { const sentinel = `__sim_placeholder_${digest}__` if (!code.includes(sentinel)) return sentinel } - throw new CodePlaceholderCompileError('Unable to allocate a collision-free Python marker') + throw new CodePlaceholderInvariantError('Unable to allocate a collision-free Python marker') } function pythonTriviaGap( diff --git a/apps/sim/lib/execution/code-placeholders/shared.ts b/apps/sim/lib/execution/code-placeholders/shared.ts index 52cd4ea88b4..8390c10b0d1 100644 --- a/apps/sim/lib/execution/code-placeholders/shared.ts +++ b/apps/sim/lib/execution/code-placeholders/shared.ts @@ -27,6 +27,18 @@ export class CodePlaceholderCompileError extends Error { } } +/** + * A broken compiler invariant: Sim's own compilation failed, not the user's code. + * Deliberately not a {@link CodePlaceholderCompileError}, so callers that answer a + * user compile error with a client status treat this as a server failure. + */ +export class CodePlaceholderInvariantError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodePlaceholderInvariantError' + } +} + /** * Scans `{{name}}` placeholders, accepting exactly what `/\{\{([^}]+)\}\}/g` accepts — * a name may contain `{`, because parameter keys are arbitrary strings rather than @@ -270,7 +282,7 @@ export function applySourceEdits(code: string, edits: SourceEdit[]): string { let output = '' for (const edit of sorted) { if (edit.start < cursor || edit.end < edit.start || edit.end > code.length) { - throw new CodePlaceholderCompileError('Overlapping code placeholder transformations') + throw new CodePlaceholderInvariantError('Overlapping code placeholder transformations') } output += code.slice(cursor, edit.start) output += edit.text From 4f6c2d3cdec437bba02780ae396c406cee8c37b8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 22:12:49 -0700 Subject: [PATCH 09/22] fix(knowledge): shrink the member lifecycle budget tests so they stop timing out in CI (#8404) --- .../member-document-lifecycle.integration.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts b/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts index 2786e141a00..801274d195a 100644 --- a/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts @@ -42,6 +42,15 @@ import { stillHoldsMemberSyncLock, } from '@/lib/knowledge/connectors/sync-lock' +/** + * A three-page budget keeps the budget-boundary tests near two thousand documents; the production + * budget made them seed over ten thousand and run past the timeout on slower CI Postgres. + */ +vi.mock('@/lib/knowledge/connectors/sync-limits', async (importOriginal) => ({ + ...(await importOriginal>()), + MEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN: 3, +})) + describe('member document lifecycle in PostgreSQL', () => { let ids: ReturnType let members: Awaited> From 4e39944e94bfb8cf15323ebf7aab0c5f67c7361a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 28 Sep 2026 22:13:04 -0700 Subject: [PATCH 10/22] docs(library): update sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform (#8398) * docs(library): update sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform * Pi Babysit: address PR #8398 feedback --------- Co-authored-by: Sim Pi Agent --- .../index.mdx | 298 ++++++++++-------- 1 file changed, 160 insertions(+), 138 deletions(-) diff --git a/apps/sim/content/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform/index.mdx b/apps/sim/content/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform/index.mdx index 533a94fd88c..ef82ab5f915 100644 --- a/apps/sim/content/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform/index.mdx +++ b/apps/sim/content/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform/index.mdx @@ -1,225 +1,247 @@ --- slug: sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform title: 'Sim vs Dify: Open-Source AI Workspace vs LLM App / RAG Platform' -description: 'Compare Sim and Dify for AI agents, RAG applications, workflow automation, licensing, deployment, and pricing. Learn when an AI workspace or an LLM app platform fits your team.' +description: 'A current, evidence-based comparison of Sim and Dify across visual workflow building, RAG, deployment, integrations, licensing, pricing, and team fit.' date: 2026-08-05 -updated: 2026-09-28 +updated: 2026-09-29 authors: - andrew -readingTime: 10 +readingTime: 12 tags: [Dify, Open Source, AI Agents, RAG, Sim] ogImage: /library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform/cover.jpg canonical: https://www.sim.ai/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform draft: false faq: - - q: 'Is Sim better than Dify?' - a: 'Sim is better than Dify for integration-heavy AI workflows, while Dify is better than Sim for app-centric RAG and LLM application development.' - - q: 'Is Dify better than Sim for RAG?' - a: 'Dify is usually the better fit for packaged RAG because knowledge bases and retrieval are first-class parts of its LLM application model, while Sim is better for retrieval embedded in a customizable workflow.' - - q: 'Is Sim a good Dify alternative?' - a: 'Sim is a strong alternative for teams that need agentic workflows and business automation alongside retrieval and chat. The better choice depends on whether your product is primarily an LLM application or a connected operational workflow.' - - q: 'Why should I compare licenses before choosing an AI platform?' - a: 'A license determines the rights to use, modify, distribute, and host software. Review it against your actual deployment, commercial, multi-tenant, and branding plans before committing to a platform.' - - q: 'Does Sim support RAG?' - a: 'Yes. Sim Knowledge Bases support retrieval workflows, and they can be used alongside Tables, Files, agents, tools, APIs, and scheduled workflows.' - - q: 'Can Sim be self-hosted?' - a: 'Sim core is available under Apache 2.0. Teams can use the repository and deployment documentation to evaluate whether self-hosting fits their infrastructure and operational requirements.' - - q: 'How should I compare Sim and Dify pricing?' - a: 'Model the cost using your real seat count, workspace count, environments, and expected model or tool usage. Check each vendor’s current pricing page because plans, credits, and limits can change.' - - q: 'Is Sim open source?' - a: 'Sim core is open-source software released under the OSI-approved Apache License 2.0; enterprise features in the repository’s ee directory are covered by a separate Sim Enterprise License.' - - q: 'Is Dify open source?' - a: 'Dify publishes its source code under Apache License 2.0 with additional conditions as of September 2026, so buyers should review the repository license rather than assuming Dify uses unmodified Apache 2.0 terms.' - - q: 'Can Sim and Dify be self-hosted?' - a: 'Sim and Dify both provide self-hosted deployment paths, although their license terms and operational requirements differ.' - - q: 'Which is better for building AI agents, Sim or Dify?' - a: 'Sim is usually the better fit for agents that execute multi-step work across external systems, while Dify is usually the better fit for agents delivered as LLM applications with managed knowledge.' - - q: 'Which is better for building a chatbot, Sim or Dify?' - a: 'Dify is generally the more direct fit for a knowledge-grounded chatbot, while Sim is a stronger fit when the chatbot must trigger a broader operational workflow.' - - q: 'Which is better for workflow automation, Sim or Dify?' - a: 'Sim is generally better suited to workflow automation because its primary product model is a visual process connecting models, tools, logic, data, and external services.' - - q: 'Which has more integrations, Sim or Dify?' - a: 'Sim and Dify organize integrations differently, so buyers should test their required connectors instead of relying on vendor totals that may count models, plugins, tools, and native applications differently.' - - q: 'Is Sim free?' - a: 'Sim core can be self-hosted under Apache License 2.0 without a software license fee, although infrastructure and model usage still cost money and current hosted pricing should be checked separately.' - - q: 'Is Dify free?' - a: 'Dify provides source code and a self-hosted Community deployment path, but buyers should verify its current hosted plan limits and repository license conditions as of September 2026.' - - q: 'How does Sim compare with n8n?' - a: 'Sim focuses more directly on AI-native agent workflows and uses Apache License 2.0, while n8n is a broader automation platform whose Sustainable Use License is source-available rather than OSI-approved.' - - q: 'How does Dify compare with n8n?' - a: 'Dify is centered on LLM applications, managed knowledge, and RAG, while n8n is centered on general workflow automation across application connectors.' - - q: 'What is the best n8n alternative for AI workflows?' - a: 'Sim is a strong n8n alternative for teams prioritizing AI-native workflows and Apache 2.0 self-hosting, while the best choice still depends on the required connectors and automation patterns.' - - q: 'What is the best AI agent builder?' - a: 'Sim is one candidate for the best AI agent builder, but buyers should use Sim’s canonical 2026 AI agent builder guide for the broader market comparison rather than treating a Sim-versus-Dify page as a universal ranking.' - - q: 'Can you migrate from Dify to Sim?' - a: 'Sim can rebuild many Dify orchestration patterns, but migration usually requires mapping prompts, models, retrieval, variables, API contracts, credentials, and application interfaces rather than importing the project unchanged.' - - q: 'Can you migrate from Sim to Dify?' - a: 'Dify can reproduce many Sim workflows that primarily support an LLM application, but cross-system actions and workflow-specific integrations may need to be redesigned or implemented as tools, plugins, or API calls.' - - q: 'Which platform is better for an internal knowledge assistant?' - a: 'Dify is generally the more direct fit for an internal knowledge assistant, while Sim is preferable when the assistant must also execute actions across business systems.' - - q: 'Which platform is better for enterprise deployment?' - a: 'Sim and Dify can both be evaluated for enterprise deployment, but the correct choice depends on security controls, identity requirements, support, data residency, infrastructure, licensing, and the intended application architecture.' + - q: "What is the difference between Sim and Dify?" + a: "Sim is an AI workflow and agent builder for automating processes across models, data, and external applications, while Dify is an LLM application platform centered on chatflows, workflows, agents, model management, and knowledge retrieval." + - q: "Is Sim better than Dify?" + a: "Sim is the better fit when a team wants an Apache 2.0 visual workspace for AI agents and multi-application automation, while Dify may fit better when managed LLM applications and knowledge-base operations are the main requirements." + - q: "Is Dify better than Sim?" + a: "Dify is the better fit when a team primarily needs to build and operate retrieval-heavy LLM applications, while Sim is usually the better fit for AI workflows that coordinate models, data, logic, and business applications." + - q: "Which is better for RAG, Sim or Dify?" + a: "Dify is often the more specialized choice for teams whose product revolves around managed knowledge bases and retrieval configuration, while Sim is a strong choice when retrieval is one step inside a broader automated workflow." + - q: "Which is better for AI workflow automation, Sim or Dify?" + a: "Sim is generally the more direct fit for visual AI workflow automation across external applications, while Dify is generally the more direct fit for workflows packaged as LLM applications." + - q: "Is Sim open source?" + a: "Sim core is open-source software licensed under the OSI-approved Apache License 2.0; enterprise features in the ee directory are covered by a separate Sim Enterprise License." + - q: "Is Dify open source?" + a: "Dify makes its source code available under the Dify Open Source License, which is based on Apache License 2.0 but adds conditions and is not the standard OSI-approved Apache 2.0 license." + - q: "Can Sim be self-hosted?" + a: "Sim can be self-hosted, and its Apache 2.0 license permits use, modification, and distribution subject to the license terms." + - q: "Can Dify be self-hosted?" + a: "Dify can be self-hosted, including through its documented Docker Compose deployment, subject to the Dify Open Source License." + - q: "How much do Sim and Dify cost?" + a: "Sim and Dify both publish hosted-cloud pricing, while self-hosters must also account for infrastructure, model, storage, database, and operational costs; as of September 2026, buyers should confirm current prices and quotas on each vendor's official pricing page." + - q: "Which has more integrations, Sim or Dify?" + a: "Sim emphasizes connections used in end-to-end AI automation, while Dify extends LLM applications through models, tools, APIs, and plugins; buyers should compare the current official catalogs against the exact systems they need." + - q: "Which is better for teams, Sim or Dify?" + a: "Sim fits teams coordinating AI-driven business workflows, while Dify fits teams building and operating LLM applications with centralized prompt, model, retrieval, and application configuration." + - q: "How do Sim and Dify compare with n8n?" + a: "Sim focuses on AI-native agents and workflows, Dify focuses on LLM applications and RAG, and n8n is the broadest general-purpose application automation platform of the three." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License and Enterprise License, and the Sustainable Use License is not an OSI-approved open-source license." + - q: "What is the best open-source n8n alternative?" + a: "Sim is a strong open-source n8n alternative for AI-native workflows because Sim uses the OSI-approved Apache 2.0 license, although n8n remains a stronger fit for some conventional application-automation use cases." + - q: "What is the best open-source Zapier alternative for AI workflows?" + a: "Sim is a strong open-source Zapier alternative when AI agents, model calls, retrieval, and visual workflow logic are central requirements." + - q: "Is Sim free?" + a: "Sim can be self-hosted under Apache 2.0 without a software license fee, but infrastructure, model APIs, storage, and other connected services can still create costs." + - q: "Sim vs Gumloop: which should I choose?" + a: "Sim is the clearer choice when Apache 2.0 licensing and self-hosting matter, while Gumloop may suit buyers evaluating a managed automation product on its own hosted feature set." --- ## TL;DR -Sim is the better fit for teams building integration-heavy AI workflows, while Dify is the better fit for teams building LLM applications around managed knowledge bases, retrieval, prompts, and app-facing APIs. Both provide visual development and documented self-hosting, but Sim is workflow-first and Dify is LLM-app-first. Compare the current licenses, hosted terms, required integrations, and operational costs against a representative project before choosing. +Sim and Dify overlap as visual platforms for building AI applications, but they are optimized for different jobs: **Sim focuses on AI agents and workflows spanning models, data, and external applications, whereas Dify focuses on building and operating LLM applications with workflows, chatflows, agents, and retrieval.** + +Choose Sim when flexible AI automation, an OSI-approved core license, and broader workflow orchestration are priorities. Choose Dify when the central task is managing retrieval-heavy LLM applications. Neither platform is universally superior. + +_Reviewed September 2026. Product capabilities, hosted pricing, quotas, and license terms should be reconfirmed from the linked first-party sources before purchase or deployment._ ## What is the difference between Sim and Dify? -Sim is an AI workflow and agent platform, whereas Dify is an [LLM application development platform](https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction) with first-party knowledge and RAG features. +**Sim is an AI workflow and agent builder, while Dify is an LLM application development platform with a particularly strong emphasis on application configuration and RAG.** + +[Sim](https://www.sim.ai/) provides a visual workspace for connecting models, agents, knowledge, logic, APIs, and business applications into executable workflows. Its core is available under the standard [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE). -Sim is designed around visual workflows that connect models, agents, tools, data, and external services. That structure suits multi-step automations such as qualifying incoming requests, researching accounts, updating a CRM, drafting content, or routing work for human approval. +[Dify](https://dify.ai/) provides visual workflows and chatflows alongside model-provider management, agents, knowledge bases, retrieval settings, APIs, and application publishing. Its product is organized around the lifecycle of an LLM-powered application. -Dify is designed around building and operating LLM applications. Its [documented product model](https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction) includes application orchestration, model management, observability, and retrieval-augmented generation. That structure suits chatbots, knowledge assistants, text generators, and other applications that need managed retrieval and a stable interface for end users or developers. +The products overlap, but their centers of gravity differ: -Neither product is limited to one category. Sim can implement RAG inside a broader workflow, and Dify can orchestrate multi-step processes. The distinction is the center of gravity: Sim is workflow-first, while Dify is LLM-app-first. +- **Choose Sim** when AI is part of a multi-step process involving external systems, branching logic, data, human-facing tools, or multiple model calls. +- **Choose Dify** when the primary deliverable is a chatbot, assistant, agent, or other LLM application backed by centrally managed prompts and knowledge. ## What are the key facts about Sim, Dify, and n8n? -Sim, Dify, and n8n overlap in visual automation, but their licenses and primary product models differ. +**Sim, Dify, and n8n differ most clearly in license, deployment model, and the type of workflow each platform treats as its primary use case.** -- **Sim:** Sim is an AI workflow and agent builder whose core is released under the OSI-approved [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE); enterprise features are covered by a separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Sim offers hosted and [self-hosted deployment paths](https://docs.sim.ai/platform/self-hosting). -- **Dify:** As of September 2026, Dify publishes its source code under Apache License 2.0 with [additional conditions described in its repository license](https://github.com/langgenius/dify/blob/main/LICENSE), so buyers should review those conditions rather than treating Dify as unmodified Apache 2.0 software. -- **n8n:** As of September 2026, n8n uses the [Sustainable Use License and Enterprise License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/); these are fair-code, source-available licenses rather than OSI-approved open-source licenses and restrict some commercial uses. +- **Sim:** Sim core uses the OSI-approved [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), supports [Docker and Kubernetes self-hosting](https://docs.sim.ai/platform/self-hosting), and offers a hosted service whose current plans and usage charges are listed on the [official Sim pricing page](https://www.sim.ai/pricing). Enterprise features in the repository's `ee` directory use a separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). +- **Dify:** Dify supports [self-hosting](https://docs.dify.ai/en/self-host/deploy/overview) under the [Dify Open Source License](https://github.com/langgenius/dify/blob/main/LICENSE) and offers hosted workspace plans with quotas and limits on the [official Dify pricing page](https://dify.ai/pricing). +- **n8n:** n8n supports self-hosting under its source-available [Sustainable Use License and Enterprise License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) and sells hosted plans using the units and limits on the [official n8n pricing page](https://n8n.io/pricing/). -For more context on license categories, see [Apache 2.0 vs fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code). +As of September 2026, Sim's core uses Apache 2.0, which appears on the [OSI Approved Licenses list](https://opensource.org/licenses). Dify's repository uses a modified license with additional conditions, and n8n's Sustainable Use License is source-available rather than OSI-approved open source. For more context, see [Apache 2.0 vs fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code). ## How do Sim and Dify compare at a glance? -Sim leads with flexible agentic workflow automation, while Dify leads with an integrated LLM application and knowledge-base experience. +**Sim is usually the better match for cross-application AI workflows, while Dify is usually the better match for teams operating LLM applications and managed retrieval.** -| Buyer question | Sim | Dify | Better fit | +| Buyer criterion | Sim | Dify | Better fit when… | |---|---|---|---| -| What is the product built around? | Visual AI agents and multi-step workflows | [LLM applications, workflows, knowledge, retrieval, and APIs](https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction) | Sim for workflow automation; Dify for app-centric LLM development | -| How are workflows built? | Canvas-based blocks connecting models, tools, logic, data, and integrations | [Visual orchestration within Dify applications](https://docs.dify.ai/en/cloud/use-dify/build/workflow-chatflow) | Sim for cross-system processes; Dify for application-specific orchestration | -| How is RAG handled? | Retrieval can be composed as part of a larger workflow | [Knowledge bases, retrieval configuration, and application grounding](https://docs.dify.ai/en/api-reference/guides/knowledge) are central product concepts | Dify for a packaged RAG experience; Sim for customizable retrieval pipelines | -| What deployment paths are available? | Hosted service and Apache 2.0 self-hosting | Hosted service and [documented self-hosting](https://docs.dify.ai/en/self-host/deploy/overview) | Both, subject to each product’s license and operational requirements | -| How do integrations work? | Workflow connectors, APIs, webhooks, and tool blocks | [Plugins, model providers, APIs, tools, and extensions](https://docs.dify.ai/en/cloud/use-dify/workspace/plugins) | Sim for business-system workflows; Dify for LLM-app components | -| What is the license? | Apache License 2.0 for the core; [separate license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) for enterprise features | [Apache License 2.0 with additional repository conditions](https://github.com/langgenius/dify/blob/main/LICENSE) as of September 2026 | Sim for teams requiring standard Apache 2.0 terms | -| Who is it best for? | Automation teams, AI operations teams, and developers coordinating work across systems | AI product teams building chat, assistant, generation, or knowledge applications | Depends on the primary product being built | -| How should pricing be checked? | Confirm current hosted terms on Sim’s official pricing page; Apache 2.0 self-hosting has no software license fee | Confirm current hosted terms on Dify’s official pricing page and self-hosting terms in its repository | Compare current usage, infrastructure, support, and operational costs | +| Primary focus | Visual AI agents and workflows | [LLM applications, chatflows, workflows, agents, and RAG](https://docs.dify.ai/en/learn/key-concepts) | The required outcome determines the fit | +| Workflow building | Coordinates models, logic, data, APIs, and external applications | [Coordinates application steps, tools, retrieval, and outputs](https://docs.dify.ai/en/cloud/use-dify/build/workflow-chatflow) | Sim for broader automation; Dify for app-centric orchestration | +| RAG | Knowledge and retrieval can be incorporated into larger workflows | [Knowledge-base ingestion and retrieval](https://docs.dify.ai/en/api-reference/guides/knowledge) are central platform capabilities | Dify for a RAG-centered product; Sim for RAG inside broader automation | +| Integrations | Emphasizes workflow connections to models and business systems | Emphasizes [model providers, tools, APIs, data sources, and plugins](https://docs.dify.ai/en/cloud/use-dify/workspace/plugins) | Compare the current catalogs against required systems | +| Self-hosting | [Supported](https://docs.sim.ai/platform/self-hosting) | [Supported, including Docker Compose](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) | Both can fit self-hosting requirements | +| License | Apache License 2.0 for core; separate enterprise-feature license | Dify Open Source License with additional conditions | Sim when an OSI-approved core license is required | +| Hosted service | Available | Available | Compare current quotas, usage units, and support requirements | +| Typical team | Automation, operations, product, and engineering teams building AI workflows | Product and AI teams building LLM applications and knowledge assistants | Match the platform to the team's main operating model | +| General automation alternative | More AI-native than a conventional automation platform | More LLM-app-centric than a conventional automation platform | Consider n8n when broad non-AI app automation dominates | -The linked license, deployment, and pricing sources in this article substantiate the factual entries in the table. Buyers comparing more products can also review [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). +## Which is better for visual workflow building, Sim or Dify? -## Which is better for building AI workflows, Sim or Dify? +**Sim is generally the more direct choice for visually automating an end-to-end process across AI models and external applications, while Dify is generally the more direct choice for visually composing an LLM application.** -Sim is generally the better fit for AI workflows that coordinate models, tools, business applications, branching logic, and human decisions. +A Sim workflow can represent a broader operational process: receive or fetch data, retrieve context, invoke one or more models, apply logic, call external services, and deliver the result. This makes Sim suitable when the AI step is part of a larger automation rather than the entire product. -Sim’s canvas treats the full process as the product. A team can connect model calls with APIs, webhooks, data operations, conditional paths, and other actions without forcing every automation into the shape of a chatbot or standalone LLM application. +Dify's documented [Workflow and Chatflow builder](https://docs.dify.ai/en/cloud/use-dify/build/workflow-chatflow) combines models, tools, logic, retrieval, conditions, and outputs in applications that can be published through the web or APIs. -Dify also provides [visual workflow orchestration](https://docs.dify.ai/en/cloud/use-dify/build/workflow-chatflow), and it can be the more coherent choice when the workflow exists primarily to power a Dify application. For example, a customer-facing assistant that retrieves documentation, evaluates a question, generates an answer, and exposes the result through an application API fits Dify’s application model well. - -Choose Sim when the workflow must span several operational systems. Choose Dify when the workflow is principally the internal logic of an LLM application. +Neither approach is inherently better. The important architectural question is whether the team is primarily automating a process or operating an LLM application. ## Which is better for RAG, Sim or Dify? -Dify is generally the faster fit for teams that want knowledge ingestion and retrieval managed as first-class parts of an LLM application, while Sim is the more flexible fit when retrieval is one stage in a broader automation. +**Dify is often the better fit for a RAG-centered application, while Sim is often the better fit when retrieval is one component of a larger AI workflow.** -Dify places knowledge bases, document processing, retrieval configuration, and application grounding in one product model. Its [Knowledge API documentation](https://docs.dify.ai/en/api-reference/guides/knowledge) covers managing and querying knowledge bases for search or RAG. That can reduce the amount of architecture a team must assemble for a conventional support assistant, internal knowledge bot, or documentation search application. +Dify gives knowledge management a prominent role in the product. Teams can create knowledge bases, connect them to applications, configure chunking and retrieval, and manage the resulting experience in the same platform, as described in the [official Dify knowledge documentation](https://docs.dify.ai/en/cloud/use-dify/knowledge/create-knowledge/introduction). -Sim lets teams compose retrieval with the rest of a workflow. That approach is useful when documents must be collected from several systems, transformed, classified, searched, checked, and then used to trigger downstream actions. It also lets a team choose the data and retrieval components appropriate to its architecture rather than centering the entire project on a built-in knowledge base. +Sim supports knowledge and retrieval within visual workflows, allowing retrieved context to be combined with model calls, application actions, conditions, and additional processing. Buyers can review the [Sim retrieval documentation](https://docs.sim.ai/knowledgebase/debugging-retrieval). -Dify is the clearer choice for packaged, app-centric RAG. Sim is the clearer choice for customizable RAG pipelines embedded in operational workflows. +For a support assistant whose defining feature is answering from an internal corpus, Dify's application-and-knowledge orientation may reduce conceptual overhead. For a process that retrieves information and then updates systems, requests approval, generates assets, or triggers downstream actions, Sim's broader workflow orientation may be more natural. -## Which is easier to self-host, Sim or Dify? +RAG quality still depends on document preparation, chunking, embedding choices, retrieval settings, reranking, model behavior, evaluation, and source freshness. A platform cannot remove the need to test those components against real queries. -Sim and Dify both document self-hosting, but the easier deployment depends on the team’s license requirements, infrastructure skills, scale, and need for operational support. +## Can Sim and Dify be self-hosted? -Sim’s standard [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) gives teams broad rights to use, modify, and distribute Sim core under the license terms. Enterprise features in the `ee` directory are excluded and fall under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an Enterprise subscription for production use and prohibits modification and redistribution. This is important for organizations that require an OSI-approved license or expect to make substantial internal modifications. Sim documents [Docker and Kubernetes self-hosting](https://docs.sim.ai/platform/self-hosting). +**Sim and Dify can both be self-hosted, but their license terms and operational requirements differ.** -Dify provides a self-hosted Community deployment path, including a documented [Docker Compose installation](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose). As of September 2026, its [repository license](https://github.com/langgenius/dify/blob/main/LICENSE) adds conditions to Apache 2.0, so legal and procurement teams should review the actual license before adopting or redistributing the software. +Sim's core source code is available in the [official Sim GitHub repository](https://github.com/simstudioai/sim) under [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE). Apache 2.0 permits use, modification, and distribution subject to its terms and includes an express patent grant. Enterprise features in the `ee` directory are covered by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). -Self-hosting either product transfers responsibility for infrastructure, upgrades, secrets, model credentials, databases, monitoring, backups, and security controls to the deploying organization. A source-available repository does not make those operational costs disappear. +Dify's source code is available in the [official Dify GitHub repository](https://github.com/langgenius/dify). Dify documents self-hosting through [Docker Compose](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose), but buyers should read the repository's [current license](https://github.com/langgenius/dify/blob/main/LICENSE) because it adds conditions to Apache 2.0. -## Which has better integrations, Sim or Dify? +Self-hosting either product still requires operational ownership. Teams should plan for secrets, model credentials, databases, storage, networking, access controls, backups, observability, upgrades, and incident response. -Sim is better aligned with cross-application automation, while Dify is better aligned with the model, retrieval, plugin, and API components of an LLM application. +## Is Sim open source, and is Dify open source? -Raw integration counts are not a reliable way to compare the products because vendors classify models, tools, triggers, community packages, and native connectors differently. Buyers should instead test the exact systems required by the intended workflow. +**Sim core is open source under Apache License 2.0, while Dify is source-available under a modified license that adds conditions beyond standard Apache 2.0.** -For Sim, verify that the necessary workflow triggers, actions, APIs, authentication methods, and data transformations are available. For Dify, verify the necessary model providers, knowledge sources, tools, plugins, APIs, and application interfaces described in its [current tools documentation](https://docs.dify.ai/en/cloud/use-dify/workspace/tools). +This distinction matters when procurement or engineering policy requires an OSI-approved license. Sim's [LICENSE file](https://github.com/simstudioai/sim/blob/main/LICENSE) contains the standard Apache 2.0 terms, while enterprise features carry a [separate license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). -If a required connector is missing, compare whether the platform can call the service through HTTP, a webhook, custom code, or an extension mechanism. That fallback often matters more than the published integration count. For background on one extension standard, read [what an MCP server is](https://www.sim.ai/library/what-is-an-mcp-server). +Dify describes its terms in its [official LICENSE file](https://github.com/langgenius/dify/blob/main/LICENSE). Organizations considering Dify should review the additional conditions, especially if they plan to provide a multi-tenant service, alter branding, redistribute the software, or embed it in a commercial offering. -## Which is better for teams, Sim or Dify? +This article does not provide legal advice. Teams with commercial redistribution or hosted-service plans should have counsel evaluate the current license text. Buyers comparing licensing across the category can also review [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). -Sim is better suited to teams that think in automations and operational processes, while Dify is better suited to teams that think in LLM applications, knowledge bases, and product APIs. +## Which has more integrations, Sim or Dify? -Sim is likely to fit automation engineers, AI operations teams, growth teams, and developers who need to coordinate actions across multiple services. Its visual workflow model makes the sequence of operational steps the primary artifact. +**Sim is oriented toward integrations used in complete AI-powered business workflows, while Dify is oriented toward the models, tools, plugins, APIs, and data sources used by LLM applications.** -Dify is likely to fit AI product teams, application developers, and knowledge-management teams shipping assistants or generation features. Its [application publishing model](https://docs.dify.ai/en/cloud/use-dify/publish/README) groups workflows, models, retrieval, APIs, and observability around an LLM experience. +Raw integration counts are a weak buying metric because vendors classify models, triggers, actions, community plugins, and generic HTTP connections differently. A better evaluation is to test the exact systems required by the proposed workflow. -Mixed teams should prototype one representative use case in each platform. The test should include the real data source, model, authentication flow, approval step, failure path, and deployment environment rather than a simplified demonstration. +For Sim, verify whether each required service has the necessary trigger, action, authentication method, and data fields in the [current Sim documentation](https://docs.sim.ai/). For Dify, inspect the [current Dify integrations documentation](https://docs.dify.ai/en/cloud/use-dify/workspace/plugins) from the perspective of the target LLM application. -## How does n8n compare with Sim and Dify? +Both products can use APIs to reach services without a dedicated connector, but a generic API call may require more setup and maintenance than a maintained native integration. -n8n is the incumbent to evaluate when conventional application automation is as important as AI orchestration. +## How much do Sim and Dify cost? -n8n describes itself as a [workflow automation tool combining AI features with business process automation](https://docs.n8n.io/), making it relevant for buyers comparing Sim with established automation platforms. Sim is more directly centered on AI agents and model-driven workflows. Dify is more directly centered on LLM applications and managed knowledge. +**Sim and Dify costs depend on hosted plan limits, model usage, storage, execution volume, and whether the team self-hosts.** -The licensing distinction is material. As of September 2026, n8n’s [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) is fair-code and source-available rather than OSI-approved, while Sim uses the OSI-approved [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE). Organizations that plan to self-host, modify, redistribute, or offer workflows as part of a commercial service should review the applicable licenses rather than relying on the word “open.” +As of September 2026, current hosted prices and included quotas should be taken directly from the [Sim pricing page](https://www.sim.ai/pricing) and [Dify pricing page](https://dify.ai/pricing). Exact numeric prices are not reproduced here because plan prices and allowances can change independently of this comparison. -Choose n8n when a mature general automation ecosystem is the primary requirement. Choose Sim when AI-native workflow composition and Apache 2.0 licensing are priorities. Choose Dify when the primary deliverable is an LLM application with integrated knowledge and retrieval. +A useful total-cost comparison should include: -## How much do Sim and Dify cost? +1. Hosted workspace or subscription charges. +2. Model-provider token or inference charges. +3. Workflow or message usage beyond included allowances. +4. Vector storage, databases, files, and network transfer. +5. Engineering time for custom integrations and evaluation. +6. Infrastructure and operational labor for self-hosting. +7. Security, support, governance, and compliance requirements. + +Self-hosted software is not cost-free. Sim core does not require a software license fee for use under Apache 2.0, but infrastructure and connected services still cost money. Dify self-hosting similarly creates infrastructure and operations costs and remains subject to the Dify Open Source License. + +## Which is better for teams, Sim or Dify? + +**Sim fits teams automating AI-driven processes across systems, while Dify fits teams building and managing LLM applications as products or internal services.** + +Choose Sim when the team includes automation engineers, operations specialists, product builders, or developers who need to see and modify a complete AI workflow. Sim's Apache 2.0 core license is also relevant for organizations with strict open-source or extensibility requirements. + +Choose Dify when the team wants a centralized environment for configuring model providers, retrieval, tools, and application behavior. Dify can be particularly suitable for teams repeatedly launching assistants or other knowledge-backed LLM interfaces. + +For either platform, test collaboration and governance against real requirements rather than feature labels. Important questions include role-based access, environment separation, versioning, approval processes, secret management, logs, evaluation, and rollback procedures. -Sim and Dify should be compared using their official pricing pages because this article does not preserve exact hosted prices that may become stale. +## How do Sim and Dify compare with n8n? -As of September 2026, buyers should verify [Sim’s current hosted pricing](https://www.sim.ai/pricing) and [Dify’s current hosted pricing](https://dify.ai/pricing). Compare the billing unit, included usage, model costs, storage, seats, execution limits, support, and overage policy for the intended workload. +**Sim is the AI-native workflow option, Dify is the LLM-application option, and n8n is the general-purpose automation option.** -For self-hosting, include infrastructure, database, observability, backup, upgrade, security, and engineering costs. Sim’s [Apache 2.0 software](https://github.com/simstudioai/sim/blob/main/LICENSE) can be self-hosted without a software license fee under that license, but operating it still consumes infrastructure and staff time. Dify adopters should review the repository’s [current additional license conditions](https://github.com/langgenius/dify/blob/main/LICENSE) alongside the technical costs. +[n8n](https://n8n.io/) is relevant because many buyers begin with application automation and then add AI. Its [official documentation](https://docs.n8n.io/) describes a fair-code workflow automation tool combining AI capabilities with business process automation, which can make it a fit when most steps move data between applications and only some steps use models. -## When should you choose Sim instead of Dify? +Sim is more directly centered on building AI agents and model-driven workflows in a visual workspace. Dify is more directly centered on creating and operating LLM applications with retrieval and tools. -Sim is the stronger fit when the main requirement is an AI-driven workflow that crosses multiple tools, data sources, and operational steps. +Licensing also differs. As of September 2026, n8n uses its [Sustainable Use License and Enterprise License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/). These are fair-code licenses rather than OSI-approved open-source licenses. Sim core uses Apache 2.0, while Dify uses its own modified license. -Choose Sim when: +A simple decision rule is: -- The primary artifact is a workflow rather than a chatbot or LLM application. -- Agents must take actions across business systems. -- Retrieval is one component in a larger pipeline. -- The team requires the standard Apache License 2.0. -- The workflow needs branching, approvals, API calls, and downstream automation. -- The team wants the same workflow architecture available through hosted or self-hosted deployment. +- Choose **n8n** when broad, conventional application automation is the dominant need. +- Choose **Dify** when a managed LLM application or RAG experience is the dominant need. +- Choose **Sim** when AI agents and model-driven processes must coordinate knowledge, logic, and external systems under an OSI-approved core license. -Sim’s advantage in these cases is fit, not universal superiority. Teams focused on managed knowledge applications may reach production faster with Dify. +## Is Sim better than Dify? -## When should you choose Dify instead of Sim? +**Sim is better suited to cross-application AI automation and Apache 2.0 core self-hosting, while Dify is better suited to teams whose primary unit of work is an LLM application.** -Dify is the stronger fit when the main requirement is an [LLM application with integrated workflows, knowledge, retrieval, APIs, and runtime management](https://docs.dify.ai/en/cloud/use-dify/knowledge/readme). +Sim's strongest case is not an unsupported claim of universal superiority. Its advantage is the combination of visual AI workflow building, external-system orchestration, self-hosting, and a standard open-source core license. -Choose Dify when: +Dify's strongest case is its cohesive environment for models, application workflows, knowledge bases, retrieval, and LLM application delivery. Teams should favor Dify when those capabilities align more closely with the system they are building. -- The primary deliverable is a chatbot, assistant, generator, or other LLM application. -- The team wants knowledge bases and retrieval managed inside the same platform. -- Application APIs and app-specific monitoring are central requirements. -- Product developers want one environment for model configuration, prompts, retrieval, and deployment. -- The workflow mainly exists to support a Dify application. +A proof of concept should use the same documents, models, external systems, security requirements, and success criteria planned for production. Compare build time, answer quality, observability, failure recovery, deployment effort, and total cost rather than relying on a generic feature checklist. -Dify’s advantage in these cases is product coherence. Teams should still review its current [repository license conditions](https://github.com/langgenius/dify/blob/main/LICENSE) and confirm that required integrations and deployment controls meet their policies. +## What should I test before choosing Sim or Dify? -## What is the final verdict on Sim vs Dify? +**Sim and Dify should be tested with one representative production workflow before either platform is selected.** -Sim is the better choice for integration-heavy AI workflows, while Dify is the better choice for app-centric RAG and LLM product development. +Use the following evaluation checklist: -Use Sim when agents must coordinate work across tools and when Apache 2.0 licensing matters. Use Dify when the shortest path to a knowledge-grounded assistant or LLM application matters more than using a general workflow canvas. +1. Build the same high-value workflow in both products. +2. Connect the actual model providers, knowledge sources, and business applications required in production. +3. Measure retrieval relevance and answer grounding with a fixed evaluation set. +4. Test malformed inputs, unavailable APIs, model timeouts, and partial failures. +5. Inspect logs and determine whether operators can diagnose each failed step. +6. Confirm authentication, secret storage, access controls, and data retention. +7. Reproduce the intended cloud or self-hosted deployment. +8. Calculate model, platform, infrastructure, and labor costs at expected volume. +9. Review Sim's Apache 2.0 core license, its enterprise-feature license, and Dify's current license against the intended use. +10. Ask the people who will maintain the system to modify and debug the workflow. -Teams still deciding among the broader market should consult [The Best AI Agent Builders in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026), which is Sim’s canonical guide to the “best AI agent builder” question. This page owns the narrower Sim-versus-Dify decision rather than making a universal head-term ranking claim. +The better product is the one that satisfies the real deployment and operating constraints with the least avoidable complexity. -## Which official sources support this comparison? +For a broader category comparison, see [Best AI Agent Builder in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026); this page remains focused on the Sim-versus-Dify decision. -Sim, Dify, and n8n publish the primary documentation and license texts buyers should review before making a final decision. +## Where can I verify the claims in this comparison? + +**Sim, Dify, and n8n publish the primary documentation, repositories, licenses, deployment instructions, and pricing pages needed to verify this comparison.** + +Primary sources: - [Sim website](https://www.sim.ai/) -- [Sim pricing](https://www.sim.ai/pricing) -- [Sim source repository and Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE) +- [Sim documentation](https://docs.sim.ai/) +- [Sim GitHub repository](https://github.com/simstudioai/sim) +- [Sim Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE) - [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) - [Sim self-hosting documentation](https://docs.sim.ai/platform/self-hosting) -- [Dify documentation](https://docs.dify.ai/en/home) +- [Sim pricing](https://www.sim.ai/pricing) +- [Dify website](https://dify.ai/) +- [Dify documentation](https://docs.dify.ai/en/learn/key-concepts) +- [Dify GitHub repository](https://github.com/langgenius/dify) +- [Dify license](https://github.com/langgenius/dify/blob/main/LICENSE) +- [Dify self-hosting documentation](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) - [Dify pricing](https://dify.ai/pricing) -- [Dify source repository and license](https://github.com/langgenius/dify/blob/main/LICENSE) -- [Dify self-hosting documentation](https://docs.dify.ai/en/self-host/deploy/overview) -- [n8n Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) -- [n8n source repository](https://github.com/n8n-io/n8n) +- [n8n license documentation](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) +- [n8n pricing](https://n8n.io/pricing/) From e715c5b09aba11720b2eba719f0c42f030078e26 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 22:53:36 -0700 Subject: [PATCH 11/22] fix(knowledge): name embedding quota, key, and deadline failures in knowledge search (#8405) * fix(knowledge): name embedding quota, key, and deadline failures in knowledge search * fix(knowledge): keep keyless Ollama and mixed fallback quota attribution accurate * test(embeddings): prove the quota pause through returned errors, not mock call counts --- apps/sim/lib/embeddings/client.test.ts | 40 +++++++++++++ apps/sim/lib/embeddings/client.ts | 32 ++++++++-- apps/sim/lib/embeddings/index.ts | 1 + .../lib/internal/knowledge/execute-tool.ts | 3 +- .../lib/knowledge/api/route-policies.test.ts | 60 ++++++++++++++++++- apps/sim/lib/knowledge/api/route-policies.ts | 30 ++++++++++ 6 files changed, 159 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/embeddings/client.test.ts b/apps/sim/lib/embeddings/client.test.ts index 90ae018f059..1cb21c0b086 100644 --- a/apps/sim/lib/embeddings/client.test.ts +++ b/apps/sim/lib/embeddings/client.test.ts @@ -606,6 +606,46 @@ describe('knowledge embedding transport fallback', () => { expect(isBYOKEmbeddingCredentialRejection(workspaceError)).toBe(true) }) + it('attributes quota exhaustion to the workspace key, including while its pause is open', async () => { + /** + * Only a credential's first request is refused; the provider would answer every later + * one. A second search can therefore fail only if the open pause refused it. + */ + const refusedCredentials = new Set() + fetchMock.mockImplementation(async (_url, init) => { + const credential = String((init as RequestInit).headers?.Authorization) + if (refusedCredentials.has(credential)) return jsonResponse(openAIBody([[1, 2]])) + refusedCredentials.add(credential) + return jsonResponse( + { error: { type: 'insufficient_quota', code: 'insufficient_quota' } }, + 429 + ) + }) + const search = () => + embedKnowledgeForDeployment( + ['hello'], + { ...options, taskType: 'query' as const, workspaceId: 'workspace-1' }, + true + ).catch((error) => error) + + mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-openai-test', isBYOK: true }) + const refused = await search() + const paused = await search() + expect(refused).toBeInstanceOf(EmbeddingQuotaExhaustedError) + expect(paused).toBeInstanceOf(EmbeddingQuotaExhaustedError) + expect(refused.isBYOK).toBe(true) + expect(paused.isBYOK).toBe(true) + + mockGetBYOKKey.mockResolvedValue(null) + setEnv({ OPENAI_API_KEY: 'platform-openai-test' }) + const platformRefused = await search() + const platformPaused = await search() + expect(platformRefused).toBeInstanceOf(EmbeddingQuotaExhaustedError) + expect(platformPaused).toBeInstanceOf(EmbeddingQuotaExhaustedError) + expect(platformRefused.isBYOK).toBe(false) + expect(platformPaused.isBYOK).toBe(false) + }) + it('ignores OpenRouter on hosted deployments', async () => { setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' }) fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]]))) diff --git a/apps/sim/lib/embeddings/client.ts b/apps/sim/lib/embeddings/client.ts index 6f7fd9e0028..b7fdfe9a9bd 100644 --- a/apps/sim/lib/embeddings/client.ts +++ b/apps/sim/lib/embeddings/client.ts @@ -212,12 +212,22 @@ export const BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE = export class EmbeddingQuotaExhaustedError extends EmbeddingAPIError { public readonly providerId: EmbeddingProviderKind - constructor(providerId: EmbeddingProviderKind, cause?: unknown) { + /** + * `isBYOK` must be passed when there is no provider response to read it from — an + * already-open quota pause or an admission refusal — so a workspace key's exhaustion + * is never reported as the platform's. Ollama takes no credential: its provider-level + * `isBYOK` only marks its tokens non-billable, so it never attributes to a customer key. + */ + constructor( + providerId: EmbeddingProviderKind, + cause?: unknown, + isBYOK = cause instanceof EmbeddingAPIError && cause.isBYOK + ) { const status = cause instanceof EmbeddingAPIError ? cause.status : 429 super( `The ${providerId} embedding credential has exhausted its available quota. Add credit or replace the credential before retrying.`, status, - cause instanceof EmbeddingAPIError && cause.isBYOK + isBYOK && providerId !== 'ollama' ) this.name = 'EmbeddingQuotaExhaustedError' this.providerId = providerId @@ -240,6 +250,18 @@ export function isEmbeddingQuotaExhaustion(error: unknown): boolean { return false } +/** + * True when the operation failed on quota and a customer-managed credential is among + * the exhausted ones: adding credit to that key is what lets it run again, even when a + * platform fallback behind it is exhausted too. + */ +export function isBYOKEmbeddingQuotaExhaustion(error: unknown): boolean { + if (error instanceof AggregateError) { + return isEmbeddingQuotaExhaustion(error) && error.errors.some(isBYOKEmbeddingQuotaExhaustion) + } + return error instanceof EmbeddingAPIError && error.isBYOK && error.quotaExhausted === true +} + /** * True when a customer-managed embedding credential was rejected outright. * These failures require a key or permission change; retrying the same request @@ -524,7 +546,7 @@ async function callEmbeddingAPI( return retryWithExponentialBackoff( async (operationSignal, deadlineAt) => { if (await isEmbeddingQuotaCircuitOpen(admissionIdentity)) { - throw new EmbeddingQuotaExhaustedError(providerId) + throw new EmbeddingQuotaExhaustedError(providerId, undefined, isBYOK) } try { @@ -541,7 +563,7 @@ async function callEmbeddingAPI( }) } catch (error) { if (error instanceof ProviderQuotaExhaustedError) - throw new EmbeddingQuotaExhaustedError(providerId, error) + throw new EmbeddingQuotaExhaustedError(providerId, error, isBYOK) throw error } @@ -1243,7 +1265,7 @@ export async function assertKnowledgeEmbeddingCapacityForDeployment( const exhausted = await isEmbeddingQuotaCircuitOpen(embeddingAdmissionIdentity(provider)) options.signal?.throwIfAborted() if (!exhausted) return - errors.push(new EmbeddingQuotaExhaustedError(provider.providerId)) + errors.push(new EmbeddingQuotaExhaustedError(provider.providerId, undefined, provider.isBYOK)) } if (errors.length === 1) throw errors[0] throw new AggregateError( diff --git a/apps/sim/lib/embeddings/index.ts b/apps/sim/lib/embeddings/index.ts index 392aa4de248..1312bc033f6 100644 --- a/apps/sim/lib/embeddings/index.ts +++ b/apps/sim/lib/embeddings/index.ts @@ -20,6 +20,7 @@ export { embedOpenRouter, getEmbeddingAggregateItemLimit, isBYOKEmbeddingCredentialRejection, + isBYOKEmbeddingQuotaExhaustion, isEmbeddingQuotaExhaustion, } from '@/lib/embeddings/client' export { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models' diff --git a/apps/sim/lib/internal/knowledge/execute-tool.ts b/apps/sim/lib/internal/knowledge/execute-tool.ts index 03b177ab4c9..535a0e2beb1 100644 --- a/apps/sim/lib/internal/knowledge/execute-tool.ts +++ b/apps/sim/lib/internal/knowledge/execute-tool.ts @@ -93,8 +93,9 @@ function projectError( ): Response { signal?.throwIfAborted() const projected = policy.project(error) - if (projected) return descriptorResponse(projected) + if (projected && projected.status < 500) return descriptorResponse(projected) logger.error(`[${requestId}] Knowledge tool execution failed`, { error }) + if (projected) return descriptorResponse(projected) return descriptorResponse( policy.unhandled?.() ?? { status: 500, body: { error: 'Internal server error' } } ) diff --git a/apps/sim/lib/knowledge/api/route-policies.test.ts b/apps/sim/lib/knowledge/api/route-policies.test.ts index 333c339df77..23bd9dfefba 100644 --- a/apps/sim/lib/knowledge/api/route-policies.test.ts +++ b/apps/sim/lib/knowledge/api/route-policies.test.ts @@ -7,7 +7,65 @@ import { WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' +import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' +import { + internalKnowledgeErrorPolicies, + v2KnowledgeErrorPolicies, +} from '@/lib/knowledge/api/route-policies' +import { SearchDeadlineError } from '@/lib/knowledge/search/budget' + +function quotaError(isBYOK: boolean, providerId: 'openai' | 'ollama' = 'openai') { + return new EmbeddingQuotaExhaustedError(providerId, undefined, isBYOK) +} + +describe('internal knowledge search error policy', () => { + it.each([ + ['a workspace key out of quota', quotaError(true), 503, /this workspace's embedding API key/], + ['the platform key out of quota', quotaError(false), 503, /^Knowledge search is temporarily/], + [ + 'every fallback provider out of quota', + new AggregateError([quotaError(false), quotaError(false)]), + 503, + /^Knowledge search is temporarily/, + ], + [ + 'a workspace key out of quota ahead of an exhausted platform fallback', + new AggregateError([quotaError(true), quotaError(false)]), + 503, + /this workspace's embedding API key/, + ], + [ + 'a keyless Ollama server out of quota', + quotaError(true, 'ollama'), + 503, + /^Knowledge search is temporarily/, + ], + [ + 'a rejected workspace key', + new EmbeddingAPIError('Embedding API failed: 401', 401, true), + 502, + /was rejected/, + ], + ['the retrieval deadline', new SearchDeadlineError(), 504, /retrieval deadline/], + ])('names %s', (_case, error, status, message) => { + const response = internalKnowledgeErrorPolicies.search.project(error) + expect(response?.status).toBe(status) + expect((response?.body as { error: string }).error).toMatch(message) + }) + + it('leaves a platform key rejection and unknown failures to the generic server error', () => { + const policy = internalKnowledgeErrorPolicies.search + expect( + policy.project(new EmbeddingAPIError('Embedding API failed: 401', 401, false)) + ).toBeNull() + expect(policy.project(new Error('connection reset'))).toBeNull() + expect(policy.unhandled?.()).toMatchObject({ + status: 500, + body: { error: 'Failed to perform vector search' }, + }) + }) +}) describe('v2 knowledge error policies', () => { it.each([ diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index 30f841a41a5..27f3b4c36f8 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -11,11 +11,17 @@ import { } from '@/lib/api/server/routes' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + isBYOKEmbeddingCredentialRejection, + isBYOKEmbeddingQuotaExhaustion, + isEmbeddingQuotaExhaustion, +} from '@/lib/embeddings' import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' import { KnowledgeSearchProvenanceUnavailableError } from '@/lib/knowledge/application/search' import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' +import { SearchDeadlineError } from '@/lib/knowledge/search/budget' import { SearchIndexDormantError } from '@/lib/sim-search/indexed/gate' import { v2Error } from '@/app/api/v2/lib/response' @@ -40,6 +46,18 @@ const internalKnowledgeUploadErrorPolicy: InternalErrorPolicy = { internalErrorResponse(500, { error: 'Failed to process knowledge upload request' }), } +const BYOK_EMBEDDING_QUOTA_SEARCH_MESSAGE = + "Knowledge search could not run: this workspace's embedding API key (Settings > Provider API keys) has no remaining quota. Add credit with the provider or replace the key." +const BYOK_EMBEDDING_REJECTED_SEARCH_MESSAGE = + "Knowledge search could not run: this workspace's embedding API key (Settings > Provider API keys) was rejected. Update the key and try again." +const PLATFORM_EMBEDDING_QUOTA_SEARCH_MESSAGE = + 'Knowledge search is temporarily unavailable because the embedding provider has no remaining quota. Try again later.' + +/** + * Names the failures a caller can act on instead of collapsing them into the generic + * vector-search `500`. Every status stays `5xx`, so retry and alerting behavior keyed + * on server errors is unchanged; only the message and the specific code differ. + */ const internalKnowledgeSearchErrorPolicy: InternalErrorPolicy = { project(error) { if (error instanceof KnowledgeUsageLimitExceededError) { @@ -48,6 +66,18 @@ const internalKnowledgeSearchErrorPolicy: InternalErrorPolicy = { if (error instanceof KnowledgeSearchProvenanceUnavailableError) { return internalErrorResponse(422, { error: error.message }) } + if (isBYOKEmbeddingQuotaExhaustion(error)) { + return internalErrorResponse(503, { error: BYOK_EMBEDDING_QUOTA_SEARCH_MESSAGE }) + } + if (isEmbeddingQuotaExhaustion(error)) { + return internalErrorResponse(503, { error: PLATFORM_EMBEDDING_QUOTA_SEARCH_MESSAGE }) + } + if (isBYOKEmbeddingCredentialRejection(error)) { + return internalErrorResponse(502, { error: BYOK_EMBEDDING_REJECTED_SEARCH_MESSAGE }) + } + if (error instanceof SearchDeadlineError) { + return internalErrorResponse(504, { error: error.message }) + } return internalOrchestrationErrorPolicy.project(error) }, unhandled: () => internalErrorResponse(500, { error: 'Failed to perform vector search' }), From c47ed6c191dce40e2e8ce0f29171d46e4c349b3c Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 23:02:15 -0700 Subject: [PATCH 12/22] fix(logs): keep compacted child span trees shaped as trees (#8403) * fix(logs): keep compacted child span trees shaped as trees Block output compaction spilled oversized child span lists to large-value references, so a span's children could stop being an array and trace span building threw while finalizing the run. That left pauses unpersisted and runs unfinalized. - Compact child span trees structurally, spilling only each span's payload fields - Drop non-list child spans with a warning when building trace spans - Finalize without spans if building them fails, so logs, pauses, and billing settle * fix(logs): keep an output carrying child spans a record when compacting it * fix(logs): split child spans off block output so oversized state still spills * fix(logs): keep a child span tree whole or drop it, bounded as before A structurally compacted tree had no whole-tree bound, so a large one stayed inline in block logs and pause snapshots. A tree still over the threshold after its payloads spill is now dropped (or rejected), as generic compaction bounded it. Block log outputs compact generically again; new logs never carry child spans there. * fix(logs): keep the skeleton of a child span tree too large to keep whole A tree over the threshold as a whole now keeps its shape, names, timing, status, and cost instead of disappearing, using the same content stripping the execution log applies to oversized traces (moved to a shared module). Only a tree whose skeleton is still over the threshold is dropped. * fix(logs): keep span trees well formed and keep nested child workflows in the skeleton The structural walk now keeps only span objects and drops a child list that is not an array, so building a skeleton can never throw on a malformed entry. The skeleton keeps a nested child workflow's output.childTraceSpans. * fix(logs): skip span fields not in their expected shape when summarizing Per-field compaction can spill an oversized modelToolCalls, toolCalls, or providerTiming to a large-value reference; the skeleton now drops such a field instead of reading it. --- apps/sim/executor/execution/block-executor.ts | 12 +- .../lib/execution/payloads/serializer.test.ts | 249 +++++++++++++++++- apps/sim/lib/execution/payloads/serializer.ts | 153 ++++++++++- apps/sim/lib/logs/execution/logger.ts | 64 +---- .../execution/trace-spans/span-factory.ts | 34 ++- .../logs/execution/trace-spans/summarize.ts | 79 ++++++ .../execution/trace-spans/trace-spans.test.ts | 62 +++++ .../workflows/executor/execution-core.test.ts | 54 ++++ .../lib/workflows/executor/execution-core.ts | 33 ++- 9 files changed, 658 insertions(+), 82 deletions(-) create mode 100644 apps/sim/lib/logs/execution/trace-spans/summarize.ts diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 34ddc144e50..e893f5dde92 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -7,7 +7,7 @@ import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types' import { redactApiKeys } from '@/lib/core/security/redaction' import { normalizeStringArray } from '@/lib/core/utils/arrays' import { getBaseUrl } from '@/lib/core/utils/urls' -import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { compactBlockOutput } from '@/lib/execution/payloads/serializer' import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values' import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction' import { @@ -379,14 +379,15 @@ export class BlockExecutor { normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions) } - normalizedOutput = (await compactExecutionPayload(normalizedOutput, { + const compacted = await compactBlockOutput(normalizedOutput, { workspaceId: blockCtx.workspaceId, workflowId: blockCtx.workflowId, executionId: blockCtx.executionId, userId: blockCtx.userId, preserveUserFileBase64: blockCtx.includeFileBase64 === true, requireDurable: true, - })) as NormalizedBlockOutput + }) + normalizedOutput = compacted.output const endedAt = new Date().toISOString() const duration = performance.now() - startTime @@ -396,8 +397,8 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = true blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block }) - if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) { - blockLog.childTraceSpans = normalizedOutput.childTraceSpans + if (compacted.childTraceSpans) { + blockLog.childTraceSpans = compacted.childTraceSpans } const childExecutionId = normalizedOutput[CHILD_EXECUTION_ID_OUTPUT_KEY] if (typeof childExecutionId === 'string' && childExecutionId) { @@ -409,7 +410,6 @@ export class BlockExecutor { } const { - childTraceSpans: _traces, [CHILD_EXECUTION_ID_OUTPUT_KEY]: _childExecutionId, [CHILD_TRACE_DISABLED_OUTPUT_KEY]: _childTraceDisabled, ...outputForState diff --git a/apps/sim/lib/execution/payloads/serializer.test.ts b/apps/sim/lib/execution/payloads/serializer.test.ts index 938a53c902e..c22a4bda001 100644 --- a/apps/sim/lib/execution/payloads/serializer.test.ts +++ b/apps/sim/lib/execution/payloads/serializer.test.ts @@ -2,7 +2,7 @@ import { largeValueMetadataMock, largeValueMetadataMockFns, } from '@sim/testing/mocks/large-value-metadata.mock' -import { storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' +import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { uploadsMock } from '@sim/testing/mocks/uploads.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' @@ -15,12 +15,19 @@ import { getLargeValueMaterializationError, isLargeValueRef, } from '@/lib/execution/payloads/large-value-ref' -import { compactExecutionPayload, compactSubflowResults } from '@/lib/execution/payloads/serializer' -import type { UserFile } from '@/executor/types' +import { + compactBlockLogs, + compactBlockOutput, + compactExecutionPayload, + compactSubflowResults, +} from '@/lib/execution/payloads/serializer' +import type { TraceSpan } from '@/lib/logs/types' +import type { BlockLog, UserFile } from '@/executor/types' const { mockDownloadFile, mockUploadFile } = storageServiceMockFns vi.mock('@/lib/uploads', () => uploadsMock) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/execution/payloads/large-value-metadata', () => largeValueMetadataMock) @@ -295,3 +302,239 @@ describe('compactExecutionPayload', () => { expect(error.message).not.toContain('lv_CQcekP8gSJI5') }) }) + +/** + * A child workflow's spans as the workflow block reports them: a loop whose one + * iteration holds two block spans, each with a `resultBytes` payload. + */ +function childWorkflowSpans(resultBytes: number): TraceSpan[] { + const blockSpan = (id: string): TraceSpan => ({ + id, + name: id, + type: 'function', + duration: 1, + startTime: '2026-09-29T00:00:00.000Z', + endTime: '2026-09-29T00:00:00.001Z', + output: { result: 'x'.repeat(resultBytes) }, + }) + return [ + { + id: 'loop', + name: 'Loop', + type: 'loop', + duration: 2, + startTime: '2026-09-29T00:00:00.000Z', + endTime: '2026-09-29T00:00:00.002Z', + children: [ + { + id: 'iteration-0', + name: 'Iteration 0', + type: 'loop-iteration', + duration: 2, + startTime: '2026-09-29T00:00:00.000Z', + endTime: '2026-09-29T00:00:00.002Z', + children: [blockSpan('span-a'), blockSpan('span-b')], + }, + ], + }, + ] +} + +/** Spans whose payloads each exceed the 4 KiB test threshold, so each spills on its own. */ +const spansWithLargePayloads = () => childWorkflowSpans(8192) + +/** + * Spans whose payloads each stay under the 4 KiB test threshold but whose + * iteration `children` together exceed it — the shape generic compaction + * turned into a manifest nested inside the tree. + */ +const spansTooLargeAsAWhole = () => childWorkflowSpans(2500) + +/** Asserts the loop → iteration → block span nesting survived with every `children` an array. */ +function expectSpanTree(spans: unknown): void { + expect(Array.isArray(spans)).toBe(true) + const [loop] = spans as TraceSpan[] + expect(Array.isArray(loop.children)).toBe(true) + const [iteration] = loop.children ?? [] + expect(Array.isArray(iteration.children)).toBe(true) + expect(iteration.children?.map((span) => span.id)).toEqual(['span-a', 'span-b']) +} + +describe('compacting span trees', () => { + const options = { thresholdBytes: 4096, requireDurable: true, ...TEST_EXECUTION_CONTEXT } + + beforeEach(() => { + clearLargeValueCacheForTests() + mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) + mockRegisterLargeValueOwner.mockResolvedValue(true) + }) + + const childWorkflowLog = (overrides: Partial): BlockLog => ({ + blockId: 'child-workflow', + blockType: 'workflow', + startedAt: '2026-09-29T00:00:00.000Z', + endedAt: '2026-09-29T00:00:00.002Z', + durationMs: 2, + success: true, + ...overrides, + }) + + it('splits a block output child span tree off, spilling each oversized payload', async () => { + const compacted = await compactBlockOutput( + { result: 'done', childTraceSpans: spansWithLargePayloads() }, + options + ) + + expect(compacted.output).toEqual({ result: 'done' }) + expectSpanTree(compacted.childTraceSpans) + const [loop] = compacted.childTraceSpans as TraceSpan[] + const spilled = loop.children?.[0].children?.[0] + expect(isLargeValueRef(spilled?.output?.result)).toBe(true) + }) + + it('keeps the skeleton of a block output child span tree too large as a whole', async () => { + const compacted = await compactBlockOutput( + { result: 'done', childTraceSpans: spansTooLargeAsAWhole() }, + options + ) + + expect(compacted.output).toEqual({ result: 'done' }) + expectSpanTree(compacted.childTraceSpans) + const [loop] = compacted.childTraceSpans as TraceSpan[] + expect(loop.children?.[0].children?.[0].output).toBeUndefined() + }) + + it('drops malformed span entries so an oversized tree still keeps its skeleton', async () => { + const spans = spansTooLargeAsAWhole() + const iteration = spans[0].children?.[0] + iteration?.children?.push(null as unknown as TraceSpan) + + const compacted = await compactBlockOutput( + { childTraceSpans: [...spans, undefined as unknown as TraceSpan] }, + options + ) + + expectSpanTree(compacted.childTraceSpans) + expect(compacted.childTraceSpans).toHaveLength(1) + }) + + it('keeps the skeleton when span metadata itself was spilled', async () => { + const spans = spansTooLargeAsAWhole() + const blockSpan = spans[0].children?.[0].children?.[0] + Object.assign(blockSpan ?? {}, { + modelToolCalls: Array.from({ length: 8 }, (_, index) => ({ + name: `tool-${index}`, + arguments: { query: 'q'.repeat(1024) }, + })), + toolCalls: Array.from({ length: 8 }, (_, index) => ({ + name: `tool-${index}`, + input: 'i'.repeat(1024), + })), + providerTiming: { segments: [{ assistantContent: 'a'.repeat(8192) }] }, + }) + + const compacted = await compactBlockOutput({ childTraceSpans: spans }, options) + + expectSpanTree(compacted.childTraceSpans) + }) + + it('keeps nested child workflow trees in the skeleton', async () => { + const nestedWorkflowSpan: TraceSpan = { + id: 'nested-workflow', + name: 'Nested Workflow', + type: 'workflow', + duration: 2, + startTime: '2026-09-29T00:00:00.000Z', + endTime: '2026-09-29T00:00:00.002Z', + output: { result: 'done', childTraceSpans: spansTooLargeAsAWhole() }, + } + + const compacted = await compactBlockOutput({ childTraceSpans: [nestedWorkflowSpan] }, options) + + const [nested] = compacted.childTraceSpans as TraceSpan[] + expect(nested.output?.result).toBeUndefined() + expectSpanTree(nested.output?.childTraceSpans) + }) + + it('drops a child span tree whose skeleton alone exceeds the threshold', async () => { + const spans = Array.from({ length: 64 }, (_, index) => ({ + ...spansTooLargeAsAWhole()[0], + id: `loop-${index}`, + })) + + const compacted = await compactBlockOutput({ childTraceSpans: spans }, options) + + expect(compacted.childTraceSpans).toBeUndefined() + }) + + it('rejects a child span tree too large as a whole when large values are rejected', async () => { + await expect( + compactBlockOutput( + { childTraceSpans: spansTooLargeAsAWhole() }, + { ...options, rejectLargeValues: true } + ) + ).rejects.toThrow() + }) + + it('still spills a block output whose fields together exceed the threshold', async () => { + const compacted = await compactBlockOutput( + { + first: 'a'.repeat(2500), + second: 'b'.repeat(2500), + childTraceSpans: spansWithLargePayloads(), + }, + options + ) + + expect(isLargeValueRef(compacted.output)).toBe(true) + expectSpanTree(compacted.childTraceSpans) + }) + + it('keeps block log child span trees whole or as a skeleton', async () => { + const compacted = + (await compactBlockLogs( + [ + childWorkflowLog({ childTraceSpans: spansWithLargePayloads() }), + childWorkflowLog({ childTraceSpans: spansTooLargeAsAWhole() }), + ], + options + )) ?? [] + + expectSpanTree(compacted[0]?.childTraceSpans) + expectSpanTree(compacted[1]?.childTraceSpans) + const [loop] = compacted[1]?.childTraceSpans ?? [] + expect(loop.children?.[0].children?.[0].output).toBeUndefined() + }) + + it('keeps a nested child workflow span tree shaped as a tree', async () => { + const nestedWorkflowSpan: TraceSpan = { + id: 'nested-workflow', + name: 'Nested Workflow', + type: 'workflow', + duration: 2, + startTime: '2026-09-29T00:00:00.000Z', + endTime: '2026-09-29T00:00:00.002Z', + output: { result: 'done', childTraceSpans: spansWithLargePayloads() }, + } + + const compacted = await compactBlockOutput({ childTraceSpans: [nestedWorkflowSpan] }, options) + + const [nested] = compacted.childTraceSpans as TraceSpan[] + expect(nested.output?.result).toBe('done') + expectSpanTree(nested.output?.childTraceSpans) + }) + + it('terminates on a cyclic span tree', async () => { + const span: TraceSpan = { + id: 'cyclic', + name: 'Cyclic', + type: 'function', + duration: 1, + startTime: '2026-09-29T00:00:00.000Z', + endTime: '2026-09-29T00:00:00.001Z', + } + span.children = [span] + + await expect(compactBlockOutput({ childTraceSpans: [span] }, options)).resolves.toBeDefined() + }) +}) diff --git a/apps/sim/lib/execution/payloads/serializer.ts b/apps/sim/lib/execution/payloads/serializer.ts index e8239f9b141..7d7680a3429 100644 --- a/apps/sim/lib/execution/payloads/serializer.ts +++ b/apps/sim/lib/execution/payloads/serializer.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { @@ -9,8 +11,12 @@ import { LARGE_VALUE_THRESHOLD_BYTES, } from '@/lib/execution/payloads/large-value-ref' import { type LargeValueStoreContext, storeLargeValue } from '@/lib/execution/payloads/store' +import { summarizeTraceSpansWithoutIo } from '@/lib/logs/execution/trace-spans/summarize' +import type { TraceSpan } from '@/lib/logs/types' import type { BlockLog } from '@/executor/types' +const logger = createLogger('ExecutionPayloadSerializer') + export interface CompactExecutionPayloadOptions extends LargeValueStoreContext { thresholdBytes?: number preserveUserFileBase64?: boolean @@ -254,6 +260,151 @@ export async function compactSubflowResults( return compactedResults } +/** Maps each entry of a record concurrently, keeping its keys. */ +async function mapEntriesAsync( + record: Record, + mapValue: (key: string, value: unknown) => Promise +): Promise> { + return Object.fromEntries( + await Promise.all( + Object.entries(record).map(async ([key, value]) => [key, await mapValue(key, value)]) + ) + ) +} + +/** + * Compacts a trace span tree without collapsing its structure: `children` and + * `output.childTraceSpans` stay arrays of spans and only each span's payload + * fields spill when oversized. A malformed list or entry is dropped, so every + * reader can walk the tree. See {@link compactChildTraceSpans} for the size bound. + */ +async function compactTraceSpanTree( + spans: unknown, + options: CompactExecutionPayloadOptions, + seen: WeakSet +): Promise { + if (!Array.isArray(spans)) { + return undefined + } + return Promise.all( + spans.filter(isRecordLike).map((span) => compactTraceSpan(span, options, seen)) + ) +} + +async function compactTraceSpan( + span: Record, + options: CompactExecutionPayloadOptions, + seen: WeakSet +): Promise> { + if (seen.has(span)) { + return span + } + seen.add(span) + return mapEntriesAsync(span, (key, value) => { + if (key === 'children') return compactTraceSpanTree(value, options, seen) + if (key === 'output') return compactSpanOutput(value, options, seen) + return compactExecutionPayload(value, options) + }) +} + +/** + * Compacts a span's output. One carrying a nested child workflow's + * `childTraceSpans` keeps its root so the spans stay attached; its other + * fields spill individually. + */ +async function compactSpanOutput( + output: unknown, + options: CompactExecutionPayloadOptions, + seen: WeakSet +): Promise { + if (!isRecordLike(output) || !('childTraceSpans' in output)) { + return compactExecutionPayload(output, options) + } + return mapEntriesAsync(output, (key, value) => + key === 'childTraceSpans' + ? compactTraceSpanTree(value, options, seen) + : compactExecutionPayload(value, options) + ) +} + +/** + * Compacts a block's child span tree for its log. Readers walk span trees as + * arrays, so a tree is never collapsed: payload fields spill individually (see + * {@link compactTraceSpanTree}). A tree still over the threshold as a whole + * keeps only its skeleton (shape, names, timing, status, cost), and one whose + * skeleton is still over it is dropped, bounding it as generic compaction did. + */ +async function compactChildTraceSpans( + spans: unknown, + options: CompactExecutionPayloadOptions +): Promise { + if (!Array.isArray(spans)) { + if (spans !== undefined) { + logger.warn('Dropping child trace spans that are not a list', { shape: typeof spans }) + } + return undefined + } + const compacted = (await compactTraceSpanTree( + spans, + options, + new WeakSet() + )) as TraceSpan[] + const maxBytes = options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES + const measured = getJsonAndSize(compacted) + if (!measured) { + logger.warn('Dropping child trace spans that cannot be serialized') + return undefined + } + if (measured.size <= maxBytes) { + return compacted + } + if (options.rejectLargeValues) { + throw largeValueLimitError(options, measured.size) + } + const skeleton = summarizeTraceSpansWithoutIo(compacted) + const skeletonSize = getJsonAndSize(skeleton)?.size + if (skeletonSize !== undefined && skeletonSize <= maxBytes) { + logger.warn('Kept only the skeleton of child trace spans too large to keep whole', { + observedBytes: measured.size, + maxBytes, + }) + return skeleton + } + logger.warn('Dropping child trace spans too large to keep', { + observedBytes: skeletonSize ?? measured.size, + maxBytes, + }) + return undefined +} + +export interface CompactedBlockOutput { + /** The output without `childTraceSpans`, compacted as execution state. */ + output: T + /** The output's child span tree for the block log (see {@link compactChildTraceSpans}). */ + childTraceSpans?: TraceSpan[] +} + +/** + * Compacts a block output for execution state and splits off its + * `childTraceSpans`, which belong to the block log rather than state. The + * output compacts as any execution payload, so an oversized one still spills + * whole. + */ +export async function compactBlockOutput( + output: T, + options: CompactExecutionPayloadOptions = {} +): Promise> { + if (!isRecordLike(output) || !('childTraceSpans' in output)) { + return { output: await compactExecutionPayload(output, options) } + } + const { childTraceSpans, ...rest } = output + const [compactedOutput, compactedSpans] = await Promise.all([ + compactExecutionPayload(rest, options), + compactChildTraceSpans(childTraceSpans, options), + ]) + return { output: compactedOutput as T, childTraceSpans: compactedSpans } +} + export async function compactBlockLogs( logs: BlockLog[] | undefined, options: CompactExecutionPayloadOptions = {} @@ -279,7 +430,7 @@ export async function compactBlockLogs( compactedLog.output = await compactExecutionPayload(compactedLog.output, options) } if ('childTraceSpans' in compactedLog) { - compactedLog.childTraceSpans = await compactExecutionPayload( + compactedLog.childTraceSpans = await compactChildTraceSpans( compactedLog.childTraceSpans, options ) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 533e9629604..b6ca886ba74 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -50,6 +50,11 @@ import { } from '@/lib/logs/execution/progress-markers' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { traceSpansHaveHandledErrors } from '@/lib/logs/execution/trace-spans/handled-errors' +import { + stripLegacyToolCallContent, + stripModelToolCallArguments, + summarizeTraceSpansWithoutIo, +} from '@/lib/logs/execution/trace-spans/summarize' import { traceSpansIndicateFailure } from '@/lib/logs/execution/trace-spans/trace-spans' import { copyTraceSpansWithoutCosts, @@ -195,12 +200,6 @@ function retainBoundedTraceContent(value: T, maxBytes = MAX_TRACE_IO_BYTES): return size !== undefined && size <= maxBytes ? value : undefined } -function stripModelToolCallArguments( - calls: NonNullable -): NonNullable { - return calls.map(({ arguments: _arguments, ...call }) => call as (typeof calls)[number]) -} - function compactModelToolCalls( calls: NonNullable ): NonNullable | undefined { @@ -226,12 +225,6 @@ function compactLegacyToolCalls( return retainBoundedTraceContent(compacted) } -function stripLegacyToolCallContent( - calls: NonNullable -): NonNullable { - return calls.map(({ input: _input, output: _output, error: _error, ...call }) => call) -} - function compactProviderTiming( providerTiming: NonNullable ): NonNullable { @@ -253,26 +246,6 @@ function compactProviderTiming( } } -function stripProviderTimingContent( - providerTiming: NonNullable -): NonNullable { - return { - ...providerTiming, - segments: providerTiming.segments.map( - ({ - assistantContent: _assistantContent, - thinkingContent: _thinkingContent, - errorMessage: _errorMessage, - toolCalls, - ...segment - }) => ({ - ...segment, - ...(toolCalls ? { toolCalls: stripModelToolCallArguments(toolCalls) } : {}), - }) - ), - } -} - function summarizeTraceSpansForExecutionData(traceSpans?: TraceSpan[]): TraceSpan[] | undefined { if (!traceSpans) { return traceSpans @@ -317,33 +290,6 @@ function summarizeTraceSpansForExecutionData(traceSpans?: TraceSpan[]): TraceSpa }) } -function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpan[] | undefined { - if (!traceSpans) { - return traceSpans - } - - return traceSpans.map((span) => { - const { - input: _input, - output: _output, - children, - thinking: _thinking, - errorMessage: _errorMessage, - modelToolCalls, - toolCalls, - providerTiming, - ...rest - } = span - return { - ...rest, - ...(modelToolCalls ? { modelToolCalls: stripModelToolCallArguments(modelToolCalls) } : {}), - ...(toolCalls ? { toolCalls: stripLegacyToolCallContent(toolCalls) } : {}), - ...(providerTiming ? { providerTiming: stripProviderTimingContent(providerTiming) } : {}), - ...(children?.length ? { children: summarizeTraceSpansWithoutIo(children) } : {}), - } - }) -} - function summarizeExecutionState(executionState?: SerializableExecutionState) { if (!executionState) { return undefined diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index 7abe0bcbc71..af9b981aed5 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -392,8 +392,11 @@ function resolveToolCallsList(output: NormalizedBlockOutput | undefined): BlockT /** Extracts and flattens child workflow trace spans into the parent span's children. */ function attachChildWorkflowSpans(span: TraceSpan, log: ValidBlockLog): void { - const childTraceSpans = log.childTraceSpans ?? log.output?.childTraceSpans - if (!childTraceSpans?.length) return + const childTraceSpans = readChildSpans( + log.childTraceSpans ?? log.output?.childTraceSpans, + 'childTraceSpans' + ) + if (childTraceSpans.length === 0) return span.children = flattenWorkflowChildren(childTraceSpans) span.output = stripChildTraceSpansFromOutput(span.output) @@ -404,10 +407,21 @@ function isSyntheticWorkflowWrapper(span: TraceSpan): boolean { return span.type === 'workflow' && !span.blockId } +/** + * Reads a list of child spans, or `[]` when absent. Anything other than an + * array (for example a span list that was spilled to a large-value reference) + * is dropped with a warning so one malformed subtree cannot fail the trace. + */ +function readChildSpans(value: unknown, source: 'children' | 'childTraceSpans'): TraceSpan[] { + if (value === undefined || value === null) return [] + if (Array.isArray(value)) return value + logger.warn('Dropping child spans that are not a list', { source, shape: typeof value }) + return [] +} + /** Reads nested `childTraceSpans` off a span's output, or `[]` if absent. */ function extractOutputChildren(output: TraceSpan['output']): TraceSpan[] { - const nested = (output as { childTraceSpans?: TraceSpan[] } | undefined)?.childTraceSpans - return Array.isArray(nested) ? nested : [] + return readChildSpans(output?.childTraceSpans, 'childTraceSpans') } /** Returns a copy of `output` with `childTraceSpans` removed, or undefined unchanged. */ @@ -430,23 +444,21 @@ export function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] { for (const span of spans) { if (isSyntheticWorkflowWrapper(span)) { - if (span.children?.length) { - flattened.push(...flattenWorkflowChildren(span.children)) - } + flattened.push(...flattenWorkflowChildren(readChildSpans(span.children, 'children'))) continue } - const directChildren = span.children ?? [] + const directChildren = readChildSpans(span.children, 'children') const outputChildren = extractOutputChildren(span.output) const allChildren = [...directChildren, ...outputChildren] const nextSpan: TraceSpan = { ...span } if (allChildren.length > 0) { nextSpan.children = flattenWorkflowChildren(allChildren) + } else if (!Array.isArray(span.children)) { + nextSpan.children = undefined } - if (outputChildren.length > 0) { - nextSpan.output = stripChildTraceSpansFromOutput(nextSpan.output) - } + nextSpan.output = stripChildTraceSpansFromOutput(nextSpan.output) flattened.push(nextSpan) } diff --git a/apps/sim/lib/logs/execution/trace-spans/summarize.ts b/apps/sim/lib/logs/execution/trace-spans/summarize.ts new file mode 100644 index 00000000000..77ddc4e9e65 --- /dev/null +++ b/apps/sim/lib/logs/execution/trace-spans/summarize.ts @@ -0,0 +1,79 @@ +import { isRecordLike } from '@sim/utils/object' +import type { TraceSpan } from '@/lib/logs/types' + +export function stripModelToolCallArguments( + calls: NonNullable +): NonNullable { + return calls.map(({ arguments: _arguments, ...call }) => call as (typeof calls)[number]) +} + +export function stripLegacyToolCallContent( + calls: NonNullable +): NonNullable { + return calls.map(({ input: _input, output: _output, error: _error, ...call }) => call) +} + +export function stripProviderTimingContent( + providerTiming: NonNullable +): NonNullable { + return { + ...providerTiming, + segments: providerTiming.segments.map( + ({ + assistantContent: _assistantContent, + thinkingContent: _thinkingContent, + errorMessage: _errorMessage, + toolCalls, + ...segment + }) => ({ + ...segment, + ...(Array.isArray(toolCalls) ? { toolCalls: stripModelToolCallArguments(toolCalls) } : {}), + }) + ), + } +} + +/** + * A trace span tree with every span's content removed: inputs, outputs, + * thinking, error text, tool-call arguments, and provider content. Keeps the + * tree's shape, names, timing, status, and cost, including a nested child + * workflow's spans carried on `output.childTraceSpans`. A field that is not in + * its expected shape (for example one spilled to a large-value reference) is + * dropped rather than read. + */ +export function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpan[] | undefined { + if (!traceSpans) { + return traceSpans + } + + return traceSpans.map((span) => { + const { + input: _input, + output, + children, + thinking: _thinking, + errorMessage: _errorMessage, + modelToolCalls, + toolCalls, + providerTiming, + ...rest + } = span + const nestedSpans = isRecordLike(output) ? output.childTraceSpans : undefined + return { + ...rest, + ...(Array.isArray(nestedSpans) && nestedSpans.length + ? { output: { childTraceSpans: summarizeTraceSpansWithoutIo(nestedSpans) } } + : {}), + ...(Array.isArray(modelToolCalls) + ? { modelToolCalls: stripModelToolCallArguments(modelToolCalls) } + : {}), + ...(Array.isArray(toolCalls) ? { toolCalls: stripLegacyToolCallContent(toolCalls) } : {}), + ...(Array.isArray(providerTiming?.segments) + ? { providerTiming: stripProviderTimingContent(providerTiming) } + : {}), + ...(Array.isArray(children) && children.length + ? { children: summarizeTraceSpansWithoutIo(children) } + : {}), + } + }) +} diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 495e8ddb28d..4c4a8211d40 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -1419,3 +1419,65 @@ describe('custom block invoked as an Agent tool', () => { expect(toolSpan?.output).not.toHaveProperty('_childTraceDisabled') }) }) + +describe('child span trees that were compacted into references', () => { + const spilledChildren = { + __simLargeValueRef: true, + version: 1, + id: 'lv_ABCDEFGHIJKLMNOPQRSTUV', + kind: 'array', + size: 9_000_000, + } as unknown as TraceSpan[] + + function childWorkflowResult(childTraceSpans: TraceSpan[]): ExecutionResult { + return { + success: true, + output: {}, + logs: [ + { + blockId: 'workflow-1', + blockName: 'Child Workflow', + blockType: 'workflow', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:05.000Z', + durationMs: 5000, + success: true, + output: { success: true }, + childTraceSpans, + }, + ], + } + } + + const loopSpan = (overrides: Partial): TraceSpan => ({ + id: 'loop-1', + name: 'Loop', + type: 'loop', + blockId: 'loop-1', + duration: 1000, + startTime: '2024-01-01T10:00:01.000Z', + endTime: '2024-01-01T10:00:02.000Z', + status: 'success', + ...overrides, + }) + + it.concurrent('keeps the span when its children are a reference', () => { + const { traceSpans } = buildTraceSpans( + childWorkflowResult([loopSpan({ children: spilledChildren })]) + ) + + expect(traceSpans[0].children?.map((span) => span.id)).toEqual(['loop-1']) + expect(traceSpans[0].children?.[0].children).toBeUndefined() + }) + + it.concurrent('keeps the span when its output child spans are a reference', () => { + const { traceSpans } = buildTraceSpans( + childWorkflowResult([ + loopSpan({ output: { result: 'done', childTraceSpans: spilledChildren } }), + ]) + ) + + expect(traceSpans[0].children?.map((span) => span.id)).toEqual(['loop-1']) + expect(traceSpans[0].children?.[0].output).toEqual({ result: 'done' }) + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 613dcab195f..37ce5f47360 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1570,6 +1570,60 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { expect(clearExecutionCancellationMock).not.toHaveBeenCalled() }) + it('still persists a pause when its trace spans cannot be built', async () => { + buildTraceSpansMock.mockImplementation(() => { + throw new TypeError('directChildren is not iterable') + }) + executorExecuteMock.mockResolvedValue({ + success: true, + status: 'paused', + output: {}, + logs: [], + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + executionState: { blockStates: {} }, + }) + + await executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + expect(safeCompleteWithPauseMock).toHaveBeenCalledWith( + expect.objectContaining({ totalDurationMs: 123, traceSpans: [] }) + ) + }) + + it('still finalizes a failed execution when its trace spans cannot be built', async () => { + buildTraceSpansMock.mockImplementation(() => { + throw new TypeError('directChildren is not iterable') + }) + const error = Object.assign(new Error('block threw'), { + executionResult: { + success: false, + output: {}, + logs: [], + metadata: { duration: 55, startTime: 'start', endTime: 'end' }, + }, + }) + executorExecuteMock.mockRejectedValue(error) + + await expect( + executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + ).rejects.toBe(error) + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + expect(safeCompleteWithErrorMock).toHaveBeenCalledWith( + expect.objectContaining({ traceSpans: [] }) + ) + expect(wasExecutionFinalizedByCore(error, 'execution-1')).toBe(true) + }) + it('clears cancellation intent when pause finalization observes a persisted cancellation', async () => { executorExecuteMock.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 420be743ab9..fa337c3cef1 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -290,6 +290,28 @@ async function recordSettledRun(workflowId: string, requestId: string): Promise< } } +/** + * Builds a run's trace spans for its log. Spans are diagnostics: a failure to + * build them is logged and the run is finalized without them, so the log, + * pause, billing, and run counts still settle. + */ +function buildTraceSpansForLog( + result: ExecutionResult, + loggingSession: LoggingSession, + requestId: string, + executionId: string +): ReturnType { + try { + return buildTraceSpans(result) + } catch (error) { + logger.error( + `[${requestId}] Failed to build trace spans; finalizing without them`, + loggingSession.projectDiagnosticError(error, { executionId }) + ) + return { traceSpans: [], totalDuration: result.metadata?.duration ?? 0 } + } +} + async function finalizeExecutionOutcome(params: { result: ExecutionResult loggingSession: LoggingSession @@ -301,7 +323,12 @@ async function finalizeExecutionOutcome(params: { }): Promise { const { result, loggingSession, workflowId, executionId, requestId, workflowInput, abortSignal } = params - const { traceSpans, totalDuration } = buildTraceSpans(result) + const { traceSpans, totalDuration } = buildTraceSpansForLog( + result, + loggingSession, + requestId, + executionId + ) const endedAt = new Date().toISOString() try { @@ -372,7 +399,9 @@ async function finalizeExecutionError(params: { }): Promise { const { error, loggingSession, workflowId, executionId, requestId } = params const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } + const { traceSpans } = executionResult + ? buildTraceSpansForLog(executionResult, loggingSession, requestId, executionId) + : { traceSpans: [] } let finalized = false try { From 85b96fba2746f83762b8298055d5be2139f29068 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 28 Sep 2026 23:26:51 -0700 Subject: [PATCH 13/22] fix(forks): route new workflows through one row builder and harden lineage locking (#8406) * fix(forks): route new workflows through one row builder and harden lineage locking * fix(workflows): read the fork-sync policy inside each create transaction --- .../docs/platform/enterprise/forks.mdx | 16 +- .../api/superuser/import-workflow/route.ts | 31 ++- .../api/v1/admin/workflows/import/route.ts | 31 ++- .../v1/admin/workspaces/[id]/import/route.ts | 31 ++- .../[id]/fork/sync-default/route.ts | 6 +- .../application/lineage-details.ts | 4 +- .../application/operations.ts | 8 +- .../application/recovery-and-mappings.ts | 6 +- .../workspace-forking/application/revision.ts | 19 +- .../application/sync-default.test.ts | 87 +++------ .../application/sync-default.ts | 97 ++++------ .../fork-sync-default-toggle.tsx | 31 +-- .../fork-synced-workflows.test.ts | 59 ------ .../fork-synced-workflows.tsx | 37 ++-- .../ee/workspace-forking/components/forks.tsx | 13 +- .../workspace-forking/hooks/workspace-fork.ts | 9 +- .../lib/copy/copy-workflows.test.ts | 54 ------ .../lib/copy/copy-workflows.ts | 19 +- .../workspace-forking/lib/create-fork.test.ts | 13 +- .../ee/workspace-forking/lib/create-fork.ts | 82 +++----- .../lineage/fork-lock-order.integration.ts | 67 ++----- .../lib/lineage/lineage-root.test.ts | 65 +++++++ .../lineage-root.ts} | 90 ++------- .../workspace-forking/lib/lineage/lineage.ts | 37 ++-- .../lib/lineage/unlink.test.ts | 22 +++ .../workspace-forking/lib/lineage/unlink.ts | 21 ++- .../lib/promote/promote-plan.ts | 9 - .../workspace-forking/lib/promote/promote.ts | 4 +- .../lib/sync-default.test.ts | 177 ------------------ .../orchestration/workflow-lifecycle.ts | 43 ++--- .../lib/workflows/persistence/duplicate.ts | 60 +++--- .../workflows/persistence/new-workflow-row.ts | 60 ++++++ apps/sim/lib/workflows/utils.ts | 67 ------- .../__integration__/fork-sync.integration.ts | 92 ++++++++- apps/sim/lib/workspaces/create.test.ts | 9 +- apps/sim/lib/workspaces/create.ts | 26 ++- packages/db/schema.ts | 18 +- packages/testing/src/mocks/index.ts | 4 + .../testing/src/mocks/workflows-utils.mock.ts | 2 - .../workspace-forking-lineage-root.mock.ts | 37 ++++ 40 files changed, 583 insertions(+), 980 deletions(-) delete mode 100644 apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts create mode 100644 apps/sim/ee/workspace-forking/lib/lineage/lineage-root.test.ts rename apps/sim/ee/workspace-forking/lib/{sync-default.ts => lineage/lineage-root.ts} (50%) delete mode 100644 apps/sim/ee/workspace-forking/lib/sync-default.test.ts create mode 100644 apps/sim/lib/workflows/persistence/new-workflow-row.ts create mode 100644 packages/testing/src/mocks/workspace-forking-lineage-root.mock.ts diff --git a/apps/docs/content/docs/platform/enterprise/forks.mdx b/apps/docs/content/docs/platform/enterprise/forks.mdx index 94334615944..c33c941c936 100644 --- a/apps/docs/content/docs/platform/enterprise/forks.mdx +++ b/apps/docs/content/docs/platform/enterprise/forks.mdx @@ -29,7 +29,7 @@ On Sim Cloud, your organization may also need the feature turned on for your acc ### 1. Open Forks -Go to **Settings → Organization → Workspace forks** in the workspace you want to fork from (or manage). +Go to **Settings → Workspace → Workspace forks** in the workspace you want to fork from (or manage). @@ -62,7 +62,7 @@ Click **Fork**. The child workspace is created immediately. Deployed workflows l ### 3. Open the parent edge (from the child) -Open the **child** workspace → **Settings → Organization → Workspace forks**. On the **Parent** row, open the menu and choose **Edit mappings**. +Open the **child** workspace → **Settings → Workspace → Workspace forks**. On the **Parent** row, open the menu and choose **Edit mappings**. Child rows (when you are on the parent) only offer **Open workspace** and **Disconnect** — mapping and sync are owned by the child configuring how it relates to its parent. @@ -136,14 +136,14 @@ Above the list, **Sync new workflows by default** decides where a **newly create | Setting | A new workflow… | |---------|-----------------| -| **On** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it | -| **Off** | starts outside fork sync — it arrives unchecked and only syncs after you check it | +| **Sync** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it | +| **Don't sync** | starts outside fork sync — it arrives unchecked and only syncs after you check it | Three things to know: -- **It applies to the whole fork lineage.** The toggle writes every workspace in the lineage — the root, every ancestor, every descendant — so a parent and its forks can never disagree about what "new" means. Any workspace admin in the lineage can change it, and each member gets its own audit entry naming the workspace the change came from. A new fork inherits the value at creation. +- **It applies to the whole fork lineage.** The toggle writes every workspace in the lineage — the root, every ancestor, every descendant — so a parent and its forks can never disagree about what "new" means. Any workspace admin in the lineage can change it, and each workspace whose value changes gets its own audit entry naming the workspace the change came from. A new fork inherits the value at creation. - **It is forward-only.** Flipping it never moves an existing workflow in or out of sync. The checkbox list above stays the record of what syncs. -- **"New" means genuinely new.** Creating, duplicating, or importing a workflow takes this setting, as does the blank starter workflow a fork gets when there is nothing to copy. A workflow that arrives as a **copy** — from a fork, or from a push or pull — inherits its source's own checkbox instead, so a workflow you deliberately synced never lands unsynced in the child. +- **"New" means genuinely new.** Creating, duplicating, or importing a workflow takes this setting, as does the blank starter workflow a fork gets when there is nothing to copy. A workflow that arrives as a **copy** — from a fork, or from a push or pull — ignores this setting. Only synced workflows are copied, and they always arrive synced, so a workflow you deliberately synced never lands unsynced on the other side. **Example:** a template workspace turns this off so every scratch workflow the team creates stays local, then checks only the handful meant to reach the forks. @@ -399,7 +399,7 @@ Schedules, webhooks, and triggers are not live in the child until you **deploy** { question: "Why is Sync greyed out?", answer: "Usually a blocking reference, an unmapped credential or secret, or a required dependent field (label, channel, document, …) still empty. Open Blocking sync and the mapping sections — each row explains what to fix. Sync also stays disabled while details are loading or if loading failed (reload the page)." }, { question: "Is sync a merge?", answer: "No. Deploy is like a commit; sync is a force push or force pull of deployed workflows onto the target. Use Rollback only for the last sync into a workspace, and remember copied resources may remain." }, { question: "Who can disconnect a fork I cannot open?", answer: "Any admin on your side of the edge. Disconnect does not require access to the other workspace — so you are not stuck if the other side lost membership." }, - { question: "I deployed a new workflow and sync ignored it. Why?", answer: "Sync new workflows by default is off for this fork lineage, so the workflow was created outside fork sync. Open Settings → Organization → Workspace forks and check it under Synced workflows. Turning the toggle back on only affects workflows created after that — it never moves an existing one." }, + { question: "I deployed a new workflow and sync ignored it. Why?", answer: "Sync new workflows by default is off for this fork lineage, so the workflow was created outside fork sync. Open Settings → Workspace → Workspace forks and check it under Synced workflows. Turning the toggle back on only affects workflows created after that — it never moves an existing one." }, { question: "Does turning Sync new workflows by default off stop my current syncs?", answer: "No. It is forward-only and never rewrites an existing workflow's checkbox, so everything already synced keeps syncing. It also applies to every workspace in the fork lineage, not just the one you changed it from." } ]} /> @@ -413,4 +413,4 @@ Self-hosted deployments turn Forks on with an environment variable instead of th |----------|-------------| | `FORKING_ENABLED`, `NEXT_PUBLIC_FORKING_ENABLED` | Enables workspace forking when billing is not used as the entitlement gate | -Once enabled, use the same **Settings → Organization → Workspace forks** UI as Sim Cloud. Only workspace admins can manage forks. +Once enabled, use the same **Settings → Workspace → Workspace forks** UI as Sim Cloud. Only workspace admins can manage forks. diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index f84d160f6e5..cc59d7627bf 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -12,13 +12,13 @@ import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' import { deduplicateWorkflowName } from '@/lib/workflows/utils' -import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('SuperUserImportWorkflow') @@ -130,30 +130,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Create new workflow record const newWorkflowId = generateId() - const now = new Date() const dedupedName = await deduplicateWorkflowName( `[Debug Import] ${sourceWorkflow.name}`, targetWorkspaceId, null ) - await db.insert(workflow).values({ - id: newWorkflowId, - userId: session.user.id, - workspaceId: targetWorkspaceId, - folderId: null, - name: dedupedName, - description: sourceWorkflow.description, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, // Never copy deployment status - runCount: 0, - variables: sourceWorkflow.variables || {}, - // An imported workflow is a NEW workflow in the target workspace, so it takes that - // workspace's fork-sync policy rather than the column default. - forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, targetWorkspaceId), - }) + await db.insert(workflow).values( + await buildNewWorkflowRow(db, { + id: newWorkflowId, + userId: session.user.id, + workspaceId: targetWorkspaceId, + folderId: null, + name: dedupedName, + description: sourceWorkflow.description, + variables: sourceWorkflow.variables || {}, + }) + ) // Save using existing persistence logic const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, { diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 5e0b85791bc..0c7f0f3c405 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -30,6 +30,7 @@ import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -41,7 +42,6 @@ import { notFoundResponse, } from '@/app/api/v1/admin/responses' import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types' -import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('AdminWorkflowImportAPI') @@ -113,27 +113,18 @@ export const POST = withRouteHandler( ) const workflowId = generateId() - const now = new Date() const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null) - await db.insert(workflow).values({ - id: workflowId, - userId: workspaceData.ownerId, - workspaceId, - folderId: folderId || null, - name: dedupedName, - description: workflowDescription, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - // An imported workflow is a NEW workflow in this workspace, so it takes the - // workspace's fork-sync policy. Without this it lands on the column default and - // silently joins fork sync in a workspace that opted out. - forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId), - }) + await db.insert(workflow).values( + await buildNewWorkflowRow(db, { + id: workflowId, + userId: workspaceData.ownerId, + workspaceId, + folderId: folderId || null, + name: dedupedName, + description: workflowDescription, + }) + ) /** * Same normalization the editor and the v1 import API run, via the one diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 12b26db0c28..f19a1c072a7 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -45,6 +45,7 @@ import { extractWorkflowsFromZip, parseWorkflowJson, } from '@/lib/workflows/operations/import-export' +import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -62,7 +63,6 @@ import type { WorkspaceImportRequest, WorkspaceImportResponse, } from '@/app/api/v1/admin/types' -import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' const logger = createLogger('AdminWorkspaceImportAPI') @@ -348,27 +348,18 @@ async function importSingleWorkflow( } const workflowId = generateId() - const now = new Date() const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId) - await db.insert(workflow).values({ - id: workflowId, - userId: ownerId, - workspaceId, - folderId: targetFolderId, - name: dedupedName, - description: workflowData.metadata?.description || 'Imported via Admin API', - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - // An imported workflow is a NEW workflow in this workspace, so it takes the - // workspace's fork-sync policy. Without this it lands on the column default and - // silently joins fork sync in a workspace that opted out. - forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId), - }) + await db.insert(workflow).values( + await buildNewWorkflowRow(db, { + id: workflowId, + userId: ownerId, + workspaceId, + folderId: targetFolderId, + name: dedupedName, + description: workflowData.metadata?.description || 'Imported via Admin API', + }) + ) /** * Same normalization the editor, the v1 import API and the single-workflow diff --git a/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts index 1f9cdaf9d27..d33985f4cb2 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts @@ -13,10 +13,8 @@ export const PUT = defineInternalJsonRoute({ auth: internalSessionAuth, operation: forkOperations.syncDefault, /** - * Rated, unlike its sibling fork routes. This is the one that writes workspaces the - * caller may not administer, under the feature's coarsest advisory lock, so an admin of - * any single lineage member could otherwise loop it and starve fork creation across the - * whole lineage. + * Rate-limited, unlike sibling fork routes: it writes the whole lineage under the coarsest + * fork lock, so looping it could starve fork creation lineage-wide. */ rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-sync-default' }), errorPolicy: internalForkErrorPolicy, diff --git a/apps/sim/ee/workspace-forking/application/lineage-details.ts b/apps/sim/ee/workspace-forking/application/lineage-details.ts index a2143b28b91..0b640febdc0 100644 --- a/apps/sim/ee/workspace-forking/application/lineage-details.ts +++ b/apps/sim/ee/workspace-forking/application/lineage-details.ts @@ -1,10 +1,10 @@ import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { eq } from 'drizzle-orm' +import { readForkSyncNewWorkflowsExcluded } from '@/lib/workflows/persistence/new-workflow-row' import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' -import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default' /** * Annotates a lineage node with whether the viewer holds any access to it (explicit @@ -40,7 +40,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({ getForkChildren(workspaceId), getUndoableRunForTarget(db, workspaceId), // Lineage-uniform, so this workspace's own value is the lineage's value. - resolveForkSyncExclusionForNewWorkflow(db, workspaceId), + readForkSyncNewWorkflowsExcluded(db, workspaceId), ]) const [parent, children] = await Promise.all([ diff --git a/apps/sim/ee/workspace-forking/application/operations.ts b/apps/sim/ee/workspace-forking/application/operations.ts index 27759c9e9b5..e744d99f92f 100644 --- a/apps/sim/ee/workspace-forking/application/operations.ts +++ b/apps/sim/ee/workspace-forking/application/operations.ts @@ -99,12 +99,8 @@ export const forkOperations = { oauthScope: 'api:write', }), /** - * Admin on the CALLING workspace is sufficient, and the write then fans out to every - * ancestor and descendant, because the default is meaningless unless it is uniform - * across a lineage. Flipping it to "sync new workflows" restores the historical - * behaviour rather than granting anything new, and it never moves an existing workflow - * in or out of sync - so each member records its own audit entry rather than the write - * being restricted to one workspace. + * Admin on the calling workspace is sufficient; the write fans out to the whole lineage + * because the default must be uniform, and it never moves an existing workflow. * * permission-group-exempt: the new-workflow fork-sync default is workspace configuration governed by the admin role. */ diff --git a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts index eb519e299bc..f679f2686e2 100644 --- a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts +++ b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts @@ -52,10 +52,8 @@ export const updateWorkspaceForkMappings = defineForkUseCase< input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId return db.transaction(async (tx) => { await setForkLockTimeout(tx) - // Rank 4 - see the rank table on `acquireForkLineageLock`. Unlike promote and - // rollback this takes no rank-3 target lock: it rewrites only this edge's mapping - // rows, never the target's workflows, so nothing contends with a sync into the - // target. Skipping a higher rank is not an ordering violation. + // Rank 4 - see the rank table on `acquireForkLineageLock`. No target lock: this + // rewrites only the edge's mapping rows. await acquireForkEdgeLock(tx, edge.childWorkspaceId) const [currentEdge] = await tx .select({ parentId: workspace.forkedFromWorkspaceId }) diff --git a/apps/sim/ee/workspace-forking/application/revision.ts b/apps/sim/ee/workspace-forking/application/revision.ts index 4ca0fb3b96d..1bfb81aaf92 100644 --- a/apps/sim/ee/workspace-forking/application/revision.ts +++ b/apps/sim/ee/workspace-forking/application/revision.ts @@ -143,11 +143,8 @@ export async function loadForkPreviewRevision( /** * Locks normalized graph rows as well as workflow metadata, including realtime-only writes. * - * Rank 5 - the heaviest acquirer in the fork module, and the one the rank table on - * `acquireForkLineageLock` exists for. It takes `FOR UPDATE` on the `workspace` rows, so - * any caller that also needs the rank-2 lineage lock must take that one FIRST; doing it - * the other way round deadlocks against `unlinkForkEdge`, which holds the lineage key and - * then updates the same `workspace` row. + * Rank 5 - see the rank table on `acquireForkLineageLock`. Takes `FOR UPDATE` on `workspace` + * rows, so a caller needing the rank-2 lineage lock must take it first. */ export async function lockForkRevision(tx: DbTransaction, scope: ForkRevisionScope): Promise { const workspaceIds = [ @@ -207,21 +204,11 @@ export async function assertForkSourceVersions( sourceWorkspaceId: string, expected: ReadonlyMap ): Promise { - // Verify exactly the workflows that were ADMITTED, rather than re-deriving the source - // predicate here. Re-deriving it duplicated `listDeployedWorkflows`'s filter, so the day - // a caller admitted a different set - "Copy unsynced workflows" admits sync-excluded - // workflows - this query returned fewer rows and every such fork failed on a phantom - // size mismatch. Keying off `expected` cannot drift from the admitted set by construction. - if (expected.size === 0) return - const admittedIds = sql.join( - [...expected.keys()].map((id) => sql`${id}`), - sql`, ` - ) const rows = await tx.execute<{ workflowId: string; id: string; digest: string }>(sql` SELECT w.id AS "workflowId", d.id, md5(d.state::text) AS digest FROM ${workflow} w JOIN ${workflowDeploymentVersion} d ON d.workflow_id = w.id AND d.is_active = true WHERE w.workspace_id = ${sourceWorkspaceId} AND w.is_deployed = true - AND w.archived_at IS NULL AND w.id IN (${admittedIds}) + AND w.archived_at IS NULL AND w.fork_sync_excluded = false `) if ( rows.length !== expected.size || diff --git a/apps/sim/ee/workspace-forking/application/sync-default.test.ts b/apps/sim/ee/workspace-forking/application/sync-default.test.ts index 953001acd26..5eb0b50df73 100644 --- a/apps/sim/ee/workspace-forking/application/sync-default.test.ts +++ b/apps/sim/ee/workspace-forking/application/sync-default.test.ts @@ -1,9 +1,10 @@ /** * @vitest-environment node */ +import { workspace } from '@sim/db/schema' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' -import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' import { @@ -12,12 +13,14 @@ import { } from '@sim/testing/mocks/workspace-authorization.mock' import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock' import { workspaceForkingLineageMock } from '@sim/testing/mocks/workspace-forking-lineage.mock' +import { + workspaceForkingLineageRootMock, + workspaceForkingLineageRootMockFns, +} from '@sim/testing/mocks/workspace-forking-lineage-root.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const hoisted = vi.hoisted(() => ({ - resolveRootId: vi.fn(), - resolveLineage: vi.fn(), -})) +const { mockResolveForkLineageRootId, mockResolveForkLineageWorkspaceIds } = + workspaceForkingLineageRootMockFns vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) @@ -25,10 +28,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) -vi.mock('@/ee/workspace-forking/lib/sync-default', () => ({ - resolveForkLineageRootId: hoisted.resolveRootId, - resolveForkLineageWorkspaceIds: hoisted.resolveLineage, -})) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage-root', () => workspaceForkingLineageRootMock) import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default' @@ -37,7 +37,6 @@ const LINEAGE = ['root-ws', 'fork-a', 'fork-b', 'grandchild'] beforeEach(() => { resetDbChainMock() - vi.clearAllMocks() permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'fork-a', name: 'Fork A', @@ -45,11 +44,13 @@ beforeEach(() => { allowPersonalApiKeys: true, }) workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) - hoisted.resolveRootId.mockResolvedValue('root-ws') - hoisted.resolveLineage.mockResolvedValue(LINEAGE) - // The use case's `UPDATE ... RETURNING` over the global @sim/db mock: the rows it returns - // are the lineage members whose value actually changed, each with the name the audit - // entry is filed under. + mockResolveForkLineageRootId.mockResolvedValue('root-ws') + mockResolveForkLineageWorkspaceIds.mockResolvedValue(LINEAGE) + // The live-member row lock, then the `UPDATE ... RETURNING` of the members that changed. + queueTableRows( + workspace, + LINEAGE.map((id) => ({ id })) + ) dbChainMockFns.returning.mockResolvedValue(LINEAGE.map((id) => ({ id, name: `Name of ${id}` }))) }) @@ -60,18 +61,11 @@ const run = (excludeNewWorkflows: boolean) => }) describe('setForkSyncDefault', () => { - it('writes every lineage member when issued from a mid-lineage fork', async () => { - await expect(run(true)).resolves.toMatchObject({ - excludeNewWorkflows: true, - changedWorkspaces: LINEAGE.map((id) => ({ id, name: `Name of ${id}` })), - }) - }) - /** * One entry per member, because the default genuinely changed for all of them. A single * entry on the calling workspace would leave the other members' admins with no record. */ - it('records one entry per CHANGED member, naming where the change was issued from', async () => { + it("files one audit entry per updated member, in that member's own workspace and name", async () => { await run(true) const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry) expect(audited).toHaveLength(LINEAGE.length) @@ -82,9 +76,7 @@ describe('setForkSyncDefault', () => { // defaults it to the caller's workspace, so every entry would pile into one log and // the other members' admins would see nothing. expect(entry.workspaceId).toBe(entry.resourceId) - // The member's OWN name, not a raw id and not the caller's name. A lineage-wide - // change that named only the calling workspace left every other member's audit - // entry reading as an opaque identifier. + // The member's OWN name, not a raw id and not the caller's name. expect(entry.resourceName).toBe(`Name of ${entry.resourceId}`) expect(entry.metadata).toMatchObject({ forkSyncNewWorkflowsExcluded: true, @@ -94,44 +86,9 @@ describe('setForkSyncDefault', () => { } }) - /** - * `projectAudit` maps over `changedWorkspaces` and `afterSuccess` returns early when it - * is empty, so an empty result IS "no audit, no analytics". Asserting the result rather - * than the mocks' call counts keeps this pinned to the value the fan-out reads. - */ - it('reports no changed members when the value already matched everywhere', async () => { - dbChainMockFns.returning.mockResolvedValue([]) - await expect(run(true)).resolves.toMatchObject({ changedWorkspaces: [] }) - }) - - /** - * The update only touches members whose value differs, so auditing the whole lineage - * would file a change record against a workspace that already held the requested value. - */ - it('records nothing for a member that already held the requested value', async () => { - dbChainMockFns.returning.mockResolvedValue([{ id: 'fork-b', name: 'Fork B' }]) - await expect(run(true)).resolves.toMatchObject({ - changedWorkspaces: [{ id: 'fork-b', name: 'Fork B' }], - }) - const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry) - expect(audited.map((entry) => entry.resourceId)).toEqual(['fork-b']) - }) - - /** - * The fan-out reaches workspaces the caller may not administer, so the admission check - * is the only thing standing between a non-admin and a lineage-wide write. Assert it - * rejects rather than trusting that the wrapper was wired up. - */ - it('rejects a caller who fails workspace admission', async () => { - workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockRejectedValue( - new Error('forbidden') - ) - await expect(run(true)).rejects.toThrow('forbidden') - }) - - it('carries the chosen value through, so turning the default back on is symmetric', async () => { - await expect(run(false)).resolves.toMatchObject({ excludeNewWorkflows: false }) - const [entry] = auditMockFns.mockRecordAudit.mock.calls[0] - expect(entry.metadata).toMatchObject({ forkSyncNewWorkflowsExcluded: false }) + /** An unlink that moved the caller out of the locked root's lineage must not be written. */ + it('refuses when the locked root no longer reaches the calling workspace', async () => { + mockResolveForkLineageWorkspaceIds.mockResolvedValue(['root-ws', 'fork-b']) + await expect(run(true)).rejects.toMatchObject({ statusCode: 409 }) }) }) diff --git a/apps/sim/ee/workspace-forking/application/sync-default.ts b/apps/sim/ee/workspace-forking/application/sync-default.ts index 1b8ea739670..8c5592aa3ca 100644 --- a/apps/sim/ee/workspace-forking/application/sync-default.ts +++ b/apps/sim/ee/workspace-forking/application/sync-default.ts @@ -1,8 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' import { workspace } from '@sim/db/schema' -import { compareStrings } from '@sim/utils/string' -import { and, inArray, isNull, ne, sql } from 'drizzle-orm' +import { and, asc, inArray, isNull, ne } from 'drizzle-orm' import { captureServerEvent } from '@/lib/posthog/server' import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' import { forkOperations } from '@/ee/workspace-forking/application/operations' @@ -14,7 +13,7 @@ import { import { resolveForkLineageRootId, resolveForkLineageWorkspaceIds, -} from '@/ee/workspace-forking/lib/sync-default' +} from '@/ee/workspace-forking/lib/lineage/lineage-root' export interface SetForkSyncDefaultInput { workspaceId: string @@ -50,98 +49,68 @@ export const setForkSyncDefault = defineForkUseCase< >({ operation: forkOperations.syncDefault, async execute({ input }) { + // The root is the lock key, so it is resolved before the lock; membership is expanded under it. + const rootId = await resolveForkLineageRootId(db, input.workspaceId) return db.transaction(async (tx) => { await setForkLockTimeout(tx) - const rootId = await resolveForkLineageRootId(tx, input.workspaceId) - // Rank 2, and the only fork lock this transaction takes before its rank-6 row - // locks - see the rank table on `acquireForkLineageLock`. + // Rank 2 - see the rank table on `acquireForkLineageLock`. await acquireForkLineageLock(tx, rootId) - // Re-resolve under the lock and refuse if the root moved. An unlink committing - // between the read and the lock would leave us holding the OLD lineage's key while - // writing the new one's members, so a second write rooted at the new lineage could - // run concurrently over the same rows. Mirrors the organization re-check `createFork` - // performs under its own lock. - if ((await resolveForkLineageRootId(tx, input.workspaceId)) !== rootId) { + // Expanded under the lock, so a fork created a moment ago is included and one being + // created now waits on the same key. A root that no longer reaches the caller means an + // unlink moved it before we locked, and this key no longer covers its lineage. + const lineageWorkspaceIds = await resolveForkLineageWorkspaceIds(tx, rootId) + if (!lineageWorkspaceIds.includes(input.workspaceId)) { throw new ForkError( 'The fork lineage changed while this request was being applied. Try again.', 409 ) } - // Resolve the membership AFTER the lock: a fork created a moment ago must be - // included, and one being created right now is blocked on the same key. - const lineageWorkspaceIds = await resolveForkLineageWorkspaceIds(tx, input.workspaceId) - if (lineageWorkspaceIds.length === 0) { + // Rank 6: lock the live members whose value differs, in id order, since a multi-row + // UPDATE locks in plan order and could deadlock against other multi-workspace writers. + // NO KEY UPDATE keeps FK inserts into these workspaces (KEY SHARE) from queueing behind + // the write. Nothing else writes this column under the lineage lock, so the filter holds. + const toChange = await tx + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + inArray(workspace.id, lineageWorkspaceIds), + isNull(workspace.archivedAt), + ne(workspace.forkSyncNewWorkflowsExcluded, input.excludeNewWorkflows) + ) + ) + .orderBy(asc(workspace.id)) + .for('no key update') + if (toChange.length === 0) { return { excludeNewWorkflows: input.excludeNewWorkflows, changedWorkspaces: [] } } - // Rank 6: take the member row locks in sorted id order BEFORE the update. A bare - // multi-row `UPDATE ... WHERE id IN (...)` acquires its row locks in whatever order - // the plan produces, so it is unordered against any other multi-workspace writer - // that takes no lineage lock - `lockWorkspaceRowsForPayerChanges` on the - // organization-attach path is one today. `revision.ts` locks this same table with an - // explicit `ORDER BY id FOR UPDATE` for exactly this reason; Drizzle's - // `.update().where(inArray(...))` cannot express ORDER BY, so this is the same - // lock-then-update shape. - const memberIds = sql.join( - [...lineageWorkspaceIds].sort(compareStrings).map((id) => sql`${id}`), - sql`, ` - ) - await tx.execute( - sql`SELECT id FROM ${workspace} WHERE id IN (${memberIds}) ORDER BY id FOR UPDATE` - ) const changed = await tx .update(workspace) .set({ forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows, updatedAt: new Date() }) .where( - and( - inArray(workspace.id, lineageWorkspaceIds), - // Re-assert liveness at write time: `resolveForkLineageWorkspaceIds` filtered - // archived members when it read, but a workspace can be archived between that - // read and this update, and a policy write must never touch one. - isNull(workspace.archivedAt), - ne(workspace.forkSyncNewWorkflowsExcluded, input.excludeNewWorkflows) + inArray( + workspace.id, + toChange.map((member) => member.id) ) ) - // Return the NAME too, so the audit fan-out can identify each member the way a - // reader knows it. Projecting a bare id as `resourceName` made every entry but the - // caller's read as a raw identifier in the audit log. .returning({ id: workspace.id, name: workspace.name }) - return { - excludeNewWorkflows: input.excludeNewWorkflows, - // The members whose value ACTUALLY changed, not every member considered. Auditing - // the whole lineage would file a change record against a workspace that already - // held the requested value, making its history claim something that did not happen. - changedWorkspaces: changed, - } + return { excludeNewWorkflows: input.excludeNewWorkflows, changedWorkspaces: changed } }) }, - /** - * One entry per workspace whose value actually changed. Every such member gets its own - * record, because the default genuinely moved for each of them and a single entry on the - * calling workspace would leave the others' admins with no trace. A member that already - * held the requested value gets nothing - it did not change. - */ + /** One entry per member whose value changed, so every affected workspace's admins see it. */ projectAudit: ({ input, context, result }) => result.changedWorkspaces.map((member) => ({ action: AuditAction.WORKSPACE_FORK_SYNC_DEFAULT_CHANGED, - // File each entry in the workspace it describes, not the caller's. The audit - // wrapper defaults `workspaceId` to the initiating workspace, which would land - // every entry in one log and leave the other members' admins with no record of - // their own workspace changing - the exact gap this per-member fan-out exists - // to close. + // The audit wrapper defaults `workspaceId` to the caller; file each entry in its own workspace. workspaceId: member.id, resourceType: AuditResourceType.WORKSPACE, resourceId: member.id, - // The member's own name, read back from the UPDATE. Falling back to the id for - // every member but the caller made a lineage-wide change read as one named - // workspace and N opaque identifiers. resourceName: member.name, description: input.excludeNewWorkflows ? 'New workflows no longer sync to forks by default' : 'New workflows sync to forks by default', metadata: { forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows, - // Which workspace in the lineage the admin changed it from, so a member's own - // log explains why its behaviour moved without an action taken on it. originWorkspaceId: context.workspace.id, originWorkspaceName: context.workspace.name, workspacesChanged: result.changedWorkspaces.length, diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx index 7e3fa15cf97..f2f86ca30b9 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx @@ -14,48 +14,25 @@ interface ForkSyncDefaultToggleProps { workspaceId: string /** The lineage's stored policy: true means new workflows start outside fork sync. */ excludeNewWorkflows: boolean - /** - * True while the value on screen is not this workspace's own. Covers the first load AND - * the placeholder window after a workspace switch: `useForkLineage` sets - * `placeholderData: keepPreviousData`, so it serves the PREVIOUS workspace's policy with - * `isLoading: false`. Rendering then would show one workspace's value under another's - * name, and a click would write that stale value to the newly selected lineage. - */ - loading: boolean } /** * Whether a newly created workflow in this lineage joins fork sync automatically. * - * Positive polarity, matching the checkbox list below it - on means new workflows sync, - * which is the historical default. The stored column is the negative - * `forkSyncNewWorkflowsExcluded`, so this component owns that inversion. - * - * The description is not decoration: this writes to every workspace in the lineage, and - * without it the control reads as a local preference. That is the "prevents a - * misunderstanding" case `sim-ui-copy.md` reserves supporting copy for. - * - * Forward-only - no existing workflow's checkbox moves, so flipping it can never silently - * pull a workflow into or out of sync. + * Positive polarity, matching the checkbox list below it; this component owns the inversion + * from the stored `forkSyncNewWorkflowsExcluded`. The write reaches every workspace in the + * lineage and is forward-only: no existing workflow moves. */ export function ForkSyncDefaultToggle({ workspaceId, excludeNewWorkflows, - loading, }: ForkSyncDefaultToggleProps) { const updateDefault = useUpdateForkSyncDefault() - // Render nothing until the lineage resolves, matching the workflow list below. A - // placeholder would have to guess a value, and guessing `false` selects "Sync" - the - // opposite of the truth for an opt-in lineage, which then visibly snaps once the real - // value lands. Disabled-but-wrong is worse than absent for a cross-workspace policy. - if (loading) return null - return (
- {/* No `htmlFor`: `ChipSwitch` is a radio group and takes no id, so the group - carries its own `aria-label`. Matches `inbox-enable-toggle.tsx`. */} + {/* No `htmlFor`: `ChipSwitch` is a radio group that takes no id; it carries its own `aria-label`. */}

Applies to every workspace in this fork lineage. diff --git a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts deleted file mode 100644 index 6e75b29b2c1..00000000000 --- a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildForkSyncWorkflowTree } from '@/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows' - -const wf = (over: Record) => ({ - id: 'w', - name: 'W', - isDeployed: true, - archivedAt: null, - folderId: null, - forkSyncExcluded: false, - ...over, -}) - -describe('buildForkSyncWorkflowTree', () => { - it('lists only deployed, non-archived workflows — the only ones that sync', () => { - const tree = buildForkSyncWorkflowTree( - [ - wf({ id: 'live', name: 'Live' }), - wf({ id: 'draft', name: 'Draft', isDeployed: false }), - wf({ id: 'gone', name: 'Gone', archivedAt: new Date() }), - ] as never, - [] - ) - expect(tree.rootWorkflows.map((w) => w.id)).toEqual(['live']) - }) - - it('falls a workflow whose folder was deleted back to root so it stays selectable', () => { - const tree = buildForkSyncWorkflowTree([wf({ id: 'orphan', folderId: 'missing' })] as never, []) - expect(tree.rootWorkflows.map((w) => w.id)).toEqual(['orphan']) - }) - - it('prunes folders with no deployed workflows anywhere beneath them', () => { - const folders = [ - { id: 'f-empty', name: 'Empty', parentId: null, sortOrder: 0 }, - { id: 'f-full', name: 'Full', parentId: null, sortOrder: 1 }, - ] - const tree = buildForkSyncWorkflowTree( - [wf({ id: 'a', folderId: 'f-full' })] as never, - folders as never - ) - expect(tree.folders.map((f) => f.id)).toEqual(['f-full']) - }) - - /** - * The folder-level select-all writes this list verbatim, so a subtree that misses a - * nested workflow silently leaves it out of a "sync all in this folder" click. - */ - it('collects nested descendants into the parent folder select-all list', () => { - const folders = [ - { id: 'parent', name: 'Parent', parentId: null, sortOrder: 0 }, - { id: 'child', name: 'Child', parentId: 'parent', sortOrder: 0 }, - ] - const tree = buildForkSyncWorkflowTree( - [wf({ id: 'top', folderId: 'parent' }), wf({ id: 'nested', folderId: 'child' })] as never, - folders as never - ) - expect([...tree.folders[0].descendantWorkflowIds].sort()).toEqual(['nested', 'top']) - }) -}) diff --git a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx index b6014a11c5b..f7a03674c28 100644 --- a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx @@ -1,7 +1,8 @@ 'use client' import { useId, useMemo, useState } from 'react' -import { Checkbox, ChevronDown, cn, OverflowText, toast } from '@sim/emcn' +import { Checkbox, cn, OverflowText, toast } from '@sim/emcn' +import { ChevronDown } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useUpdateForkSyncedWorkflows } from '@/ee/workspace-forking/hooks/workspace-fork' @@ -36,7 +37,7 @@ interface SyncTreeFolder { * a workflow whose folder was deleted falls into the root bucket so it stays selectable. * Folders sort like the sidebar (sortOrder, then name); workflows keep the list order. */ -export function buildForkSyncWorkflowTree( +function buildForkSyncWorkflowTree( workflows: WorkflowMetadata[], folders: WorkflowFolder[] ): { folders: SyncTreeFolder[]; rootWorkflows: SyncWorkflowItem[] } { @@ -136,12 +137,8 @@ export function ForkSyncedWorkflows({ workspaceId }: ForkSyncedWorkflowsProps) { ) } - // `useWorkflows` and `useFolders` both set `placeholderData: keepPreviousData`, so during - // a workspace switch they serve the PREVIOUS workspace's rows with `isLoading: false`. - // Gating on loading alone rendered workspace A's workflows under workspace B's id, and a - // click then posted A's workflow ids against B. `sim-react-performance.md`: "Never carry - // placeholder data between protected resource keys ... an explicit loading state is - // truthful." Matches `custom-tools.tsx` and `integration-skills-section.tsx`. + // Both queries keep the previous workspace's rows as placeholder data on a switch; + // rendering them would post workspace A's workflow ids against workspace B. const isLoading = workflowsQuery.isPending || workflowsQuery.isPlaceholderData || @@ -149,6 +146,14 @@ export function ForkSyncedWorkflows({ workspaceId }: ForkSyncedWorkflowsProps) { foldersQuery.isPlaceholderData if (isLoading) return null + if (workflowsQuery.isError || foldersQuery.isError) { + return ( + + {getErrorMessage(workflowsQuery.error ?? foldersQuery.error, 'Failed to load workflows')} + + ) + } + if (tree.folders.length === 0 && tree.rootWorkflows.length === 0) { return ( @@ -196,11 +201,15 @@ function SyncFolderRow({ folder, level, syncedIds, onToggle, disabled }: SyncFol const total = folder.descendantWorkflowIds.length const selectedCount = folder.descendantWorkflowIds.filter((id) => syncedIds.has(id)).length const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' + const countLabel = selectedCount > 0 ? `${selectedCount}/${total}` : String(total) return (