diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md new file mode 100644 index 00000000000..f963ce14517 --- /dev/null +++ b/.agents/skills/add-enrichment/SKILL.md @@ -0,0 +1,142 @@ +--- +name: add-enrichment +description: Add a code-defined table enrichment (registry entry) under `apps/sim/enrichments/` backed by an ordered provider cascade, ensuring every provider tool it calls has hosted-key support. Use when adding a per-row table enrichment that fills cells via existing Sim tools. +--- + +# Adding a Table Enrichment + +Enrichments are code-defined entries in `apps/sim/enrichments/` that run **directly per table row** (no workflow). Each enrichment declares inputs, outputs, and an ordered list of **providers**; the cascade runner tries providers in order and the first non-empty result fills the cell. Each provider calls one existing Sim tool via `executeTool`, which injects the workspace's BYOK key or a **hosted key** and bills usage automatically. + +Because enrichments run on Sim's hosted keys by default, **every provider tool you reference must have hosted-key support** — otherwise it can only run when the workspace brings its own key. This command makes that check a required step. + +## Overview + +| Step | What | Where | +|------|------|-------| +| 1 | Pick the data-source tool(s) for each output | `tools/{service}/` + `tools/registry.ts` | +| 2 | **Verify each tool has `hosting`; if not, run `/add-hosted-key`** | `tools/{service}/{action}.ts` | +| 3 | Write the enrichment definition | `enrichments/{name}/{name}.ts` + `index.ts` | +| 4 | Register it | `enrichments/registry.ts` | +| 5 | Verify | tsc / biome / manual run | + +## Architecture (what you're plugging into) + +- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, mapOutput }`. Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe. +- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `firstNonEmpty(arr)`, `splitName(fullName)`. +- **`enrichments/run.ts`** — the server-only cascade runner. Calls `executeTool(provider.toolId, { ...params, _context: { workspaceId } })`, accumulates hosted-key cost, returns the first non-empty mapped result. **You do not edit this** — it works for any registry entry. +- **`enrichments/registry.ts`** — `ENRICHMENT_REGISTRY` / `ALL_ENRICHMENTS` / `getEnrichment`. Register new entries here. + +Outputs automatically become table columns; billing, the catalog/sidebar UI, the column meta-header icon, and per-row execution all work with no extra wiring. + +## Step 1: Pick the data-source tool(s) + +For each output the enrichment produces, decide which existing tool provides it. Look up the service's API and the tool in `apps/sim/tools/{service}/` (e.g. `hunter_email_finder`, `pdl_person_enrich`, `pdl_company_enrich`). Confirm: + +- The tool id is registered in `apps/sim/tools/registry.ts`. +- Its `params` accept what you can derive from table columns (read the tool's `params`). +- Its `outputs` / `transformResponse` actually expose the field you need (read the real output shape — don't assume). + +Order providers **cheapest / most-likely-to-hit first**; the cascade stops at the first non-empty result. Apollo / LinkedIn are not hosted-safe (ToS) — don't use them. + +## Step 2: Verify hosted-key support — chain to `/add-hosted-key` if missing + +**This is the required gate.** For every tool a provider calls, open `apps/sim/tools/{service}/{action}.ts` and check for a `hosting` block: + +```typescript +hosting: { + envKeyPrefix: 'SERVICE_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'service', + pricing: { /* ... */ }, + rateLimit: { /* ... */ }, +} +``` + +- **If `hosting` is present** — good. Note the `envKeyPrefix`; the deployment needs `{PREFIX}_COUNT` + `{PREFIX}_1..N` env vars set for the hosted key to actually resolve at runtime (ops concern, not code). If those env vars aren't set in the target environment, the provider will only run with a workspace BYOK key. +- **If `hosting` is absent** — the tool can't use a Sim-provided key, so the enrichment would silently produce blank cells on hosted Sim. **Stop and run `/add-hosted-key `** to add hosted-key support to that tool first, then come back. Do this for every provider tool that lacks it. + +Why it matters: the cascade runner only bills (and only reads `output.cost.total`) when `executeTool` injected a hosted key, which requires the tool's `hosting` config. No `hosting` → no hosted key → the enrichment depends entirely on per-workspace BYOK. + +## Step 3: Write the enrichment definition + +Create `apps/sim/enrichments/{name}/{name}.ts` and a barrel `index.ts`. Mirror the existing entries (`work-email`, `phone-number`, `company-domain`, `company-info`). + +```typescript +import { SomeIcon } from 'lucide-react' +import { filterUndefined } from '@sim/utils/object' +import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers' +import type { EnrichmentConfig } from '@/enrichments/types' + +export const myEnrichment: EnrichmentConfig = { + id: 'my-enrichment', + name: 'My Enrichment', + description: 'One concise sentence describing what it finds.', + icon: SomeIcon, + inputs: [ + // Person enrichments take a single canonical `fullName` (Clay-style); + // split it with splitName() for tools that need first/last. + { id: 'fullName', name: 'Full name', type: 'string', required: true }, + { id: 'companyDomain', name: 'Company domain', type: 'string' }, + ], + outputs: [{ id: 'value', name: 'value', type: 'string' }], + providers: [ + toolProvider({ + id: 'provider-a', + label: 'Provider A', + toolId: 'service_action', // must have `hosting` (Step 2) + buildParams: (inputs) => { + // Return null when there aren't enough inputs → cascade skips this provider. + const name = splitName(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + if (!name || !domain) return null + return { domain, first_name: name.firstName, last_name: name.lastName } + }, + mapOutput: (output) => { + // Return { [outputId]: value } on a hit, or null to fall through. + const value = str(output.value) + return value ? { value } : null + }, + }), + // ...additional fallback providers, in priority order. + ], +} +``` + +```typescript +// apps/sim/enrichments/{name}/index.ts +export { myEnrichment } from './my-enrichment' +``` + +Rules: +- Keep the file **client-safe**: import only `lucide-react`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call. +- `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs). +- Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config). + +## Step 4: Register it + +In `apps/sim/enrichments/registry.ts`, import and add the entry (catalog order is registration order): + +```typescript +import { myEnrichment } from '@/enrichments/my-enrichment' + +export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { + // ...existing + [myEnrichment.id]: myEnrichment, +} +``` + +## Step 5: Verify + +1. `bunx tsc --noEmit` (from `apps/sim`, `NODE_OPTIONS=--max-old-space-size=8192`) and `bunx biome check` on the changed files. +2. In a table → **+ New column → Enrichments** → pick the new enrichment, map its inputs to columns, name the output column(s), Save. Confirm it appears in the catalog with its icon/description. +3. With hosted keys (or a workspace BYOK key) configured for each provider's service, run a row and confirm the cell fills; the dev-server log shows `Enrichment hit { provider }`. A row whose providers all miss completes blank; a row where every provider errored shows an error cell. + +## Checklist + +- [ ] Each output mapped to a real tool field (verified against the tool's `params`/`outputs`) +- [ ] **Every provider tool has a `hosting` block — ran `/add-hosted-key` for any that didn't** +- [ ] Providers ordered cheapest / most-likely-first; Apollo/LinkedIn not used +- [ ] Enrichment file is client-safe (no `@/tools` import); uses `toolProvider` + shared helpers +- [ ] `buildParams` returns `null` on insufficient inputs; `mapOutput` returns `null` on a miss +- [ ] Registered in `enrichments/registry.ts` +- [ ] tsc + biome clean; created and ran the column end-to-end diff --git a/.agents/skills/add-enrichment/agents/openai.yaml b/.agents/skills/add-enrichment/agents/openai.yaml new file mode 100644 index 00000000000..f19413c0a44 --- /dev/null +++ b/.agents/skills/add-enrichment/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Add Enrichment" + short_description: "Build a table enrichment cascade" + brand_color: "#16A34A" + default_prompt: "Use $add-enrichment to add a code-defined Sim table enrichment backed by a provider cascade." diff --git a/.agents/skills/add-hosted-key/SKILL.md b/.agents/skills/add-hosted-key/SKILL.md new file mode 100644 index 00000000000..2cb257c2060 --- /dev/null +++ b/.agents/skills/add-hosted-key/SKILL.md @@ -0,0 +1,296 @@ +--- +name: add-hosted-key +description: Add hosted API key support to a tool so Sim provides the key (metered and billed to the workspace) when a user has not brought their own. Use when adding a `hosting` config to a tool under `apps/sim/tools/{service}/`. +--- + +# Adding Hosted Key Support to a Tool + +When a tool has hosted key support, Sim provides its own API key if the user hasn't configured one (via BYOK or env var). Usage is metered and billed to the workspace. + +## Overview + +| Step | What | Where | +|------|------|-------| +| 1 | Register BYOK provider ID | `tools/types.ts`, `app/api/workspaces/[id]/byok-keys/route.ts` | +| 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) | +| 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` | +| 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` | +| 5 | Add to BYOK settings UI | BYOK settings component (`byok.tsx`) | +| 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) | + +## Step 1: Register the BYOK Provider ID + +Add the new provider to the `BYOKProviderId` union in `tools/types.ts`: + +```typescript +export type BYOKProviderId = + | 'openai' + | 'anthropic' + // ...existing providers + | 'your_service' +``` + +Then add it to `VALID_PROVIDERS` in `app/api/workspaces/[id]/byok-keys/route.ts`: + +```typescript +const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'your_service'] as const +``` + +## Step 2: Research the API's Pricing Model and Rate Limits + +**Before writing any `getCost` or `rateLimit` code**, look up the service's official documentation for both pricing and rate limits. You need to understand: + +### Pricing + +1. **How the API charges** — per request, per credit, per token, per step, per minute, etc. +2. **Whether the API reports cost in its response** — look for fields like `creditsUsed`, `costDollars`, `tokensUsed`, or similar in the response body or headers +3. **Whether cost varies by endpoint/options** — some APIs charge more for certain features (e.g., Firecrawl charges 1 credit/page base but +4 for JSON format, +4 for enhanced mode) +4. **The dollar-per-unit rate** — what each credit/token/unit costs in dollars on our plan + +### Rate Limits + +1. **What rate limits the API enforces** — requests per minute/second, tokens per minute, concurrent requests, etc. +2. **Whether limits vary by plan tier** — free vs paid vs enterprise often have different ceilings +3. **Whether limits are per-key or per-account** — determines whether adding more hosted keys actually increases total throughput +4. **What the API returns when rate limited** — HTTP 429, `Retry-After` header, error body format, etc. +5. **Whether there are multiple dimensions** — some APIs limit both requests/min AND tokens/min independently + +Search the API's docs/pricing page (use WebSearch/WebFetch). Capture the pricing model as a comment in `getCost` so future maintainers know the source of truth. + +### Setting Our Rate Limits + +Our rate limiter (`lib/core/rate-limiter/hosted-key/`) uses a token-bucket algorithm applied **per billing actor** (workspace). It supports two modes: + +- **`per_request`** — simple; just `requestsPerMinute`. Good when the API charges flat per-request or cost doesn't vary much. +- **`custom`** — `requestsPerMinute` plus additional `dimensions` (e.g., `tokens`, `search_units`). Each dimension has its own `limitPerMinute` and an `extractUsage` function that reads actual usage from the response. Use when the API charges on a variable metric (tokens, credits) and you want to cap that metric too. + +When choosing values for `requestsPerMinute` and any dimension limits: + +- **Stay well below the API's per-key limit** — our keys are shared across all workspaces. If the API allows 60 RPM per key and we have 3 keys, the global ceiling is ~180 RPM. Set the per-workspace limit low enough (e.g., 20-60 RPM) that many workspaces can coexist without collectively hitting the API's ceiling. +- **Account for key pooling** — our round-robin distributes requests across `N` hosted keys, so the effective API-side rate per key is `(total requests) / N`. But per-workspace limits are enforced *before* key selection, so they apply regardless of key count. +- **Prefer conservative defaults** — it's easy to raise limits later but hard to claw back after users depend on high throughput. + +## Step 3: Add `hosting` Config to the Tool + +Add a `hosting` object to the tool's `ToolConfig`. This tells the execution layer how to acquire hosted keys, calculate cost, and rate-limit. + +```typescript +hosting: { + envKeyPrefix: 'YOUR_SERVICE_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'your_service', + pricing: { + type: 'custom', + getCost: (_params, output) => { + if (output.creditsUsed == null) { + throw new Error('Response missing creditsUsed field') + } + const creditsUsed = output.creditsUsed as number + const cost = creditsUsed * 0.001 // dollars per credit + return { cost, metadata: { creditsUsed } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 100, + }, +}, +``` + +### Hosted Key Env Var Convention + +Keys use a numbered naming pattern driven by a count env var: + +``` +YOUR_SERVICE_API_KEY_COUNT=3 +YOUR_SERVICE_API_KEY_1=sk-... +YOUR_SERVICE_API_KEY_2=sk-... +YOUR_SERVICE_API_KEY_3=sk-... +``` + +The `envKeyPrefix` value (`YOUR_SERVICE_API_KEY`) determines which env vars are read at runtime. Adding more keys only requires bumping the count and adding the new env var. + +### Pricing: Prefer API-Reported Cost + +Always prefer using cost data returned by the API (e.g., `creditsUsed`, `costDollars`). This is the most accurate because it accounts for variable pricing tiers, feature modifiers, and plan-level discounts. + +**When the API reports cost** — use it directly and throw if missing: + +```typescript +pricing: { + type: 'custom', + getCost: (params, output) => { + if (output.creditsUsed == null) { + throw new Error('Response missing creditsUsed field') + } + // $0.001 per credit — from https://example.com/pricing + const cost = (output.creditsUsed as number) * 0.001 + return { cost, metadata: { creditsUsed: output.creditsUsed } } + }, +}, +``` + +**When the API does NOT report cost** — compute it from params/output based on the pricing docs, but still validate the data you depend on: + +```typescript +pricing: { + type: 'custom', + getCost: (params, output) => { + if (!Array.isArray(output.searchResults)) { + throw new Error('Response missing searchResults, cannot determine cost') + } + // Serper: 1 credit for <=10 results, 2 credits for >10 — from https://serper.dev/pricing + const credits = Number(params.num) > 10 ? 2 : 1 + return { cost: credits * 0.001, metadata: { credits } } + }, +}, +``` + +**`getCost` must always throw** if it cannot determine cost. Never silently fall back to a default — this would hide billing inaccuracies. + +### Capturing Cost Data from the API + +If the API returns cost info, capture it in `transformResponse` so `getCost` can read it from the output: + +```typescript +transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: true, + output: { + results: data.results, + creditsUsed: data.creditsUsed, // pass through for getCost + }, + } +}, +``` + +For async/polling tools, capture it in `postProcess` when the job completes: + +```typescript +if (jobData.status === 'completed') { + result.output = { + data: jobData.data, + creditsUsed: jobData.creditsUsed, + } +} +``` + +## Step 4: Hide the API Key Field When Hosted + +In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` to the API key subblock. This hides the field on hosted Sim since the platform provides the key: + +```typescript +{ + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your API key', + password: true, + required: true, + hideWhenHosted: true, +}, +``` + +The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`). + +### Excluding Specific Operations from Hosted Key Support + +When a block has multiple operations but some operations should **not** use a hosted key (e.g., the underlying API is deprecated, unsupported, or too expensive), use the **duplicate apiKey subblock** pattern. This is the same pattern Exa uses for its `research` operation: + +1. **Remove the `hosting` config** from the tool definition for that operation — it must not have a `hosting` object at all. +2. **Duplicate the `apiKey` subblock** in the block config with opposing conditions: + +```typescript +// API Key — hidden when hosted for operations with hosted key support +{ + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your API key', + password: true, + required: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'unsupported_op', not: true }, +}, +// API Key — always visible for unsupported_op (no hosted key support) +{ + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your API key', + password: true, + required: true, + condition: { field: 'operation', value: 'unsupported_op' }, +}, +``` + +Both subblocks share the same `id: 'apiKey'`, so the same value flows to the tool. The conditions ensure only one is visible at a time. The first has `hideWhenHosted: true` and shows for all hosted operations; the second has no `hideWhenHosted` and shows only for the excluded operation — meaning users must always provide their own key for that operation. + +To exclude multiple operations, use an array: `{ field: 'operation', value: ['op_a', 'op_b'] }`. + +**Reference implementations:** +- **Exa** (`blocks/blocks/exa.ts`): `research` operation excluded from hosting — lines 309-329 +- **Google Maps** (`blocks/blocks/google_maps.ts`): `speed_limits` operation excluded from hosting (deprecated Roads API) + +## Step 5: Add to the BYOK Settings UI + +Add an entry to the `PROVIDERS` array in the BYOK settings component so users can bring their own key. You need the service icon from `components/icons.tsx`: + +```typescript +{ + id: 'your_service', + name: 'Your Service', + icon: YourServiceIcon, + description: 'What this service does', + placeholder: 'Enter your API key', +}, +``` + +## Step 6: Summarize Pricing and Throttling Comparison + +After all code changes are complete, output a detailed summary to the user covering: + +### What to include + +1. **API's pricing model** — how the service charges (per token, per credit, per request, etc.), the specific rates found in docs, and whether the API reports cost in responses. +2. **Our `getCost` approach** — how we calculate cost, what fields we depend on, and any assumptions or estimates (especially when the API doesn't report exact dollar cost). +3. **API's rate limits** — the documented limits (RPM, TPM, concurrent, etc.), which plan tier they apply to, and whether they're per-key or per-account. +4. **Our `rateLimit` config** — what we set for `requestsPerMinute` (and dimensions if custom mode), why we chose those values, and how they compare to the API's limits. +5. **Key pooling impact** — how many hosted keys we expect, and how round-robin distribution affects the effective per-key rate at the API. +6. **Gaps or risks** — anything the API charges for that we don't meter, rate limit dimensions we chose not to enforce, or pricing that may be inaccurate due to variable model/tier costs. + +### Format + +Present this as a structured summary with clear headings. Example: + +``` +### Pricing +- **API charges**: $X per 1M tokens (input), $Y per 1M tokens (output) — varies by model +- **Response reports cost?**: No — only token counts in `usage` field +- **Our getCost**: Estimates cost at $Z per 1M total tokens based on median model pricing +- **Risk**: Actual cost varies by model; our estimate may over/undercharge for cheap/expensive models + +### Throttling +- **API limits**: 300 RPM per key (paid tier), 60 RPM (free tier) +- **Per-key or per-account**: Per key — more keys = more throughput +- **Our config**: 60 RPM per workspace (per_request mode) +- **With N keys**: Effective per-key rate is (total RPM across workspaces) / N +- **Headroom**: Comfortable — even 10 active workspaces at full rate = 600 RPM / 3 keys = 200 RPM per key, under the 300 RPM API limit +``` + +This summary helps reviewers verify that the pricing and rate limiting are well-calibrated and surfaces any risks that need monitoring. + +## Checklist + +- [ ] Provider added to `BYOKProviderId` in `tools/types.ts` +- [ ] Provider added to `VALID_PROVIDERS` in the BYOK keys API route +- [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses +- [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers +- [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit` +- [ ] `getCost` throws if required cost data is missing from the response +- [ ] Cost data captured in `transformResponse` or `postProcess` if API provides it +- [ ] `hideWhenHosted: true` added to the API key subblock in the block config +- [ ] Provider entry added to the BYOK settings UI with icon and description +- [ ] Env vars documented: `{PREFIX}_COUNT` and `{PREFIX}_1..N` +- [ ] Pricing and throttling summary provided to reviewer diff --git a/.agents/skills/add-hosted-key/agents/openai.yaml b/.agents/skills/add-hosted-key/agents/openai.yaml new file mode 100644 index 00000000000..97dfcf458cf --- /dev/null +++ b/.agents/skills/add-hosted-key/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Add Hosted Key" + short_description: "Add hosted API key to a tool" + brand_color: "#CA8A04" + default_prompt: "Use $add-hosted-key to add hosted API key support (metered and billed) to a Sim tool." diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md new file mode 100644 index 00000000000..f97c966e1f3 --- /dev/null +++ b/.agents/skills/add-model/SKILL.md @@ -0,0 +1,209 @@ +--- +name: add-model +description: Add a new LLM model to `apps/sim/providers/models.ts` with every pricing and capability value verified against the provider's live API docs (no hallucination), plus the repo-side touchpoints that are not data-driven — hosted-key billing, tests, and provider-code handling. Use when adding a model to an existing provider in `apps/sim/providers/models.ts`. +--- + +# Add Model Skill + +You add a new model entry to `apps/sim/providers/models.ts`. **Every numeric and capability claim MUST be derived from a live web fetch of the provider's official docs in this session.** Marketing emails, training data, and your prior knowledge are not sources of truth — they routinely hallucinate pricing, context windows, and capability lists. + +## Hard rules (do not skip) + +1. **Live-fetch or refuse.** Before writing the entry, you must successfully WebFetch the provider's official models/pricing page in this session. If you cannot reach an authoritative source for any field, **mark the field as UNVERIFIED in your report and ask the user before guessing**. Never fill in pricing or capabilities from memory. +2. **Two-source rule for pricing.** Cross-check input/output/cached pricing against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice, mem0, intuitionlabs). If sources disagree, the provider's own docs win — but flag the disagreement. +3. **Read the code before setting capability flags.** Capability flags are dead unless the provider's implementation under `apps/sim/providers/{provider}/` actually consumes them (see Consumption Matrix below). Setting a flag the provider ignores is a silent bug. +4. **Cite every fact.** Your final report must list the URL each value came from. No URL → not verified. + +## Your Task + +1. Identify provider and model id from user args +2. Live-fetch official docs + pricing page + capability/parameter pages + at least one secondary source +3. Apply the Consumption Matrix to know which capability flags are real +4. Read 2-3 sibling entries in `models.ts` and match their pattern exactly +5. Check the repo-side touchpoints that are NOT data-driven (hosted-key billing, tests, provider code) +6. Insert the entry, run `bun run lint`, print the verification report + +## Step 1: Live source-of-truth lookup + +In priority order — fetch all that exist for the provider: + +| Provider | Models index | Pricing | Reasoning/parameter caveats | +|---|---|---|---| +| OpenAI | platform.openai.com/docs/models | openai.com/api/pricing | platform.openai.com/docs/guides/reasoning | +| Anthropic | docs.anthropic.com/en/docs/about-claude/models | anthropic.com/pricing | docs.anthropic.com/en/docs/build-with-claude/extended-thinking | +| Google (Gemini) | ai.google.dev/gemini-api/docs/models | ai.google.dev/pricing | ai.google.dev/gemini-api/docs/thinking | +| xAI | docs.x.ai/developers/models | docs.x.ai/developers/models (per-model detail page) | docs.x.ai/developers/model-capabilities/text/reasoning | +| Mistral | docs.mistral.ai/getting-started/models/models_overview | mistral.ai/pricing | n/a | +| DeepSeek | api-docs.deepseek.com/quick_start/pricing | same | api-docs.deepseek.com/guides/reasoning_model | +| Groq | console.groq.com/docs/models | groq.com/pricing | n/a | +| Cerebras | inference-docs.cerebras.ai/models | cerebras.ai/pricing | n/a | + +Secondary verification (use at least one): `openrouter.ai//`, `artificialanalysis.ai/models/`, `cloudprice.net/models/-`. + +Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, context window in tokens, input price per 1M, cached input price per 1M, output price per 1M, max output tokens, supported reasoning effort levels, accepted parameters (temperature, top_p), release date. Do not fill in fields you cannot find."* + +## Step 2: Consumption Matrix (which provider honors which capability) + +| Capability | Honored by | Effect if set elsewhere | +|---|---|---| +| `temperature` | All providers (passed through if set) | Safe but inert on always-reasoning models that reject it | +| `toolUsageControl` | All providers (provider-level, not per-model) | n/a — set on `ProviderDefinition`, not models | +| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | +| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | +| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | +| `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | +| `computerUse` | `anthropic/core.ts` | Dead elsewhere | +| `deepResearch` | UI flag for routing to deep-research SKUs | Set only on actual deep-research model IDs | +| `memory: false` | Conversation persistence opt-out | Set only when model genuinely cannot maintain history (e.g., deep-research) | + +**Always re-grep before relying on this table** — the codebase moves: + +```bash +rg "reasoningEffort|reasoning_effort" apps/sim/providers// +rg "verbosity" apps/sim/providers// +rg "request\.thinking|thinking:" apps/sim/providers// +rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers// +``` + +## Step 3: Match the provider's existing entry pattern + +Open `apps/sim/providers/models.ts`, find `PROVIDER_DEFINITIONS[].models`, read 2-3 sibling entries. Match field order exactly: + +```ts +{ + id: '', + pricing: { + input: , + cachedInput: , // omit if provider doesn't offer caching + output: , + updatedAt: '', + }, + capabilities: { + // only flags the provider actually consumes — see matrix + }, + contextWindow: , + releaseDate: '', + recommended: true, // only if new flagship; ask user before swapping + speedOptimized: true, // only on smallest/fastest tier + deprecated: true, // only on retired models +} +``` + +### Reseller providers (azure-openai, azure-anthropic, vertex, bedrock, openrouter) + +Model id MUST be prefixed: `azure/`, `azure-anthropic/`, `vertex/`, `bedrock/`, `openrouter/`. Pricing usually mirrors the upstream provider but verify on the reseller's own pricing page. + +### Insertion order + +Within a family, newest first (matches existing convention: GPT-5.5 above GPT-5.4 above GPT-5.2). Across families, biggest/flagship at top of list. + +### `recommended` / `speedOptimized` + +- At most one or two `recommended: true` per provider — the current flagship(s). +- If you're adding a new flagship, ask the user before removing `recommended` from the previous flagship. Never silently flip it. +- `speedOptimized: true` only on the smallest/fastest tier (nano, flash-lite, haiku class). + +## Step 4: Repo-side touchpoints beyond the entry + +Adding the `models.ts` entry is most of the job because nearly every consumer is **data-driven** and picks the model up automatically: the ~40 query helpers in `models.ts` / `providers/utils.ts`, the public `/models` catalog (`app/(landing)/models/utils.ts` iterates `PROVIDER_DEFINITIONS`), the agent-block model dropdown, and copilot's `isKnownModelId` / `suggestModelIdsForUnknownModel` validation. The touchpoints below are the exceptions — they are **not** data-driven, so check each one. + +### Hosted = auto-billed, by provider + +`getHostedModels()` in `apps/sim/providers/models.ts` returns **every** model under `openai`, `anthropic`, and `google`: + +```ts +export function getHostedModels(): string[] { + return [ + ...getProviderModels('openai'), + ...getProviderModels('anthropic'), + ...getProviderModels('google'), + ] +} +``` + +So a model added to any of those three providers is **automatically served with Sim's rotating hosted key and billed** to the workspace via `shouldBillModelUsage()` (`providers/utils.ts`). Before you insert: + +- **If the model should be BYOK-only / never-billed**, do NOT drop it under `openai`/`anthropic`/`google` as-is — that silently enrolls it in hosted billing. Confirm hosting/billing intent with the user. (Precedent: Ollama Cloud is a deliberately separate `isReseller` provider specifically to stay BYOK-only/never-billed.) +- **If the model should be hosted**, the deployment must actually have a key for it — the provider's `{PREFIX}_COUNT` / `{PREFIX}_1..N` env vars must be set, or hosted runs fail at execution time. +- State the hosted/billing status explicitly in the verification report. + +### Tests with hardcoded model IDs + +`bun run lint` does **not** run tests. A few tests assert specific model IDs and can break or need updating when you touch a hosted or flagship model: + +- `apps/sim/providers/utils.test.ts` — asserts membership of `getHostedModels()` / `shouldBillModelUsage()` +- `apps/sim/providers/index.test.ts` and serializer tests — reference concrete model IDs + +```bash +rg "|getHostedModels|shouldBillModelUsage" apps/sim/providers/*.test.ts +``` + +If anything matches, run the affected provider tests and update assertions as needed. + +### New API behavior is NOT data-driven + +The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. + +### Wrong family entirely? + +- **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. +- **Brand-new provider** (not just a new model under an existing one) → much larger surface: add the id to `ProviderId` in `providers/types.ts`, a registry entry in `providers/registry.ts`, a provider implementation under `providers//`, an icon in `components/icons.tsx`, and the `PROVIDER_DEFINITIONS` block. That is beyond this skill — tell the user. + +## Step 5: Write, lint + +```bash +bun run lint +``` + +Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. + +## Step 6: Verification report (mandatory format) + +End with this exact structure: + +```markdown +### Verification — + +| Field | Value | Source URL | Status | +|---|---|---|---| +| `id` | `grok-4.3` | https://docs.x.ai/... | ✓ verified | +| `contextWindow` | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified (2 sources agree) | +| `input` | $1.25/M | https://docs.x.ai/... | ✓ verified | +| `cachedInput` | $0.20/M | https://cloudprice.net/... | ⚠️ single source | +| `output` | $2.50/M | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified | +| `capabilities.temperature` | `{ min: 0, max: 1 }` | matches sibling entries | — pattern-match only | +| `capabilities.reasoningEffort` | NOT SET | provider docs say API rejects it for this model | ✓ correctly omitted | +| `releaseDate` | 2026-04-30 | https://docs.x.ai/... announcement | ✓ verified | +| hosted/billing | BYOK-only (xai not in `getHostedModels`) | `providers/models.ts` | — confirmed intent | + +**Disagreements** +- _none_ OR _OpenRouter says X, provider docs say Y — used Y per provider rule_ + +**Unverified fields** +- _none_ OR _: could not find authoritative source — left as based on sibling pattern; please confirm_ +``` + +If any row is ⚠️ single-source or "unverified," **state it plainly to the user and ask whether to proceed**. Do not silently merge. + +## What to do if you cannot find a source + +Omitting a field is **not the same as verifying it**. Any field you cannot confirm from a live fetch must be **both** omitted from the entry **and** listed as ❓ UNVERIFIED in the report's "Unverified fields" section, with the URLs you attempted. Then ask the user to confirm before merging. + +- Pricing missing → do NOT guess. Omit `cachedInput`. Mark ❓ UNVERIFIED. Ask the user for the price or the docs URL. +- Context window missing → do NOT guess. Ask the user; mark ❓ UNVERIFIED. +- Release date missing → omit the field; mark ❓ UNVERIFIED in the report. +- Capability uncertain → omit the flag (safer than setting a dead/wrong one); mark ❓ UNVERIFIED so the user knows you didn't confirm it either way. + +## Anti-patterns this skill exists to prevent + +- ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) +- ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) +- ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x +- ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date +- ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) +- ❌ Stamping `recommended: true` on the new model without removing it from the previous flagship +- ❌ Adding a BYOK-only model under `openai`/`anthropic`/`google` (silently enrolls it in hosted billing via `getHostedModels()`) +- ❌ Reporting "done" after only `bun run lint` when you touched a hosted (openai/anthropic/google) or flagship model with assertions in `providers/utils.test.ts` +- ❌ Reporting "done" with any UNVERIFIED row in the table diff --git a/.agents/skills/add-model/agents/openai.yaml b/.agents/skills/add-model/agents/openai.yaml new file mode 100644 index 00000000000..4ba3fc233e8 --- /dev/null +++ b/.agents/skills/add-model/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Add Model" + short_description: "Add an LLM model, specs verified" + brand_color: "#0EA5E9" + default_prompt: "Use $add-model to add a new LLM model to Sim with pricing and capabilities verified against the provider's live docs." diff --git a/.agents/skills/council/SKILL.md b/.agents/skills/council/SKILL.md new file mode 100644 index 00000000000..0df112728be --- /dev/null +++ b/.agents/skills/council/SKILL.md @@ -0,0 +1,13 @@ +--- +name: council +description: Spawn parallel task agents to explore a given area of the codebase from multiple angles, then use their findings to answer the question or build a plan. Use when a task needs broad fan-out exploration across many files before acting. +# No agents/openai.yaml by design: council is a meta/exploration utility (like cleanup, ship, you-might-not-need-*), not a service-integration builder, so it intentionally ships no standalone agent card. +--- + +Based on the given area of interest, please: + +1. Dig around the codebase in terms of that given area of interest, gather general information such as keywords and architecture overview. +2. Spawn off n=10 (unless specified otherwise) task agents to dig deeper into the codebase in terms of that given area of interest, some of them should be out of the box for variance. +3. Once the task agents are done, use the information to do what the user wants. + +If user is in plan mode, use the information to create the plan. diff --git a/.agents/skills/validate-model/SKILL.md b/.agents/skills/validate-model/SKILL.md new file mode 100644 index 00000000000..c05b2e3527b --- /dev/null +++ b/.agents/skills/validate-model/SKILL.md @@ -0,0 +1,170 @@ +--- +name: validate-model +description: Validate a model entry (or every model in a provider) in `apps/sim/providers/models.ts` against the provider's live API docs, reporting pricing and capability drift, dead capability flags, hosting/billing intent, and any field that cannot be verified. Use when auditing or repairing model entries under `apps/sim/providers/models.ts`. +--- + +# Validate Model Skill + +You audit one or more model entries in `apps/sim/providers/models.ts` against the provider's official live API docs. **Hallucinated pricing and capabilities are the #1 failure mode in this file.** Every numeric and capability claim must be re-derived from a live web fetch in this session — not from memory, not from training data, not from the user's marketing email. + +## Hard rules (do not skip) + +1. **Live-fetch or report unverified.** Each field must be backed by a live WebFetch in this session. If you cannot reach an authoritative URL for a field, mark it **UNVERIFIED** in the report — do not silently confirm it from memory. +2. **Cite every fact.** Every value in the report must show the source URL it was checked against. No URL → mark UNVERIFIED. +3. **Two-source rule for pricing.** Cross-check input/output/cached against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice). If sources disagree, the provider's own docs win — flag the disagreement. +4. **Inspect provider implementation before flagging capability mismatches.** A capability flag in `models.ts` is dead unless the provider's code under `apps/sim/providers/{provider}/` consumes it (see Consumption Matrix below). Setting a flag the provider ignores is a warning, not a critical. +5. **Never auto-fix without printing the diff.** Show the user the proposed diff before applying. Get confirmation. + +## Your Task + +When invoked as `/validate-model [model-id]`: + +1. Read the target entries from `models.ts` +2. Live-fetch the provider's official models, pricing, and capability/reasoning pages + at least one secondary source for pricing +3. Inspect the provider implementation to know which flags are actually consumed +4. Run the checklist below per model +5. Report findings (critical / warning / suggestion / unverified) with every cell linked to its source URL +6. Offer to fix; on confirm, edit `models.ts` in a single pass and re-lint + +If `model-id` is omitted, validate every model in the provider. + +## Step 1: Read entries from `models.ts` + +Capture per model: `id`, full `pricing`, full `capabilities`, `contextWindow`, `releaseDate`, `recommended`, `speedOptimized`, `deprecated`. + +## Step 2: Live-fetch authoritative sources + +Use the canonical provider URL table in the `add-model` skill (`.claude/commands/add-model.md`, or its mirror `.agents/skills/add-model/SKILL.md`), Step 1, as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. If you update one table, update the other in the same change. + +Secondary cross-check (use at least one): OpenRouter, Artificial Analysis, CloudPrice. + +If a fetch fails (404, timeout, paywall), record the URL attempted and mark dependent fields UNVERIFIED. + +## Step 3: Build the consumption map for this provider + +Re-grep before trusting the snapshot below: + +```bash +rg "reasoningEffort|reasoning_effort" apps/sim/providers// +rg "verbosity" apps/sim/providers// +rg "request\.thinking|thinking:" apps/sim/providers// +rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers// +``` + +Snapshot (verify before relying): + +| Capability | Consumed by | +|---|---| +| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped via thinking), `gemini/core.ts` | +| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` | +| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | +| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | +| `computerUse` | `anthropic/core.ts` | +| `temperature` | All providers (passthrough) | + +A flag set in `models.ts` but not in the consumption list for this provider = **warning: dead flag**. + +## Step 4: Run the checklist + +For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, ⚠️ single-source, ❓ UNVERIFIED (could not fetch). + +### Identity +- [ ] `id` exactly matches provider's API model identifier (case, dots, dashes, prefix for resellers) +- [ ] `releaseDate` matches launch announcement +- [ ] `deprecated: true` set if provider has announced retirement (or removed from active list) + +### Pricing (per 1M tokens, USD) +- [ ] `pricing.input` matches provider pricing page +- [ ] `pricing.output` matches provider pricing page +- [ ] `pricing.cachedInput` matches provider's documented cached/prompt-cache rate (or is correctly omitted if no caching offered) +- [ ] `pricing.updatedAt` is recent — warn if older than 60 days + +### Context & output limits +- [ ] `contextWindow` matches docs (in tokens) +- [ ] `capabilities.maxOutputTokens` matches documented output cap (or is correctly omitted if "no output limit") + +### Capabilities (each must be DOCUMENTED-AS-SUPPORTED **and** CONSUMED-BY-PROVIDER-CODE) +- [ ] `temperature` — provider accepts it for this model (reasoning-always-on models often reject) +- [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. +- [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs +- [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model +- [ ] `toolUsageControl` — provider supports `tool_choice` semantics +- [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU +- [ ] `deepResearch` — only on actual deep-research SKUs +- [ ] `memory: false` — only when the model genuinely cannot maintain conversation history + +### Flags +- [ ] `recommended: true` — at most one or two per provider; should be current flagship +- [ ] `speedOptimized: true` — only on smallest/fastest tier (nano / flash-lite / haiku class) + +### Hosting / billing +- [ ] If the model is under `openai`/`anthropic`/`google`, it is automatically in `getHostedModels()` → served with Sim's rotating key and billed via `shouldBillModelUsage()`. Confirm that is the intent (a BYOK-only model parked under one of these providers is a billing bug — warning). +- [ ] If the model is hosted, the deployment is expected to have its `{PREFIX}_COUNT` / `{PREFIX}_1..N` env vars set (ops concern; note if it looks unset for a model claiming hosted support). + +## Step 5: Report (mandatory format) + +For each model, emit a table with one row per checklist item. Every row that claims ✓ must have a URL. + +```markdown +### Validation — + +| Field | Repo | Live docs | Source URL | Status | +|---|---|---|---|---| +| `input` | $1.25/M | $1.25/M | https://docs.x.ai/... | ✓ | +| `cachedInput` | $0.50/M | $0.20/M | https://cloudprice.net/... | ✗ stale (price cut not picked up) | +| `reasoningEffort` | low/medium/high | rejected by API | https://docs.x.ai/.../reasoning | ✗ inert — selecting silently no-ops | +| `contextWindow` | 1,000,000 | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ (2 sources) | +| `releaseDate` | 2026-04-30 | not found in scraped pages | _attempted: docs.x.ai, x.ai/news_ | ❓ UNVERIFIED | + +**Findings** +- 🔴 critical — `cachedInput` is wrong: docs say $0.20/M, repo has $0.50/M +- 🟡 warning — `reasoningEffort` is set but provider rejects it for this model (xAI docs explicitly: "reasoning_effort is not supported by grok-4.3") +- 🔵 suggestion — `pricing.updatedAt` is 90 days old; refresh +- ❓ unverified — `releaseDate` could not be confirmed from any fetched page; ask user + +**Disagreements between sources** +- _none_ OR _OpenRouter says $X, provider docs say $Y — went with provider docs_ +``` + +End each multi-model run with a summary count: `N models checked · X critical · Y warnings · Z suggestions · W unverified`. + +## Step 6: Offer to fix + +After reporting, ask: *"Want me to fix the critical and warning items? I'll print the diff first."* On yes: + +1. Print the proposed diff (do not apply yet) +2. Get user confirmation +3. Edit `models.ts` in a single pass +4. Run `bun run lint` +5. Re-run only the failed rows of the checklist on the new state + +## Severity definitions + +- 🔴 **critical** — wrong number or wrong identifier that misleads users about cost or breaks API calls. Examples: incorrect pricing, wrong model id, wrong context window, capability the API rejects. +- 🟡 **warning** — dead code or internal inconsistency. Examples: capability flag the provider ignores, multiple `recommended: true` per provider, `pricing.updatedAt` >60 days old, missing `deprecated: true` on retired model. +- 🔵 **suggestion** — style/consistency. Examples: field order, missing `speedOptimized` on a clearly smallest-tier model. +- ❓ **unverified** — could not fetch an authoritative source for this field. Surface it; never silently confirm. + +## Common bugs this skill catches + +- Pricing drift after a provider price cut (very common — providers cut quarterly) +- `reasoningEffort` set on always-reasoning models that reject the parameter (grok-4.3, o3-pro pattern) +- `nativeStructuredOutputs` set on providers that don't consume the flag (dead) +- `thinking` set on non-Anthropic/non-Gemini providers +- `verbosity` set on non-gpt-5.x models +- Wrong context window (e.g., 128k claimed vs 200k actual) +- Stale `pricing.updatedAt` +- Multiple `recommended: true` per provider after a flagship swap +- Missing `deprecated: true` on retired models (e.g., the xAI batch retiring May 15, 2026) + +## What "I cannot verify this" looks like + +If, after fetching the documented sources, a field cannot be confirmed: + +- Mark the row ❓ UNVERIFIED with the URL(s) attempted +- Surface it in the **Findings** section with severity ❓ +- Do NOT mark the validation as passed +- Ask the user for a docs URL or guidance before changing anything + +The skill is allowed to say *"I could not verify the cached input price for grok-4.3 from the official xAI docs in this session — I attempted [URLs] without finding the value. Third-party sources [URL1, URL2] both report $0.20/M. Confirm before I update."* That is correct behavior. Hallucinating a number is not. diff --git a/.agents/skills/validate-model/agents/openai.yaml b/.agents/skills/validate-model/agents/openai.yaml new file mode 100644 index 00000000000..fa58d32ca8b --- /dev/null +++ b/.agents/skills/validate-model/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Validate Model" + short_description: "Audit model entries vs live docs" + brand_color: "#0891B2" + default_prompt: "Use $validate-model to audit Sim model entries against the provider's live API docs." diff --git a/.claude/commands/add-connector.md b/.claude/commands/add-connector.md index 22c8c52e1c8..81823675a72 100644 --- a/.claude/commands/add-connector.md +++ b/.claude/commands/add-connector.md @@ -463,6 +463,24 @@ const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS) If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path). +## Capped or Incomplete Listings — `syncContext.listingCapped` (REQUIRED) + +If `listDocuments` can ever return **less than the full source set** on a non-incremental sync — a `maxItems`/`maxDocuments`-style cap, or a transient per-item error that drops a still-existing document from the listing — it MUST set `syncContext.listingCapped = true` when that happens. + +The sync engine reconciles deletions by comparing the full listing against stored documents: anything not seen is **hard-deleted** (sync-engine.ts, gated on `!syncContext?.listingCapped`). A truncated listing without this flag deletes every real document beyond the cap. This was the single most common bug found when auditing connectors — do not omit it. + +```typescript +if (hitLimit && syncContext) { + syncContext.listingCapped = true +} +``` + +Rules: +- Set it when a user-configured cap truncates the listing while more documents exist +- Set it when a thrown error caused a still-present document to be skipped during listing +- Do NOT set it when the source is genuinely exhausted (deleted documents must still reconcile) +- Do NOT set it for intentional scope filters (e.g. a date cutoff) — out-of-scope documents should be reconciled normally + ## Sync Engine Behavior (Do Not Modify) The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It: @@ -515,6 +533,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - `dependsOn` references selector field IDs (not `canonicalParamId`) - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS` - [ ] `listDocuments` handles pagination with metadata-based content hashes +- [ ] `syncContext.listingCapped = true` set whenever the listing is truncated (max-items cap or transient per-item error) — required to prevent the engine's deletion reconciliation from removing unseen documents - [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch) - [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument` - [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative) diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md index 1fcf828537c..c52e1b451f9 100644 --- a/.claude/commands/add-model.md +++ b/.claude/commands/add-model.md @@ -20,7 +20,8 @@ You add a new model entry to `apps/sim/providers/models.ts`. **Every numeric and 2. Live-fetch official docs + pricing page + capability/parameter pages + at least one secondary source 3. Apply the Consumption Matrix to know which capability flags are real 4. Read 2-3 sibling entries in `models.ts` and match their pattern exactly -5. Insert the entry, run `bun run lint`, print the verification report +5. Check the repo-side touchpoints that are NOT data-driven (hosted-key billing, tests, provider code) +6. Insert the entry, run `bun run lint`, print the verification report ## Step 1: Live source-of-truth lookup @@ -103,7 +104,53 @@ Within a family, newest first (matches existing convention: GPT-5.5 above GPT-5. - If you're adding a new flagship, ask the user before removing `recommended` from the previous flagship. Never silently flip it. - `speedOptimized: true` only on the smallest/fastest tier (nano, flash-lite, haiku class). -## Step 4: Write, lint +## Step 4: Repo-side touchpoints beyond the entry + +Adding the `models.ts` entry is most of the job because nearly every consumer is **data-driven** and picks the model up automatically: the ~40 query helpers in `models.ts` / `providers/utils.ts`, the public `/models` catalog (`app/(landing)/models/utils.ts` iterates `PROVIDER_DEFINITIONS`), the agent-block model dropdown, and copilot's `isKnownModelId` / `suggestModelIdsForUnknownModel` validation. The touchpoints below are the exceptions — they are **not** data-driven, so check each one. + +### Hosted = auto-billed, by provider + +`getHostedModels()` in `apps/sim/providers/models.ts` returns **every** model under `openai`, `anthropic`, and `google`: + +```ts +export function getHostedModels(): string[] { + return [ + ...getProviderModels('openai'), + ...getProviderModels('anthropic'), + ...getProviderModels('google'), + ] +} +``` + +So a model added to any of those three providers is **automatically served with Sim's rotating hosted key and billed** to the workspace via `shouldBillModelUsage()` (`providers/utils.ts`). Before you insert: + +- **If the model should be BYOK-only / never-billed**, do NOT drop it under `openai`/`anthropic`/`google` as-is — that silently enrolls it in hosted billing. Confirm hosting/billing intent with the user. (Precedent: Ollama Cloud is a deliberately separate `isReseller` provider specifically to stay BYOK-only/never-billed.) +- **If the model should be hosted**, the deployment must actually have a key for it — the provider's `{PREFIX}_COUNT` / `{PREFIX}_1..N` env vars must be set, or hosted runs fail at execution time. +- State the hosted/billing status explicitly in the verification report. + +### Tests with hardcoded model IDs + +`bun run lint` does **not** run tests. A few tests assert specific model IDs and can break or need updating when you touch a hosted or flagship model: + +- `apps/sim/providers/utils.test.ts` — asserts membership of `getHostedModels()` / `shouldBillModelUsage()` +- `apps/sim/providers/index.test.ts` and serializer tests — reference concrete model IDs + +```bash +rg "|getHostedModels|shouldBillModelUsage" apps/sim/providers/*.test.ts +``` + +If anything matches, run the affected provider tests and update assertions as needed. + +### New API behavior is NOT data-driven + +The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. + +### Wrong family entirely? + +- **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. +- **Brand-new provider** (not just a new model under an existing one) → much larger surface: add the id to `ProviderId` in `providers/types.ts`, a registry entry in `providers/registry.ts`, a provider implementation under `providers//`, an icon in `components/icons.tsx`, and the `PROVIDER_DEFINITIONS` block. That is beyond this skill — tell the user. + +## Step 5: Write, lint ```bash bun run lint @@ -111,7 +158,7 @@ bun run lint Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. -## Step 5: Verification report (mandatory format) +## Step 6: Verification report (mandatory format) End with this exact structure: @@ -128,6 +175,7 @@ End with this exact structure: | `capabilities.temperature` | `{ min: 0, max: 1 }` | matches sibling entries | — pattern-match only | | `capabilities.reasoningEffort` | NOT SET | provider docs say API rejects it for this model | ✓ correctly omitted | | `releaseDate` | 2026-04-30 | https://docs.x.ai/... announcement | ✓ verified | +| hosted/billing | BYOK-only (xai not in `getHostedModels`) | `providers/models.ts` | — confirmed intent | **Disagreements** - _none_ OR _OpenRouter says X, provider docs say Y — used Y per provider rule_ @@ -156,4 +204,6 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) - ❌ Stamping `recommended: true` on the new model without removing it from the previous flagship +- ❌ Adding a BYOK-only model under `openai`/`anthropic`/`google` (silently enrolls it in hosted billing via `getHostedModels()`) +- ❌ Reporting "done" after only `bun run lint` when you touched a hosted (openai/anthropic/google) or flagship model with assertions in `providers/utils.test.ts` - ❌ Reporting "done" with any UNVERIFIED row in the table diff --git a/.claude/commands/validate-connector.md b/.claude/commands/validate-connector.md index adcbf61b12b..3aa5da34f93 100644 --- a/.claude/commands/validate-connector.md +++ b/.claude/commands/validate-connector.md @@ -135,6 +135,13 @@ For each API endpoint the connector calls: - [ ] No off-by-one errors in pagination tracking - [ ] The connector does NOT hit known API pagination limits silently (e.g., HubSpot search 10k cap) +### Deletion-Reconciliation Safety (`listingCapped`) — CRITICAL +The sync engine hard-deletes any stored document absent from a full listing. Audit every path where `listDocuments` can return less than the full source set: +- [ ] `syncContext.listingCapped = true` is set when a `maxItems`-style cap truncates the listing while more documents exist +- [ ] `listingCapped` is set when a transient per-item error drops a still-existing document from the listing +- [ ] `listingCapped` is NOT set when the source is genuinely exhausted (deleted documents must reconcile) or for intentional scope filters (date cutoffs) +This is the most common connector bug class — verify it explicitly against `sync-engine.ts`'s reconciliation gate. + ### Pagination State Across Pages - [ ] `syncContext` is used to cache state across pages (user names, field maps, instance URLs, portal IDs, etc.) - [ ] Cached state in `syncContext` is correctly initialized on first page and reused on subsequent pages diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md index 10c6aaa0b27..bf1d30745b6 100644 --- a/.claude/commands/validate-model.md +++ b/.claude/commands/validate-model.md @@ -34,7 +34,7 @@ Capture per model: `id`, full `pricing`, full `capabilities`, `contextWindow`, ` ## Step 2: Live-fetch authoritative sources -Use the canonical provider URL table in `add-model.md` (Step 1) as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. If you update one table, update the other in the same change. +Use the canonical provider URL table in the `add-model` skill (`.claude/commands/add-model.md`, or its mirror `.agents/skills/add-model/SKILL.md`), Step 1, as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. If you update one table, update the other in the same change. Secondary cross-check (use at least one): OpenRouter, Artificial Analysis, CloudPrice. @@ -98,6 +98,10 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `recommended: true` — at most one or two per provider; should be current flagship - [ ] `speedOptimized: true` — only on smallest/fastest tier (nano / flash-lite / haiku class) +### Hosting / billing +- [ ] If the model is under `openai`/`anthropic`/`google`, it is automatically in `getHostedModels()` → served with Sim's rotating key and billed via `shouldBillModelUsage()`. Confirm that is the intent (a BYOK-only model parked under one of these providers is a billing bug — warning). +- [ ] If the model is hosted, the deployment is expected to have its `{PREFIX}_COUNT` / `{PREFIX}_1..N` env vars set (ops concern; note if it looks unset for a model claiming hosted support). + ## Step 5: Report (mandatory format) For each model, emit a table with one row per checklist item. Every row that claims ✓ must have a URL. diff --git a/README.md b/README.md index 989452870fd..6a8508bcc3c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

-

The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.

+

The open-source AI workspace where teams build, deploy, and manage AI agents. Build conversationally, visually, or with code. Connect 1,000+ integrations and every major LLM to automate real work.

Sim.ai @@ -21,25 +21,39 @@ Ask DeepWiki Set Up with Cursor

-### Build Workflows with Ease -Design agent workflows visually on a canvas—connect agents, tools, and blocks, then run them instantly. +### Build everything in Mothership +Your AI command center. Describe what you want in plain language. Mothership knows your entire workspace and takes action: building agents, running them, querying data, and more.

- Workflow Builder Demo + Mothership building and running an agent from chat

-### Supercharge with Copilot -Leverage Copilot to generate nodes, fix errors, and iterate on flows directly from natural language. +### Create files and documents +Generate documents, reports, and presentations from a single prompt, grounded in your workspace data.

- Copilot Demo + Mothership generating a document from a prompt

-### Integrate Vector Databases -Upload documents to a vector store and let agents answer questions grounded in your specific content. +### Ground agents in your knowledge +Upload documents to a knowledge base and let agents answer questions from your own content.

- Knowledge Uploads and Retrieval Demo + Creating a knowledge base +

+ +### Structured data with Tables +A database, built in. Store, query, and wire structured data into agent runs. + +

+ Tables view with typed columns +

+ +### Build visually with Workflows +Prefer a canvas? Design agents block by block in the visual builder, and let Copilot generate blocks, wire variables, and fix errors from natural language. + +

+ Workflow builder demo

## Quickstart @@ -74,7 +88,7 @@ docker compose -f docker-compose.prod.yml up -d Open [http://localhost:3000](http://localhost:3000) -Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. +Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. ### Self-hosted: Manual Setup diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 2bb74df043e..d0645dc8046 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -510,6 +510,13 @@ figure[data-rehype-pretty-code-figure], max-width: 480px !important; } +/* Search dialog overlay + panel must cover the sticky navbar — both default to z-50, + and the navbar wins the tie by DOM order, leaving it unblurred above the overlay */ +.bg-fd-overlay, +[role="dialog"][data-state] { + z-index: 60 !important; +} + pre { font-size: 0.875rem; line-height: 1.7; diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index fcdab73224d..dce91bf9720 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2743,6 +2743,18 @@ export function ClerkIcon(props: SVGProps) { ) } +export function ClickHouseIcon(props: SVGProps) { + return ( + + + + + ) +} + export function MicrosoftIcon(props: SVGProps) { return ( @@ -3365,7 +3377,18 @@ export const OllamaIcon = (props: SVGProps) => ( xmlns='http://www.w3.org/2000/svg' > Ollama - + + +) +export const FalIcon = (props: SVGProps) => ( + + Fal + ) export function ShieldCheckIcon(props: SVGProps) { @@ -3982,16 +4005,16 @@ export function FireworksIcon(props: SVGProps) { return ( ) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index d22e1caf00f..7b4a0e3a336 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -31,6 +31,7 @@ import { CirclebackIcon, ClayIcon, ClerkIcon, + ClickHouseIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -243,6 +244,7 @@ export const blockTypeToIconMap: Record = { circleback: CirclebackIcon, clay: ClayIcon, clerk: ClerkIcon, + clickhouse: ClickHouseIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, diff --git a/apps/docs/content/docs/en/knowledgebase/connectors.mdx b/apps/docs/content/docs/en/knowledgebase/connectors.mdx index 88a62383027..2f9de16cfa2 100644 --- a/apps/docs/content/docs/en/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/en/knowledgebase/connectors.mdx @@ -14,21 +14,23 @@ Connectors continuously sync documents from external services into your knowledg Connect Source picker showing a searchable list of available connectors including Airtable, Asana, Confluence, Discord, Dropbox, Evernote, Fireflies, GitHub, and Gmail -Sim ships with 30 built-in connectors: +Sim ships with 49 built-in connectors: | Category | Connectors | |----------|-----------| -| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Google Calendar, Google Sheets | -| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint | -| **Documents** | Google Docs, WordPress, Webflow | -| **Development** | GitHub | -| **Communication** | Slack, Discord, Microsoft Teams, Reddit | +| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Google Calendar, Google Sheets, Google Forms, Typeform | +| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Amazon S3 | +| **Documents** | Google Docs, WordPress, Webflow, DocuSign | +| **Development** | GitHub, GitLab, Azure DevOps, Sentry | +| **Communication** | Slack, Discord, Microsoft Teams, Reddit, YouTube | | **Email** | Gmail, Outlook | | **CRM** | HubSpot, Salesforce | | **Support** | Intercom, ServiceNow, Zendesk | +| **Incident Management** | incident.io, Rootly | | **Data** | Airtable | | **Note-taking** | Evernote, Obsidian | -| **Meetings** | Fireflies | +| **Meetings** | Zoom, Gong, Grain, Granola, Fathom, Fireflies | +| **Recruiting** | Greenhouse, Ashby | ## Adding a Connector @@ -41,13 +43,18 @@ From inside a knowledge base, click **+ New connector** in the top right to open Most connectors use **OAuth** — select an existing credential from the dropdown or click **Connect new account** to authorize through the service. Tokens are refreshed automatically. -A few connectors use **API keys** instead: +Other connectors use **API keys** or **personal access tokens** instead. The setup modal tells you which credential each connector expects — for example: | Connector | Where to get the key | |-----------|---------------------| | **Evernote** | Developer Token (starts with `S=`) from your Evernote account settings | | **Obsidian** | Install the [Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, then copy the key from its settings | | **Fireflies** | Generate from the Integrations page in your Fireflies account | +| **Typeform** | Personal access token from your Typeform account settings | +| **Azure DevOps** | Personal access token with Wiki (Read), Work Items (Read), and Code (Read) scopes | +| **YouTube** | YouTube Data API key from the Google Cloud Console | +| **Amazon S3** | Secret Access Key (the Access Key ID, region, and bucket are entered as config fields) | +| **Sentry** | Auth token with `project:read` and `event:read` scopes | If you rotate an API key in the external service, update it in Sim as well — OAuth tokens refresh automatically, but API keys do not. @@ -63,6 +70,10 @@ Each connector has source-specific fields that control what gets synced. Example - **Notion** — sync an entire workspace, a specific database, or a single page tree - **GitHub** — specify a repository, branch, and optional file extension filter - **Confluence** — enter your Atlassian domain and optionally filter by space key or content type +- **Azure DevOps** — choose what to sync (wiki pages, work items, repository files, or all), with optional work item type/state filters, a custom WIQL query, and repository/branch/path filters +- **Amazon S3** — point at a bucket with an optional key prefix and a customizable file extension allowlist; S3-compatible stores (Cloudflare R2, MinIO) are supported via a custom endpoint +- **YouTube** — sync a channel (by `@handle` or ID) or playlist, with an optional published-after date filter and the option to exclude Shorts +- **Sentry** — filter issues by search query (e.g. `is:unresolved`), environment, and time window; self-hosted Sentry is supported via a custom host - **Obsidian** — provide your vault URL (`https://127.0.0.1:27124` by default) and optionally restrict to a folder path - **Fireflies** — optionally filter by host email or cap the number of transcripts synced @@ -188,5 +199,5 @@ You can add as many connectors as you need to a single knowledge base. Each mana { question: "What happens when I delete a connector?", answer: "The connector is removed and future syncs stop. You're given the option to also delete all documents that were synced by that connector. If you don't check that option, they stay in the knowledge base as-is." }, { question: "What does the Disabled status mean?", answer: "After 10 consecutive full-sync failures, the connector is automatically disabled to stop retrying. Reconnect the OAuth account or click Resume to re-enable it." }, { question: "Do metadata tags count against a limit?", answer: "Yes. Tag slots are shared across all documents in a knowledge base — 17 slots total. Multiple connectors draw from the same pool, so plan accordingly if several connectors each auto-populate tags." }, - { question: "Do I need to re-authenticate connectors?", answer: "OAuth connectors refresh tokens automatically. API key connectors (Evernote, Obsidian, Fireflies) need manual updates if you rotate the key in the external service." }, + { question: "Do I need to re-authenticate connectors?", answer: "OAuth connectors refresh tokens automatically. API key and personal access token connectors need manual updates if you rotate the credential in the external service." }, ]} /> diff --git a/apps/docs/content/docs/en/mothership/knowledge.mdx b/apps/docs/content/docs/en/mothership/knowledge.mdx index ab17e6e6a78..008c050b5c2 100644 --- a/apps/docs/content/docs/en/mothership/knowledge.mdx +++ b/apps/docs/content/docs/en/mothership/knowledge.mdx @@ -49,7 +49,7 @@ For knowledge bases that should stay current automatically, connectors sync cont Connectors are configured through the knowledge base settings, not through Mothership chat. Once connected, all synced content is immediately searchable by Mothership and by any Agent block with the knowledge base attached. -Sim ships with 30 built-in connectors, including Notion, Google Drive, Slack, GitHub, Confluence, HubSpot, Salesforce, Gmail, and more. +Sim ships with 49 built-in connectors, including Notion, Google Drive, Slack, GitHub, Confluence, HubSpot, Salesforce, Gmail, and more. Examples of what you can sync: diff --git a/apps/docs/content/docs/en/tools/clickhouse.mdx b/apps/docs/content/docs/en/tools/clickhouse.mdx new file mode 100644 index 00000000000..f3c9837525b --- /dev/null +++ b/apps/docs/content/docs/en/tools/clickhouse.mdx @@ -0,0 +1,559 @@ +--- +title: ClickHouse +description: Connect to a ClickHouse database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[ClickHouse](https://clickhouse.com) is an open-source, column-oriented database management system for online analytical processing (OLAP). It is built for speed at scale — running aggregations and analytical queries over billions of rows in real time. + +The ClickHouse block connects to any ClickHouse deployment (ClickHouse Cloud or self-hosted) over the [HTTP interface](https://clickhouse.com/docs/interfaces/http). Use it to run analytical queries, stream rows into tables, manage schemas, inspect system state, and execute arbitrary SQL — all from within a workflow. + +**Connection details** + +- **Host** — your ClickHouse hostname (e.g. `your-instance.clickhouse.cloud` or your server address). +- **Port** — the HTTP interface port. Use `8443` for HTTPS (ClickHouse Cloud) or `8123` for plain HTTP (self-hosted). +- **Database** / **Username** — default to `default` if not specified. +- **Password** — optional for unauthenticated local instances. +- **Use HTTPS** — keep enabled for any remote or Cloud instance. + +**Things to know** + +- `UPDATE` and `DELETE` are implemented as ClickHouse [mutations](https://clickhouse.com/docs/sql-reference/statements/alter/update) (`ALTER TABLE ... UPDATE/DELETE`). Mutations run **asynchronously** in the background, so the affected row count is not returned immediately. +- ClickHouse is optimized for bulk inserts. Prefer batching many rows per insert over many single-row inserts. +- The connection host is validated to block private/internal addresses, so the block cannot reach `localhost` or internal-only hosts. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate ClickHouse into the workflow. Query and insert data, manage databases and tables, inspect schemas, monitor mutations and running queries, manage partitions, and execute raw SQL over the ClickHouse HTTP interface. + + + +## Tools + +### `clickhouse_query` + +Execute a SELECT query on a ClickHouse database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `query` | string | Yes | SQL SELECT query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows returned | + +### `clickhouse_execute` + +Execute raw SQL (DDL, mutations, or queries) on a ClickHouse database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `query` | string | Yes | Raw SQL statement to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the statement | +| `rowCount` | number | Number of rows returned or affected | + +### `clickhouse_insert` + +Insert a row into a ClickHouse table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `table` | string | Yes | Table name to insert data into | +| `data` | object | Yes | Data object to insert \(key-value pairs mapping column names to values\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Inserted rows \(empty for ClickHouse inserts\) | +| `rowCount` | number | Number of rows inserted | + +### `clickhouse_insert_rows` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_update` + +Update rows in a ClickHouse table via an ALTER TABLE ... UPDATE mutation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `table` | string | Yes | Table name to update data in | +| `data` | object | Yes | Data object with fields to update \(key-value pairs\) | +| `where` | string | Yes | WHERE clause condition \(without the WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Updated rows \(empty for ClickHouse mutations\) | +| `rowCount` | number | Number of rows written by the mutation | + +### `clickhouse_delete` + +Delete rows from a ClickHouse table via an ALTER TABLE ... DELETE mutation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `table` | string | Yes | Table name to delete data from | +| `where` | string | Yes | WHERE clause condition \(without the WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Deleted rows \(empty for ClickHouse mutations\) | +| `rowCount` | number | Number of rows affected by the mutation | + +### `clickhouse_list_databases` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_tables` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_describe_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_show_create_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_count_rows` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_introspect` + +Introspect a ClickHouse database to retrieve table structures, columns, and engines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to introspect | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `tables` | array | Array of table schemas with columns and engines | +| ↳ `name` | string | Table name | +| ↳ `database` | string | Database the table belongs to | +| ↳ `engine` | string | Table engine \(e.g., MergeTree, Log\) | +| ↳ `totalRows` | number | Approximate total number of rows in the table | +| ↳ `columns` | array | Table columns | +| ↳ `name` | string | Column name | +| ↳ `type` | string | ClickHouse data type \(e.g., UInt32, String, DateTime\) | +| ↳ `defaultKind` | string | Kind of default expression \(DEFAULT, MATERIALIZED, ALIAS\) | +| ↳ `defaultExpression` | string | Default value expression for the column | +| ↳ `isInPrimaryKey` | boolean | Whether the column is part of the primary key | +| ↳ `isInSortingKey` | boolean | Whether the column is part of the sorting key | + +### `clickhouse_create_database` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_drop_database` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_create_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_drop_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_truncate_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_rename_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_optimize_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_partitions` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_drop_partition` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_mutations` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_running_queries` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_kill_query` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_table_stats` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_clusters` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + + diff --git a/apps/docs/content/docs/en/tools/dagster.mdx b/apps/docs/content/docs/en/tools/dagster.mdx index b82c1a7f4ab..ef78e695e6e 100644 --- a/apps/docs/content/docs/en/tools/dagster.mdx +++ b/apps/docs/content/docs/en/tools/dagster.mdx @@ -73,8 +73,15 @@ Get the status and details of a Dagster run by its ID. | `runId` | string | Run ID | | `jobName` | string | Name of the job this run belongs to | | `status` | string | Run status \(QUEUED, NOT_STARTED, STARTING, MANAGED, STARTED, SUCCESS, FAILURE, CANCELING, CANCELED\) | +| `mode` | string | Execution mode of the run | | `startTime` | number | Run start time as Unix timestamp | | `endTime` | number | Run end time as Unix timestamp | +| `creationTime` | number | Time the run was created as Unix timestamp | +| `updateTime` | number | Time the run was last updated as Unix timestamp | +| `parentRunId` | string | ID of the immediate parent run \(for re-executions\) | +| `rootRunId` | string | ID of the root run in the re-execution group | +| `canTerminate` | boolean | Whether the run can currently be terminated | +| `assetSelection` | json | Asset keys targeted by the run, as slash-joined strings | | `runConfigYaml` | string | Run configuration as YAML | | `tags` | json | Run tags as array of \{key, value\} objects | @@ -108,7 +115,7 @@ Fetch execution event logs for a Dagster run. ### `dagster_list_runs` -List recent Dagster runs, optionally filtered by job name. +List Dagster runs with optional filters by job name, status, and creation-time range, plus cursor pagination. #### Input @@ -118,6 +125,9 @@ List recent Dagster runs, optionally filtered by job name. | `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | | `jobName` | string | No | Filter runs by job name \(optional\) | | `statuses` | string | No | Comma-separated run statuses to filter by, e.g. "SUCCESS,FAILURE" \(optional\) | +| `createdAfter` | number | No | Only return runs created at or after this Unix timestamp in seconds \(optional\) | +| `createdBefore` | number | No | Only return runs created at or before this Unix timestamp in seconds \(optional\) | +| `cursor` | string | No | Run ID to page after, from a previous response cursor \(optional\) | | `limit` | number | No | Maximum number of runs to return \(default 20\) | #### Output @@ -131,6 +141,8 @@ List recent Dagster runs, optionally filtered by job name. | ↳ `tags` | json | Run tags as array of \{key, value\} objects | | ↳ `startTime` | number | Start time as Unix timestamp | | ↳ `endTime` | number | End time as Unix timestamp | +| `cursor` | string | Run ID of the last returned run — pass as cursor to fetch the next page | +| `hasMore` | boolean | Whether more runs are likely available beyond this page | ### `dagster_list_jobs` @@ -295,7 +307,7 @@ List all sensors in a Dagster repository, optionally filtered by status. | --------- | ---- | ----------- | | `sensors` | json | Array of sensors \(name, sensorType, status, id, description\) | | ↳ `name` | string | Sensor name | -| ↳ `sensorType` | string | Sensor type \(ASSET, AUTO_MATERIALIZE, FRESHNESS_POLICY, MULTI_ASSET, RUN_STATUS, STANDARD\) | +| ↳ `sensorType` | string | Sensor type \(ASSET, AUTO_MATERIALIZE, FRESHNESS_POLICY, MULTI_ASSET, RUN_STATUS, STANDARD, UNKNOWN\) | | ↳ `status` | string | Sensor status: RUNNING or STOPPED | | ↳ `id` | string | Instigator state ID — use this to start or stop the sensor | | ↳ `description` | string | Human-readable sensor description | @@ -340,4 +352,120 @@ Disable (stop) a running sensor in Dagster. | `id` | string | Instigator state ID of the sensor | | `status` | string | Updated sensor status \(RUNNING or STOPPED\) | +### `dagster_list_assets` + +List assets tracked by a Dagster instance, optionally filtered by key prefix. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `prefix` | string | No | Slash-delimited asset key prefix to filter by, e.g. "raw" or "raw/events" \(optional\) | +| `cursor` | string | No | Asset key cursor from a previous response, for pagination \(optional\) | +| `limit` | number | No | Maximum number of assets to return \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assets` | json | Array of assets \(assetKey, path\) | +| ↳ `assetKey` | string | Slash-joined asset key | +| ↳ `path` | json | Asset key path segments | +| `cursor` | string | Cursor to pass on the next call to fetch more assets | +| `hasMore` | boolean | Whether more assets are likely available beyond this page | + +### `dagster_get_asset` + +Get an asset definition and its latest materialization by asset key. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `assetKey` | string | Yes | Slash-delimited asset key, e.g. "my_asset" or "raw/events" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assetKey` | string | Slash-joined asset key | +| `path` | json | Asset key path segments | +| `groupName` | string | Asset group the definition belongs to | +| `description` | string | Asset description | +| `jobNames` | json | Names of jobs that can materialize this asset | +| `computeKind` | string | Compute kind tag \(e.g., python, dbt, spark\) | +| `isPartitioned` | boolean | Whether the asset is partitioned | +| `latestMaterialization` | json | Most recent materialization \(runId, timestamp, partition, stepKey\) | +| ↳ `runId` | string | Run that produced the materialization | +| ↳ `timestamp` | string | Materialization timestamp \(epoch ms string\) | +| ↳ `partition` | string | Partition key, if partitioned | +| ↳ `stepKey` | string | Step key that emitted it | + +### `dagster_materialize_assets` + +Materialize selected assets by launching their asset job with an asset selection. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `repositoryLocationName` | string | Yes | Repository location \(code location\) name | +| `repositoryName` | string | Yes | Repository name within the code location | +| `jobName` | string | Yes | Asset job that contains the assets, e.g. "__ASSET_JOB" or a named asset job | +| `assetSelection` | string | Yes | Comma- or newline-separated asset keys to materialize, each slash-delimited \(e.g. "raw/events, summary"\) | +| `tags` | string | No | Tags as a JSON array of \{key, value\} objects \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | string | The globally unique ID of the launched materialization run | + +### `dagster_report_asset_materialization` + +Report an external (runless) materialization or observation for an asset. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `assetKey` | string | Yes | Slash-delimited asset key to report against, e.g. "my_asset" or "raw/events" | +| `eventType` | string | No | Event type to report: ASSET_MATERIALIZATION \(default\) or ASSET_OBSERVATION | +| `partitionKeys` | string | No | Comma-separated partition keys to report against \(optional\) | +| `description` | string | No | Human-readable description for the reported event \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the event was reported successfully | +| `assetKey` | string | Slash-joined asset key the event was reported against | + +### `dagster_wipe_asset` + +DESTRUCTIVE: permanently wipes ALL materialization history (every partition) for an asset. This cannot be undone. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `assetKey` | string | Yes | Slash-delimited asset key to wipe, e.g. "my_asset" or "raw/events" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the asset was wiped successfully | +| `assetKey` | string | Slash-joined asset key that was wiped | + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index a17c92d28e7..84dac39482d 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -27,6 +27,7 @@ "circleback", "clay", "clerk", + "clickhouse", "cloudflare", "cloudformation", "cloudwatch", diff --git a/apps/docs/content/docs/en/tools/tinybird.mdx b/apps/docs/content/docs/en/tools/tinybird.mdx index 0c3d74a9341..f67ac0c2ff0 100644 --- a/apps/docs/content/docs/en/tools/tinybird.mdx +++ b/apps/docs/content/docs/en/tools/tinybird.mdx @@ -1,6 +1,6 @@ --- title: Tinybird -description: Send events and query data with Tinybird +description: Send events, query data, and manage Data Sources with Tinybird --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -30,7 +30,7 @@ Connect Tinybird to your workflows today to accelerate data-driven features, aut ## Usage Instructions -Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources. +Interact with Tinybird: stream JSON or NDJSON events with the Events API, run SQL with the Query API, call published Pipe API Endpoints by name with dynamic parameters, and manage Data Sources by appending from a URL, truncating, or deleting rows by condition. @@ -77,7 +77,110 @@ Execute SQL queries against Tinybird Pipes and Data Sources using the Query API. | Parameter | Type | Description | | --------- | ---- | ----------- | | `data` | json | Query result data. For FORMAT JSON: array of objects. For other formats \(CSV, TSV, etc.\): raw text string. | +| `meta` | array | Column metadata for the result set \(only available with FORMAT JSON\) | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Column data type | | `rows` | number | Number of rows returned \(only available with FORMAT JSON\) | +| `rows_before_limit_at_least` | number | Minimum number of rows there would be without a LIMIT clause \(only available with FORMAT JSON\) | | `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read \(only available with FORMAT JSON\) | +### `tinybird_query_pipe` + +Call a published Tinybird Pipe API Endpoint by name, passing dynamic parameters and receiving structured JSON results. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `pipe` | string | Yes | Name of the published Pipe API Endpoint to call. Example: "top_pages" | +| `parameters` | json | No | Dynamic Pipe parameters as a JSON object, sent as query-string arguments. Example: \{"start_date": "2024-01-01", "limit": 10\} | +| `q` | string | No | Optional SQL to run on top of the Pipe result. Use "_" to reference the Pipe. Example: "SELECT count\(\) FROM _" | +| `token` | string | Yes | Tinybird API Token with PIPE:READ scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `data` | json | Pipe result data as an array of row objects | +| `meta` | array | Column metadata for the result set | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Column data type | +| `rows` | number | Number of rows returned | +| `rows_before_limit_at_least` | number | Minimum number of rows there would be without a LIMIT clause | +| `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read | +| ↳ `elapsed` | number | Query execution time in seconds | +| ↳ `rows_read` | number | Number of rows processed | +| ↳ `bytes_read` | number | Number of bytes processed | + +### `tinybird_append_datasource` + +Append data to a Tinybird Data Source from a remote file URL (CSV, NDJSON, Parquet). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `datasource` | string | Yes | Name of the existing Data Source to append to. Example: "events_raw" | +| `url` | string | Yes | Publicly accessible URL of the file to append. Example: "https://example.com/data.csv" | +| `format` | string | No | Format of the source file: "csv" \(default\), "ndjson", or "parquet" | +| `token` | string | Yes | Tinybird API Token with DATASOURCES:CREATE scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Identifier of the append operation | +| `import_id` | string | Import identifier for the append job | +| `job_id` | string | Job identifier used to poll import status | +| `job_url` | string | URL to query the import job status | +| `status` | string | Initial job status \(e.g., "waiting"\) | +| `job` | json | Full import job details \(kind, id, status, created_at, datasource, ...\) | +| `datasource` | json | Target Data Source metadata \(id, name, ...\) | + +### `tinybird_truncate_datasource` + +Delete all rows from a Tinybird Data Source. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `datasource` | string | Yes | Name of the Data Source to truncate. Example: "events_raw" | +| `token` | string | Yes | Tinybird API Token with DATASOURCES:CREATE scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `truncated` | boolean | Whether the Data Source was truncated successfully | +| `result` | json | Raw response body from the truncate endpoint, if any | + +### `tinybird_delete_datasource_rows` + +Delete rows from a Tinybird Data Source matching a SQL condition. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `datasource` | string | Yes | Name of the Data Source to delete rows from. Example: "events_raw" | +| `delete_condition` | string | Yes | SQL WHERE-clause condition selecting the rows to delete. Example: "country = \'ES\'" or "event_date < \'2024-01-01\'" | +| `dry_run` | boolean | No | When true, returns how many rows would be deleted without deleting them. Defaults to false. | +| `token` | string | Yes | Tinybird API Token with DATASOURCES:CREATE scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Identifier of the delete operation | +| `job_id` | string | Job identifier used to poll delete status | +| `delete_id` | string | Deletion identifier | +| `job_url` | string | URL to query the delete job status | +| `status` | string | Current job status \(e.g., "waiting", "done"\) | +| `job` | json | Full delete job details \(kind, id, status, delete_condition, rows_affected, ...\) | + diff --git a/apps/docs/content/docs/en/triggers/table.mdx b/apps/docs/content/docs/en/triggers/table.mdx index 1a4a7139987..beb0826cabb 100644 --- a/apps/docs/content/docs/en/triggers/table.mdx +++ b/apps/docs/content/docs/en/triggers/table.mdx @@ -38,7 +38,6 @@ Triggers when rows are inserted or updated in a table | `changedColumns` | json | List of column names that changed \(empty for inserts\) | | `rowId` | string | The unique row ID | | `headers` | json | Column names from the table schema | -| `rowNumber` | number | The position of the row in the table | | `tableId` | string | The table ID | | `tableName` | string | The table name | | `timestamp` | string | Event timestamp in ISO format | diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ef0582c6f41..5f5ca00b33e 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -31,6 +31,7 @@ import { CirclebackIcon, ClayIcon, ClerkIcon, + ClickHouseIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -242,6 +243,7 @@ export const blockTypeToIconMap: Record = { circleback: CirclebackIcon, clay: ClayIcon, clerk: ClerkIcon, + clickhouse: ClickHouseIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index f371f03ebe5..65df1c17364 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -2492,6 +2492,129 @@ "integrationTypes": ["security", "developer-tools"], "tags": ["identity", "automation"] }, + { + "type": "clickhouse", + "slug": "clickhouse", + "name": "ClickHouse", + "description": "Connect to a ClickHouse database", + "longDescription": "Integrate ClickHouse into the workflow. Query and insert data, manage databases and tables, inspect schemas, monitor mutations and running queries, manage partitions, and execute raw SQL over the ClickHouse HTTP interface.", + "bgColor": "#f9ff69", + "iconName": "ClickHouseIcon", + "docsUrl": "https://docs.sim.ai/tools/clickhouse", + "operations": [ + { + "name": "Query (SELECT)", + "description": "Execute a SELECT query on a ClickHouse database" + }, + { + "name": "Execute Raw SQL", + "description": "Execute raw SQL (DDL, mutations, or queries) on a ClickHouse database" + }, + { + "name": "Insert Row", + "description": "Insert a row into a ClickHouse table" + }, + { + "name": "Insert Rows (Bulk)", + "description": "Insert multiple rows into a ClickHouse table" + }, + { + "name": "Update Data", + "description": "Update rows in a ClickHouse table via an ALTER TABLE ... UPDATE mutation" + }, + { + "name": "Delete Data", + "description": "Delete rows from a ClickHouse table via an ALTER TABLE ... DELETE mutation" + }, + { + "name": "List Databases", + "description": "List all databases on a ClickHouse server" + }, + { + "name": "List Tables", + "description": "List tables in the connected ClickHouse database" + }, + { + "name": "Describe Table", + "description": "Describe the columns of a ClickHouse table" + }, + { + "name": "Show Create Table", + "description": "Get the CREATE TABLE statement (DDL) for a ClickHouse table" + }, + { + "name": "Count Rows", + "description": "Count rows in a ClickHouse table, optionally filtered" + }, + { + "name": "Introspect Schema", + "description": "Introspect a ClickHouse database to retrieve table structures, columns, and engines" + }, + { + "name": "Create Database", + "description": "Create a new database on a ClickHouse server" + }, + { + "name": "Drop Database", + "description": "Drop a database from a ClickHouse server" + }, + { + "name": "Create Table", + "description": "Create a new MergeTree-family table in ClickHouse" + }, + { + "name": "Drop Table", + "description": "Drop a table from a ClickHouse database" + }, + { + "name": "Truncate Table", + "description": "Remove all rows from a ClickHouse table" + }, + { + "name": "Rename Table", + "description": "Rename a ClickHouse table" + }, + { + "name": "Optimize Table", + "description": "Trigger a merge of table parts via OPTIMIZE TABLE" + }, + { + "name": "List Partitions", + "description": "List active partitions for a ClickHouse table" + }, + { + "name": "Drop Partition", + "description": "Drop a partition from a ClickHouse table" + }, + { + "name": "List Mutations", + "description": "List mutations (async ALTER UPDATE/DELETE) for the connected database" + }, + { + "name": "List Running Queries", + "description": "List currently running queries on a ClickHouse server" + }, + { + "name": "Kill Query", + "description": "Kill a running query by its query ID" + }, + { + "name": "Table Stats", + "description": "Get row counts and on-disk size for tables in the connected database" + }, + { + "name": "List Clusters", + "description": "List configured clusters, shards, and replicas" + } + ], + "operationCount": 26, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["databases", "analytics"], + "tags": ["data-warehouse", "data-analytics"] + }, { "type": "cloudflare", "slug": "cloudflare", @@ -3110,7 +3233,7 @@ }, { "name": "List Runs", - "description": "List recent Dagster runs, optionally filtered by job name." + "description": "List Dagster runs with optional filters by job name, status, and creation-time range, plus cursor pagination." }, { "name": "List Jobs", @@ -3151,9 +3274,29 @@ { "name": "Stop Sensor", "description": "Disable (stop) a running sensor in Dagster." + }, + { + "name": "List Assets", + "description": "List assets tracked by a Dagster instance, optionally filtered by key prefix." + }, + { + "name": "Get Asset", + "description": "Get an asset definition and its latest materialization by asset key." + }, + { + "name": "Materialize Assets", + "description": "Materialize selected assets by launching their asset job with an asset selection." + }, + { + "name": "Report Asset Materialization", + "description": "Report an external (runless) materialization or observation for an asset." + }, + { + "name": "Wipe Asset", + "description": "DESTRUCTIVE: permanently wipes ALL materialization history (every partition) for an asset. This cannot be undone." } ], - "operationCount": 14, + "operationCount": 19, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -14219,11 +14362,11 @@ "type": "tinybird", "slug": "tinybird", "name": "Tinybird", - "description": "Send events and query data with Tinybird", - "longDescription": "Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.", + "description": "Send events, query data, and manage Data Sources with Tinybird", + "longDescription": "Interact with Tinybird: stream JSON or NDJSON events with the Events API, run SQL with the Query API, call published Pipe API Endpoints by name with dynamic parameters, and manage Data Sources by appending from a URL, truncating, or deleting rows by condition.", "bgColor": "#2EF598", "iconName": "TinybirdIcon", - "docsUrl": "https://www.tinybird.co/docs/api-reference", + "docsUrl": "https://docs.sim.ai/tools/tinybird", "operations": [ { "name": "Send Events", @@ -14232,9 +14375,25 @@ { "name": "Query", "description": "Execute SQL queries against Tinybird Pipes and Data Sources using the Query API." + }, + { + "name": "Query Pipe Endpoint", + "description": "Call a published Tinybird Pipe API Endpoint by name, passing dynamic parameters and receiving structured JSON results." + }, + { + "name": "Append Data Source (from URL)", + "description": "Append data to a Tinybird Data Source from a remote file URL (CSV, NDJSON, Parquet)." + }, + { + "name": "Truncate Data Source", + "description": "Delete all rows from a Tinybird Data Source." + }, + { + "name": "Delete Data Source Rows", + "description": "Delete rows from a Tinybird Data Source matching a SQL condition." } ], - "operationCount": 2, + "operationCount": 6, "triggers": [], "triggerCount": 0, "authType": "none", diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 52c9420916c..99c395d644b 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,5 +1,5 @@ import { asyncJobs, db } from '@sim/db' -import { workflowExecutionLogs } from '@sim/db/schema' +import { userTableDefinitions, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, lt, sql } from 'drizzle-orm' @@ -110,6 +110,37 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + // Mark stale table imports as failed. Imports run detached on the web container and + // are lost if the pod is killed mid-load. `updatedAt` is bumped by progress updates, so + // an `importing` table with no recent update has stalled (not merely slow). Rows are + // left in place (no rollback); the user re-imports. + let staleImportsMarkedFailed = 0 + try { + const staleImports = await db + .update(userTableDefinitions) + .set({ + importStatus: 'failed', + importError: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, + updatedAt: new Date(), + }) + .where( + and( + eq(userTableDefinitions.importStatus, 'importing'), + lt(userTableDefinitions.updatedAt, staleThreshold) + ) + ) + .returning({ id: userTableDefinitions.id }) + + staleImportsMarkedFailed = staleImports.length + if (staleImportsMarkedFailed > 0) { + logger.info(`Marked ${staleImportsMarkedFailed} stale table imports as failed`) + } + } catch (error) { + logger.error('Failed to clean up stale table imports:', { + error: toError(error).message, + }) + } + // Clean up stale pending jobs (never started, e.g., due to server crash before startJob()) let stalePendingJobsMarkedFailed = 0 @@ -179,6 +210,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { staleThresholdMinutes: STALE_THRESHOLD_MINUTES, retentionHours: JOB_RETENTION_HOURS, }, + tableImports: { + staleMarkedFailed: staleImportsMarkedFailed, + }, }) } catch (error) { logger.error('Error in stale execution cleanup job:', error) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 197a1722b1b..5b9f960896a 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -116,6 +116,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ...(validated.inputMappings !== undefined ? { inputMappings: validated.inputMappings } : {}), + ...(validated.deploymentMode !== undefined + ? { deploymentMode: validated.deploymentMode } + : {}), ...(validated.type !== undefined ? { type: validated.type } : {}), ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), }, diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts new file mode 100644 index 00000000000..18fa93aca80 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockMarkTableImporting, mockRunTableImport } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableImporting: vi.fn(), + mockRunTableImport: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('import-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ markTableImporting: mockMarkTableImporting })) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { POST } from '@/app/api/table/[tableId]/import-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 0, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { + workspaceId: 'workspace-1', + fileKey: 'workspace/workspace-1/123-data.csv', + fileName: 'data.csv', + mode: 'append', +} + +describe('POST /api/table/[tableId]/import-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableImporting.mockResolvedValue(true) + mockRunTableImport.mockResolvedValue(undefined) + }) + + it('marks the table importing and kicks off the worker with mode + mapping', async () => { + const response = await makeRequest({ + ...validBody, + mode: 'replace', + mapping: { Name: 'name' }, + createColumns: ['Extra'], + }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', importId: 'import-id-xyz' }) + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockRunTableImport).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'tbl_1', + mode: 'replace', + delimiter: ',', + mapping: { Name: 'name' }, + createColumns: ['Extra'], + }) + ) + }) + + it('returns 409 when the table is already importing (claim lost)', async () => { + mockMarkTableImporting.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableImport).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableImporting).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableImport).not.toHaveBeenCalled() + }) + + it('returns 400 when the target table is archived', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ archivedAt: new Date() }) }) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockRunTableImport).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) + + it('returns 400 for an invalid mode', async () => { + const response = await makeRequest({ ...validBody, mode: 'bogus' }) + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts new file mode 100644 index 00000000000..46190cbfb06 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableImport } from '@/lib/table/import-runner' +import { markTableImporting } from '@/lib/table/service' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableImportIntoAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const userId = authResult.userId + + const parsed = await parseRequest(importIntoTableAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, fileKey, fileName, mode, mapping, createColumns } = parsed.data.body + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + const { table } = access + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a + // caller can't import another workspace's uploaded object. + if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { + return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 }) + } + if (table.archivedAt) { + return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) + } + + const ext = fileName.split('.').pop()?.toLowerCase() + if (ext !== 'csv' && ext !== 'tsv') { + return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) + } + const delimiter = ext === 'tsv' ? '\t' : ',' + + // Atomically claim the table — the single concurrency gate. If another import already holds it, + // this returns false (no overlapping workers writing colliding row positions). + const importId = generateId() + const claimed = await markTableImporting(tableId, importId) + if (!claimed) { + return NextResponse.json( + { error: 'An import is already in progress for this table' }, + { status: 409 } + ) + } + + runDetached('table-import', () => + runTableImport({ + importId, + tableId, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode, + mapping, + createColumns, + }) + ) + + logger.info(`[${requestId}] Async CSV import into existing table started`, { + tableId, + importId, + mode, + fileName, + }) + return NextResponse.json({ success: true, data: { tableId, importId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts b/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts new file mode 100644 index 00000000000..d45baae77e2 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockMarkImportCanceled, mockAppendTableEvent } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkImportCanceled: vi.fn(), + mockAppendTableEvent: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ markImportCanceled: mockMarkImportCanceled })) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { POST } from '@/app/api/table/[tableId]/import/cancel/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 0, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import/cancel`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { workspaceId: 'workspace-1', importId: 'import-id-xyz' } + +describe('POST /api/table/[tableId]/import/cancel', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkImportCanceled.mockResolvedValue(true) + }) + + it('cancels the import and emits a canceled event', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ canceled: true }) + expect(mockMarkImportCanceled).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'import', status: 'canceled', importId: 'import-id-xyz' }) + ) + }) + + it('does not emit an event when nothing was importing', async () => { + mockMarkImportCanceled.mockResolvedValue(false) + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ canceled: false }) + expect(mockAppendTableEvent).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkImportCanceled).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + }) + + it('returns 400 on workspace mismatch', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ workspaceId: 'other-ws' }) }) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockMarkImportCanceled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts b/apps/sim/app/api/table/[tableId]/import/cancel/route.ts new file mode 100644 index 00000000000..62ab7310f47 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/import/cancel/route.ts @@ -0,0 +1,54 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { cancelTableImportContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { appendTableEvent } from '@/lib/table/events' +import { markImportCanceled } from '@/lib/table/service' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableImportCancelAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/import/cancel + * + * Cancels an in-flight async CSV import. Flips the table's import status to `canceled`, which makes + * the detached worker's next ownership check fail so it stops inserting. Committed rows are left in + * place (no rollback) — the user can delete the table. No-op if the import already finished. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(cancelTableImportContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, importId } = parsed.data.body + + const access = await checkAccess(tableId, authResult.userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const canceled = await markImportCanceled(tableId, importId) + if (canceled) { + void appendTableEvent({ kind: 'import', tableId, importId, status: 'canceled' }) + } + logger.info(`[${requestId}] Import cancel requested`, { tableId, importId, canceled }) + + return NextResponse.json({ success: true, data: { canceled } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index 1a551745402..438f74e035e 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -8,16 +8,18 @@ import type { TableDefinition } from '@/lib/table' const { mockCheckAccess, - mockBatchInsertRowsWithTx, - mockReplaceTableRowsWithTx, - mockAddTableColumnsWithTx, + mockImportAppendRows, + mockImportReplaceRows, mockDispatchAfterBatchInsert, + mockMarkTableImporting, + mockReleaseImportClaim, } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), - mockBatchInsertRowsWithTx: vi.fn(), - mockReplaceTableRowsWithTx: vi.fn(), - mockAddTableColumnsWithTx: vi.fn(), + mockImportAppendRows: vi.fn(), + mockImportReplaceRows: vi.fn(), mockDispatchAfterBatchInsert: vi.fn(), + mockMarkTableImporting: vi.fn(), + mockReleaseImportClaim: vi.fn(), })) vi.mock('@sim/utils/id', () => ({ @@ -33,20 +35,28 @@ vi.mock('@/app/api/table/utils', async () => { const message = result.status === 404 ? 'Table not found' : 'Access denied' return NextResponse.json({ error: message }, { status: result.status }) }, + csvProxyBodyCapResponse: () => null, + multipartErrorResponse: (error: { code: string; message: string }) => + NextResponse.json( + { error: error.message }, + { status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 } + ), } }) /** - * The route imports `batchInsertRows` and `replaceTableRows` from the barrel, - * which forwards them from `./service`. Mocking the service module replaces - * both without having to touch the other real helpers (`parseCsvBuffer`, - * `coerceRowsForTable`, etc.) exported through the barrel. + * The route imports `importAppendRows` / `importReplaceRows` from the barrel, + * which forwards them from `./service`. These functions own the import + * transaction (column adds + row writes); mocking the service module replaces + * them without touching the other real helpers (`coerceRowsForTable`, + * `createCsvParser`, etc.) exported through the barrel. */ vi.mock('@/lib/table/service', () => ({ - batchInsertRowsWithTx: mockBatchInsertRowsWithTx, - replaceTableRowsWithTx: mockReplaceTableRowsWithTx, - addTableColumnsWithTx: mockAddTableColumnsWithTx, + importAppendRows: mockImportAppendRows, + importReplaceRows: mockImportReplaceRows, dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, + markTableImporting: mockMarkTableImporting, + releaseImportClaim: mockReleaseImportClaim, })) import { POST } from '@/app/api/table/[tableId]/import/route' @@ -64,8 +74,8 @@ function createFormData( createColumns?: unknown } ): FormData { + // Text fields must precede the file part for the streaming parser. const form = new FormData() - form.append('file', file) if (options?.workspaceId !== null) { form.append('workspaceId', options?.workspaceId ?? 'workspace-1') } @@ -86,6 +96,7 @@ function createFormData( : JSON.stringify(options.createColumns) ) } + form.append('file', file) return form } @@ -112,10 +123,21 @@ function buildTable(overrides: Partial = {}): TableDefinition { } } +/** Additions array the route passed to importAppendRows (2nd positional arg). */ +function appendAdditions(): { name: string; type: string }[] { + return mockImportAppendRows.mock.calls[0][1] as { name: string; type: string }[] +} + +/** Rows array the route passed to importAppendRows (3rd positional arg). */ +function appendRows(): unknown[] { + return mockImportAppendRows.mock.calls[0][2] as unknown[] +} + async function callPost(form: FormData, { tableId }: { tableId: string } = { tableId: 'tbl_1' }) { + // Building the request from a FormData body gives a real multipart stream and + // boundary, exercising the streaming `readMultipart` parser end-to-end. const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import`, { method: 'POST', - headers: { 'content-length': '1024' }, body: form, }) return POST(req, { params: Promise.resolve({ tableId }) }) @@ -130,25 +152,15 @@ describe('POST /api/table/[tableId]/import', () => { authType: 'session', }) mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockBatchInsertRowsWithTx.mockImplementation(async (_trx, data: { rows: unknown[] }) => - data.rows.map((_, i) => ({ id: `row_${i}` })) - ) - mockReplaceTableRowsWithTx.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) - mockAddTableColumnsWithTx.mockImplementation( - async ( - _trx, - table: { schema: { columns: { name: string; type: string }[] } }, - columns: { name: string; type: string }[] - ) => ({ - ...table, - schema: { - columns: [ - ...table.schema.columns, - ...columns.map((c) => ({ name: c.name, type: c.type as 'string' })), - ], - }, + mockImportAppendRows.mockImplementation( + async (table: TableDefinition, _additions: unknown, rows: unknown[]) => ({ + inserted: rows.map((_, i) => ({ id: `row_${i}` })), + table, }) ) + mockImportReplaceRows.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) + mockMarkTableImporting.mockResolvedValue(true) + mockReleaseImportClaim.mockResolvedValue(undefined) }) it('returns 401 when the user is not authenticated', async () => { @@ -160,6 +172,22 @@ describe('POST /api/table/[tableId]/import', () => { expect(response.status).toBe(401) }) + it('returns 409 when a background import already holds the table (claim lost)', async () => { + mockMarkTableImporting.mockResolvedValueOnce(false) + const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) + expect(response.status).toBe(409) + expect(mockImportAppendRows).not.toHaveBeenCalled() + expect(mockImportReplaceRows).not.toHaveBeenCalled() + expect(mockReleaseImportClaim).not.toHaveBeenCalled() + }) + + it('releases the import claim after a successful write', async () => { + const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) + expect(response.status).toBe(200) + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') + expect(mockReleaseImportClaim).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') + }) + it('returns 400 when the mode is invalid', async () => { const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'bogus' }) @@ -186,24 +214,32 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.error).toMatch(/archived/i) }) - it('returns 413 for oversized CSV files before reading their contents', async () => { - const file = createCsvFile('name,age\nAlice,30') - Object.defineProperty(file, 'size', { - value: 26 * 1024 * 1024, - }) - const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer') - + it('returns 400 when the file part precedes the required fields', async () => { + // Build a raw multipart body with the file BEFORE workspaceId. + const boundary = '----orderboundary' + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\nContent-Type: text/csv\r\n\r\nname,age\nAlice,30\r\n` + ), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="workspaceId"\r\n\r\n`), + Buffer.from('workspace-1\r\n'), + Buffer.from(`--${boundary}--\r\n`), + ]) const req = { - formData: async () => createFormData(file), + headers: new Headers({ 'content-type': `multipart/form-data; boundary=${boundary}` }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(body)) + controller.close() + }, + }), + signal: undefined, } as unknown as NextRequest const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) - expect(response.status).toBe(413) - const data = await response.json() - expect(data.error).toMatch(/CSV import file exceeds maximum size/) - expect(arrayBufferSpy).not.toHaveBeenCalled() - expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() - expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mockImportAppendRows).not.toHaveBeenCalled() + expect(mockImportReplaceRows).not.toHaveBeenCalled() }) it('returns 400 when the CSV is missing a required column', async () => { @@ -212,10 +248,10 @@ describe('POST /api/table/[tableId]/import', () => { const data = await response.json() expect(data.error).toMatch(/missing required columns/i) expect(data.details?.missingRequired).toEqual(['name']) - expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() }) - it('appends rows via batchInsertRows', async () => { + it('appends rows via importAppendRows', async () => { const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' }) ) @@ -223,13 +259,12 @@ describe('POST /api/table/[tableId]/import', () => { const data = await response.json() expect(data.data.mode).toBe('append') expect(data.data.insertedCount).toBe(2) - expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) - const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] } - expect(callArgs.rows).toEqual([ + expect(mockImportAppendRows).toHaveBeenCalledTimes(1) + expect(appendRows()).toEqual([ { name: 'Alice', age: 30 }, { name: 'Bob', age: 40 }, ]) - expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled() + expect(mockImportReplaceRows).not.toHaveBeenCalled() }) it('accepts chunked multipart imports without a content-length header', async () => { @@ -244,7 +279,7 @@ describe('POST /api/table/[tableId]/import', () => { const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) expect(response.status).toBe(200) - expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) + expect(mockImportAppendRows).toHaveBeenCalledTimes(1) }) it('rejects append when it would exceed maxRows', async () => { @@ -258,11 +293,11 @@ describe('POST /api/table/[tableId]/import', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toMatch(/exceed table row limit/) - expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() }) - it('replaces rows via replaceTableRows', async () => { - mockReplaceTableRowsWithTx.mockResolvedValueOnce({ deletedCount: 5, insertedCount: 2 }) + it('replaces rows via importReplaceRows', async () => { + mockImportReplaceRows.mockResolvedValueOnce({ deletedCount: 5, insertedCount: 2 }) const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'replace' }) ) @@ -271,8 +306,8 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.data.mode).toBe('replace') expect(data.data.deletedCount).toBe(5) expect(data.data.insertedCount).toBe(2) - expect(mockReplaceTableRowsWithTx).toHaveBeenCalledTimes(1) - expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + expect(mockImportReplaceRows).toHaveBeenCalledTimes(1) + expect(mockImportAppendRows).not.toHaveBeenCalled() }) it('uses an explicit mapping when provided', async () => { @@ -285,8 +320,7 @@ describe('POST /api/table/[tableId]/import', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.data.mappedColumns).toEqual(['First Name', 'Years']) - const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] } - expect(callArgs.rows).toEqual([ + expect(appendRows()).toEqual([ { name: 'Alice', age: 30 }, { name: 'Bob', age: 40 }, ]) @@ -316,8 +350,8 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.error).toMatch(/Mapping values must be/) }) - it('surfaces unique violations from batchInsertRows as 400', async () => { - mockBatchInsertRowsWithTx.mockRejectedValueOnce( + it('surfaces unique violations from importAppendRows as 400', async () => { + mockImportAppendRows.mockRejectedValueOnce( new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx') ) const response = await callPost( @@ -337,7 +371,7 @@ describe('POST /api/table/[tableId]/import', () => { ) ) expect(response.status).toBe(200) - expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) + expect(mockImportAppendRows).toHaveBeenCalledTimes(1) }) it('returns 400 for unsupported file extensions', async () => { @@ -358,12 +392,9 @@ describe('POST /api/table/[tableId]/import', () => { }) ) expect(response.status).toBe(200) - expect(mockAddTableColumnsWithTx).toHaveBeenCalledTimes(1) - const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0] - expect(columns).toEqual([{ name: 'email', type: 'string' }]) - - const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] } - expect(callArgs.rows).toEqual([ + expect(mockImportAppendRows).toHaveBeenCalledTimes(1) + expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }]) + expect(appendRows()).toEqual([ { name: 'Alice', age: 30, email: 'a@x.io' }, { name: 'Bob', age: 40, email: 'b@x.io' }, ]) @@ -377,8 +408,7 @@ describe('POST /api/table/[tableId]/import', () => { }) ) expect(response.status).toBe(200) - const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0] - expect(columns).toEqual([{ name: 'score', type: 'number' }]) + expect(appendAdditions()).toEqual([{ name: 'score', type: 'number' }]) }) it('dedupes when sanitized name collides with an existing column', async () => { @@ -401,8 +431,7 @@ describe('POST /api/table/[tableId]/import', () => { }) ) expect(response.status).toBe(200) - const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0] - expect(columns).toEqual([{ name: 'Email_2', type: 'string' }]) + expect(appendAdditions()).toEqual([{ name: 'Email_2', type: 'string' }]) }) it('returns 400 when createColumns references a header not in the CSV', async () => { @@ -415,8 +444,7 @@ describe('POST /api/table/[tableId]/import', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toMatch(/unknown CSV headers/) - expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled() - expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() }) it('returns 400 when createColumns is not an array of strings', async () => { @@ -429,7 +457,7 @@ describe('POST /api/table/[tableId]/import', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toMatch(/createColumns must be a JSON array/) - expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() }) it('returns 400 when createColumns is invalid JSON', async () => { @@ -444,8 +472,8 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.error).toMatch(/createColumns must be valid JSON/) }) - it('surfaces addTableColumns failures as 400', async () => { - mockAddTableColumnsWithTx.mockRejectedValueOnce(new Error('Column "email" already exists')) + it('surfaces column-creation failures from importAppendRows as 400', async () => { + mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists')) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', @@ -455,30 +483,30 @@ describe('POST /api/table/[tableId]/import', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toMatch(/already exists/) - expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() }) it('surfaces row insert failures without success when schema was mutated', async () => { - mockBatchInsertRowsWithTx.mockRejectedValueOnce(new Error('must be unique')) + mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique')) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', createColumns: ['email'], }) ) - expect(mockAddTableColumnsWithTx).toHaveBeenCalled() + // Route forwarded the column addition into the (now atomic) import op. + expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }]) expect(response.status).toBe(400) const data = await response.json() expect(data.success).toBeUndefined() expect(data.error).toMatch(/must be unique/) }) - it('does not call addTableColumns when createColumns is omitted', async () => { + it('passes no additions when createColumns is omitted', async () => { const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) ) expect(response.status).toBe(200) - expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled() + expect(appendAdditions()).toEqual([]) }) }) }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index e097723c023..5fd11d20426 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -13,36 +13,39 @@ import { } from '@/lib/api/contracts/tables' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' -import { - isPayloadSizeLimitError, - readFileToBufferWithLimit, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - addTableColumnsWithTx, - batchInsertRowsWithTx, buildAutoMapping, - CSV_MAX_BATCH_SIZE, CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping, CsvImportValidationError, coerceRowsForTable, + createCsvParser, dispatchAfterBatchInsert, + importAppendRows, + importReplaceRows, inferColumnType, - parseCsvBuffer, - replaceTableRowsWithTx, + markTableImporting, + releaseImportClaim, sanitizeName, type TableDefinition, - type TableRow, type TableSchema, validateMapping, } from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + csvProxyBodyCapResponse, + multipartErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 interface RouteParams { params: Promise<{ tableId: string }> @@ -51,6 +54,8 @@ interface RouteParams { export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { tableId } = tableIdParamsSchema.parse(await params) + let fileStream: Readable | undefined + let claimedImportId: string | null = null try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -58,29 +63,37 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const formData = await readFormDataWithLimit(request, { - maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'CSV import body', - }) - const formValidation = csvImportFormSchema.safeParse({ - file: formData.get('file'), - workspaceId: formData.get('workspaceId'), - }) - const rawMode = formData.get('mode') ?? 'append' - const rawMapping = formData.get('mapping') - const rawCreateColumns = formData.get('createColumns') - - if (!formValidation.success) { - const message = getValidationErrorMessage(formValidation.error) - const isSizeLimit = message.includes('File exceeds maximum allowed size') + const oversize = csvProxyBodyCapResponse(request) + if (oversize) return oversize + + let parsed: Awaited> + try { + parsed = await readMultipart(request, { + maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + requiredFieldsBeforeFile: ['workspaceId'], + signal: request.signal, + }) + } catch (err) { + if (isMultipartError(err)) return multipartErrorResponse(err) + throw err + } + + const { fields, file } = parsed + if (!file) { + return NextResponse.json({ error: 'CSV file is required' }, { status: 400 }) + } + fileStream = file.stream + + const workspaceIdResult = csvImportFormSchema.shape.workspaceId.safeParse(fields.workspaceId) + if (!workspaceIdResult.success) { return NextResponse.json( - { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message }, - { status: isSizeLimit ? 413 : 400 } + { error: getValidationErrorMessage(workspaceIdResult.error) }, + { status: 400 } ) } + const workspaceId = workspaceIdResult.data - const { file, workspaceId } = formValidation.data - + const rawMode = fields.mode ?? 'append' const modeValidation = csvImportModeSchema.safeParse(rawMode) if (!modeValidation.success) { return NextResponse.json( @@ -90,7 +103,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } const mode = modeValidation.data - const ext = file.name.split('.').pop()?.toLowerCase() + const ext = file.filename.split('.').pop()?.toLowerCase() const extensionValidation = csvExtensionSchema.safeParse(ext) if (!extensionValidation.success) { return NextResponse.json( @@ -114,10 +127,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (table.archivedAt) { return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) } + // Don't run a sync import on top of an in-flight background import — concurrent writers + // would insert at colliding row positions. + if (table.importStatus === 'importing') { + return NextResponse.json( + { error: 'An import is already in progress for this table' }, + { status: 409 } + ) + } let mapping: CsvHeaderMapping | undefined - if (rawMapping) { - const mappingValidation = csvImportMappingSchema.safeParse(rawMapping) + if (fields.mapping) { + const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping) if (!mappingValidation.success) { return NextResponse.json( { error: getValidationErrorMessage(mappingValidation.error) }, @@ -128,8 +149,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } let createColumns: string[] | undefined - if (rawCreateColumns) { - const createColumnsValidation = csvImportCreateColumnsSchema.safeParse(rawCreateColumns) + if (fields.createColumns) { + const createColumnsValidation = csvImportCreateColumnsSchema.safeParse(fields.createColumns) if (!createColumnsValidation.success) { return NextResponse.json( { error: getValidationErrorMessage(createColumnsValidation.error) }, @@ -139,12 +160,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createColumns = createColumnsValidation.data } - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: CSV_MAX_FILE_SIZE_BYTES, - label: 'CSV import file', - }) const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvBuffer(buffer, delimiter) + const parser = createCsvParser(delimiter) + // `.pipe` doesn't forward source errors; forward them so the iterator throws. + file.stream.on('error', (streamErr) => parser.destroy(streamErr)) + file.stream.pipe(parser) + const rows: Record[] = [] + for await (const record of parser as AsyncIterable>) { + rows.push(record) + } + if (rows.length === 0) { + return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) + } + const headers = Object.keys(rows[0]) let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) let prospectiveTable: TableDefinition = table @@ -218,6 +246,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap) + // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot + // taken before the parse/validation; a background import could claim the table in that window. + // markTableImporting is the single atomic gate (same one the async kickoff uses) — released in + // the finally so a sync import can't write concurrently with a background one (corrupts replace). + const syncImportId = generateId() + if (!(await markTableImporting(tableId, syncImportId))) { + return NextResponse.json( + { error: 'An import is already in progress for this table' }, + { status: 409 } + ) + } + claimedImportId = syncImportId + if (mode === 'append') { if (prospectiveTable.rowCount + coerced.length > prospectiveTable.maxRows) { const deficit = prospectiveTable.rowCount + coerced.length - prospectiveTable.maxRows @@ -230,32 +271,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } try { - const txResult = await db.transaction(async (trx) => { - let working = table - if (additions.length > 0) { - working = await addTableColumnsWithTx(trx, table, additions, requestId) - } - - const allInserted: TableRow[] = [] - for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { - const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) - const batchRequestId = generateId().slice(0, 8) - const result = await batchInsertRowsWithTx( - trx, - { - tableId: working.id, - rows: batch, - workspaceId, - userId: authResult.userId, - }, - working, - batchRequestId - ) - allInserted.push(...result) - } - return { inserted: allInserted, working } - }) - const { inserted: insertedRows, working: finalTable } = txResult + const { inserted: insertedRows, table: finalTable } = await importAppendRows( + table, + additions, + coerced, + { workspaceId, userId: authResult.userId, requestId } + ) const inserted = insertedRows.length // Fire trigger + scheduler AFTER the tx commits — both read through the // global db connection and would otherwise see no rows. @@ -263,7 +284,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro logger.info(`[${requestId}] Append CSV imported`, { tableId: table.id, - fileName: file.name, + fileName: file.filename, mode, inserted, createdColumns: additions.length, @@ -280,7 +301,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro mappedColumns: validation.mappedHeaders, skippedHeaders: validation.skippedHeaders, unmappedColumns: validation.unmappedColumns, - sourceFile: file.name, + sourceFile: file.filename, }, }) } catch (err) { @@ -310,22 +331,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } try { - const result = await db.transaction(async (trx) => { - let working = table - if (additions.length > 0) { - working = await addTableColumnsWithTx(trx, table, additions, requestId) - } - return replaceTableRowsWithTx( - trx, - { tableId: working.id, rows: coerced, workspaceId, userId: authResult.userId }, - working, - requestId - ) - }) + const result = await importReplaceRows( + table, + additions, + { rows: coerced, workspaceId, userId: authResult.userId }, + requestId + ) logger.info(`[${requestId}] Replace CSV imported`, { tableId: table.id, - fileName: file.name, + fileName: file.filename, mode, deleted: result.deletedCount, inserted: result.insertedCount, @@ -343,7 +358,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro mappedColumns: validation.mappedHeaders, skippedHeaders: validation.skippedHeaders, unmappedColumns: validation.unmappedColumns, - sourceFile: file.name, + sourceFile: file.filename, }, }) } catch (err) { @@ -362,22 +377,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro throw err } } catch (error) { + if (isMultipartError(error)) return multipartErrorResponse(error) + const message = toError(error).message logger.error(`[${requestId}] CSV import into existing table failed:`, error) - const isSizeLimitError = - isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size') const isClientError = message.includes('CSV file has no') || message.includes('already exists') || - message.includes('Invalid column name') || - isSizeLimitError + message.includes('Invalid column name') return NextResponse.json( { error: isClientError ? message : 'Failed to import CSV' }, - { - status: isSizeLimitError ? 413 : isClientError ? 400 : 500, - } + { status: isClientError ? 400 : 500 } ) + } finally { + fileStream?.destroy() + // Release before the response returns, so a client refetch never observes the transient claim. + if (claimedImportId) await releaseImportClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 0e73ecaaeba..c0b018f854e 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -68,6 +68,10 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab table.updatedAt instanceof Date ? table.updatedAt.toISOString() : String(table.updatedAt), + importStatus: table.importStatus ?? null, + importId: table.importId ?? null, + importError: table.importError ?? null, + importRowsProcessed: table.importRowsProcessed ?? 0, }, }, }) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 8e29e12005c..75fd9487dfe 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -1,8 +1,5 @@ -import { db } from '@sim/db' -import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, inArray, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { type BatchInsertTableRowsBodyInput, @@ -17,27 +14,20 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { - Filter, - RowData, - RowExecutionMetadata, - RowExecutions, - Sort, - TableSchema, -} from '@/lib/table' +import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' import { batchInsertRows, batchUpdateRows, deleteRowsByFilter, deleteRowsByIds, insertRow, - USER_TABLE_ROWS_SQL_NAME, updateRowsByFilter, validateBatchRows, validateRowData, validateRowSize, } from '@/lib/table' -import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql' +import { queryRows } from '@/lib/table/service' +import { TableQueryValidationError } from '@/lib/table/sql' import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableRowsAPI') @@ -81,6 +71,7 @@ async function handleBatchInsert( workspaceId: validated.workspaceId, userId, positions: validated.positions, + orderKeys: validated.orderKeys, }, table, requestId @@ -93,6 +84,7 @@ async function handleBatchInsert( id: r.id, data: r.data, position: r.position, + orderKey: r.orderKey ?? undefined, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt, })), @@ -172,6 +164,8 @@ export const POST = withRouteHandler( workspaceId: validated.workspaceId, userId: authResult.userId, position: validated.position, + afterRowId: validated.afterRowId, + beforeRowId: validated.beforeRowId, }, table, requestId @@ -184,9 +178,11 @@ export const POST = withRouteHandler( id: row.id, data: row.data, position: row.position, + orderKey: row.orderKey ?? undefined, createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, }, + message: 'Row inserted successfully', }, }) @@ -268,113 +264,35 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const baseConditions = [ - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId), - ] - - const schema = table.schema as TableSchema - - if (validated.filter) { - const filterClause = buildFilterClause( - validated.filter as Filter, - USER_TABLE_ROWS_SQL_NAME, - schema.columns - ) - if (filterClause) { - baseConditions.push(filterClause) - } - } - - let query = db - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where(and(...baseConditions)) - - if (validated.sort) { - const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) - if (sortClause) { - query = query.orderBy(sortClause) as typeof query - } else { - query = query.orderBy(userTableRows.position) as typeof query - } - } else { - query = query.orderBy(userTableRows.position) as typeof query - } - - let totalCount: number | null = null - if (validated.includeTotal) { - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) - totalCount = Number(count) - } - - const rows = await query.limit(validated.limit).offset(validated.offset) - - // Sidecar: fetch per-(row, group) execution state and group into a map - // so the response preserves the legacy `row.executions[groupId]` wire - // shape. One indexed-IN scan against table_row_executions. - const executionsByRow = new Map() - if (rows.length > 0) { - const execRows = await db - .select() - .from(tableRowExecutions) - .where( - inArray( - tableRowExecutions.rowId, - rows.map((r) => r.id) - ) - ) - for (const e of execRows) { - const existing = executionsByRow.get(e.rowId) ?? {} - const meta: RowExecutionMetadata = { - status: e.status as RowExecutionMetadata['status'], - executionId: e.executionId ?? null, - jobId: e.jobId ?? null, - workflowId: e.workflowId, - error: e.error ?? null, - ...(e.runningBlockIds && e.runningBlockIds.length > 0 - ? { runningBlockIds: e.runningBlockIds } - : {}), - ...(e.blockErrors && Object.keys(e.blockErrors as Record).length > 0 - ? { blockErrors: e.blockErrors as Record } - : {}), - ...(e.cancelledAt ? { cancelledAt: e.cancelledAt.toISOString() } : {}), - } - existing[e.groupId] = meta - executionsByRow.set(e.rowId, existing) - } - } - - logger.info( - `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})` + const result = await queryRows( + table, + { + filter: validated.filter as Filter | undefined, + sort: validated.sort, + limit: validated.limit, + offset: validated.offset, + includeTotal: validated.includeTotal, + }, + requestId ) return NextResponse.json({ success: true, data: { - rows: rows.map((r) => ({ + rows: result.rows.map((r) => ({ id: r.id, data: r.data, - executions: executionsByRow.get(r.id) ?? {}, + executions: r.executions, position: r.position, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), })), - rowCount: rows.length, - totalCount, - limit: validated.limit, - offset: validated.offset, + rowCount: result.rowCount, + totalCount: result.totalCount, + limit: result.limit, + offset: result.offset, }, }) } catch (error) { diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts new file mode 100644 index 00000000000..8ecdd2a923a --- /dev/null +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateTable, + mockGetLimits, + mockListTables, + mockRunTableImport, + mockRunDetached, + MockTableConflictError, +} = vi.hoisted(() => ({ + mockCreateTable: vi.fn(), + mockGetLimits: vi.fn(), + mockListTables: vi.fn(), + mockRunTableImport: vi.fn(), + mockRunDetached: vi.fn(), + MockTableConflictError: class extends Error { + readonly code = 'TABLE_EXISTS' as const + }, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('import-id-123'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) + +vi.mock('@/lib/table', () => ({ + createTable: mockCreateTable, + getWorkspaceTableLimits: mockGetLimits, + listTables: mockListTables, + sanitizeName: (name: string) => name.replace(/[^a-zA-Z0-9_]/g, '_'), + TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 128 }, + TableConflictError: MockTableConflictError, +})) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: mockRunDetached.mockImplementation( + (_label: string, work: () => Promise) => { + void work() + } + ), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +import { POST } from '@/app/api/table/import-async/route' + +function makeRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost:3000/api/table/import-async', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const validBody = { + workspaceId: 'workspace-1', + fileKey: 'workspace/workspace-1/123-data.csv', + fileName: 'data.csv', +} + +describe('POST /api/table/import-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 }) + mockListTables.mockResolvedValue([]) + mockCreateTable.mockResolvedValue({ id: 'tbl_async', name: 'data' }) + mockRunTableImport.mockResolvedValue(undefined) + }) + + it('creates an importing table and kicks off the background import', async () => { + const response = await POST(makeRequest(validBody)) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_async', importId: 'import-id-123' }) + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ importStatus: 'importing', importId: 'import-id-123' }), + expect.any(String) + ) + expect(mockRunTableImport).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'tbl_async', mode: 'create', delimiter: ',' }) + ) + }) + + it('uses a tab delimiter for .tsv files', async () => { + await POST(makeRequest({ ...validBody, fileName: 'data.tsv' })) + expect(mockRunTableImport).toHaveBeenCalledWith(expect.objectContaining({ delimiter: '\t' })) + }) + + it('returns 400 for unsupported extensions', async () => { + const response = await POST(makeRequest({ ...validBody, fileName: 'data.json' })) + expect(response.status).toBe(400) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await POST(makeRequest(validBody)) + expect(response.status).toBe(401) + }) + + it('returns 403 without write permission', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + const response = await POST(makeRequest(validBody)) + expect(response.status).toBe(403) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('returns 400 when the body is missing required fields', async () => { + const response = await POST(makeRequest({ workspaceId: 'workspace-1' })) + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts new file mode 100644 index 00000000000..43fefeca9a6 --- /dev/null +++ b/apps/sim/app/api/table/import-async/route.ts @@ -0,0 +1,115 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { importTableAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTable, + getWorkspaceTableLimits, + listTables, + sanitizeName, + TABLE_LIMITS, + TableConflictError, +} from '@/lib/table' +import { runTableImport } from '@/lib/table/import-runner' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('TableImportAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const userId = authResult.userId + + const parsed = await parseRequest(importTableAsyncContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, fileKey, fileName } = parsed.data.body + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a + // caller can't import another workspace's uploaded object. + if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { + return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 }) + } + + const ext = fileName.split('.').pop()?.toLowerCase() + if (ext !== 'csv' && ext !== 'tsv') { + return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) + } + const delimiter = ext === 'tsv' ? '\t' : ',' + + const planLimits = await getWorkspaceTableLimits(workspaceId) + const baseName = sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + // Re-importing the same file shouldn't fail on a name collision — pick the next free + // `name_2`, `name_3`, … (matching how "New table" auto-names), keeping under the cap. + const existingNames = new Set( + (await listTables(workspaceId, { scope: 'all' })).map((t) => t.name.toLowerCase()) + ) + let tableName = baseName + for (let n = 2; existingNames.has(tableName.toLowerCase()); n++) { + const suffix = `_${n}` + tableName = `${baseName.slice(0, TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - suffix.length)}${suffix}` + } + const importId = generateId() + + // Placeholder schema satisfies createTable's validation; the import worker infers the + // real columns from the file and overwrites it before any rows become visible. + let table: Awaited> + try { + table = await createTable( + { + name: tableName, + description: `Imported from ${fileName}`, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId, + userId, + maxRows: planLimits.maxRowsPerTable, + maxTables: planLimits.maxTables, + importStatus: 'importing', + importId, + }, + requestId + ) + } catch (error) { + if (error instanceof TableConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + if (error instanceof Error && error.message.includes('maximum table limit')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + + runDetached('table-import', () => + runTableImport({ + importId, + tableId: table.id, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode: 'create', + }) + ) + + logger.info(`[${requestId}] Async CSV import started`, { tableId: table.id, importId, fileName }) + return NextResponse.json({ success: true, data: { tableId: table.id, importId } }) +}) diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index 9844bf69664..dc0bb0a53a5 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -5,10 +5,11 @@ import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/tes import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCreateTable, mockParseCsvBuffer, mockGetWorkspaceTableLimits } = vi.hoisted(() => ({ +const { mockCreateTable, mockBatchInsertRows, mockDeleteTable, mockGetLimits } = vi.hoisted(() => ({ mockCreateTable: vi.fn(), - mockParseCsvBuffer: vi.fn(), - mockGetWorkspaceTableLimits: vi.fn(), + mockBatchInsertRows: vi.fn(), + mockDeleteTable: vi.fn(), + mockGetLimits: vi.fn(), })) vi.mock('@sim/utils/id', () => ({ @@ -16,46 +17,83 @@ vi.mock('@sim/utils/id', () => ({ generateShortId: vi.fn().mockReturnValue('short-id'), })) -vi.mock('@/lib/table', () => ({ - batchInsertRows: vi.fn(), - CSV_MAX_BATCH_SIZE: 1000, - CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024, - coerceRowsForTable: vi.fn(), +// Mock only the DB-backed service/billing functions; the real `./import` helpers +// (createCsvParser, inferSchemaFromCsv, coerceRowsForTable, …) run for real so the +// streaming multipart + CSV pipeline is exercised end-to-end. +vi.mock('@/lib/table/service', () => ({ createTable: mockCreateTable, - deleteTable: vi.fn(), - getWorkspaceTableLimits: mockGetWorkspaceTableLimits, - inferSchemaFromCsv: vi.fn(), - parseCsvBuffer: mockParseCsvBuffer, - sanitizeName: vi.fn((name: string) => name), - TABLE_LIMITS: { - MAX_TABLE_NAME_LENGTH: 64, - }, + batchInsertRows: mockBatchInsertRows, + deleteTable: mockDeleteTable, })) - -vi.mock('@/app/api/table/utils', () => ({ - normalizeColumn: vi.fn((column) => column), -})) - +vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits })) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + normalizeColumn: (column: unknown) => column, + csvProxyBodyCapResponse: () => null, + multipartErrorResponse: (error: { code: string; message: string }) => + NextResponse.json( + { error: error.message }, + { status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 } + ), + } +}) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) import { POST } from '@/app/api/table/import-csv/route' -function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { - return new File([contents], name, { type }) +type Part = + | { name: string; value: string } + | { name: string; filename: string; value: string; contentType?: string } + +const BOUNDARY = '----testboundaryCSV' + +function buildBody(parts: Part[]): Buffer { + const segments: Buffer[] = [] + for (const part of parts) { + let header = `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.name}"` + if ('filename' in part) { + header += `; filename="${part.filename}"\r\nContent-Type: ${part.contentType ?? 'text/csv'}` + } + header += '\r\n\r\n' + segments.push(Buffer.from(header, 'utf8'), Buffer.from(part.value, 'utf8'), Buffer.from('\r\n')) + } + segments.push(Buffer.from(`--${BOUNDARY}--\r\n`, 'utf8')) + return Buffer.concat(segments) } -function createFormData(file: File): FormData { - const form = new FormData() - form.append('file', file) - form.append('workspaceId', 'workspace-1') - return form +function makeRequest(parts: Part[], chunkSize?: number): NextRequest { + const body = buildBody(parts) + const stream = new ReadableStream({ + start(controller) { + if (chunkSize) { + for (let i = 0; i < body.length; i += chunkSize) { + controller.enqueue(new Uint8Array(body.subarray(i, i + chunkSize))) + } + } else { + controller.enqueue(new Uint8Array(body)) + } + controller.close() + }, + }) + return { + headers: new Headers({ 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }), + body: stream, + signal: undefined, + } as unknown as NextRequest } -async function callPost(form: FormData) { - const req = { - formData: async () => form, - } as unknown as NextRequest - return POST(req) +function csvWithRows(count: number): string { + const lines = ['name,age'] + for (let i = 0; i < count; i++) lines.push(`Person${i},${20 + (i % 50)}`) + return `${lines.join('\n')}\n` +} + +function uploadParts(csv: string): Part[] { + return [ + { name: 'workspaceId', value: 'workspace-1' }, + { name: 'file', filename: 'data.csv', value: csv }, + ] } describe('POST /api/table/import-csv', () => { @@ -67,38 +105,93 @@ describe('POST /api/table/import-csv', () => { authType: 'session', }) permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceTableLimits.mockResolvedValue({ - maxRowsPerTable: 1000, - maxTables: 10, - }) + mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 }) + mockCreateTable.mockImplementation(async (data) => ({ + id: 'tbl_1', + name: data.name, + description: data.description ?? null, + schema: data.schema, + workspaceId: data.workspaceId, + maxRows: data.maxRows, + rowCount: 0, + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + })) + mockBatchInsertRows.mockImplementation(async ({ rows }: { rows: unknown[] }) => + rows.map((_, i) => ({ id: `row-${i}` })) + ) + mockDeleteTable.mockResolvedValue(undefined) }) - it('returns 413 for oversized CSV files before reading their contents or creating a table', async () => { - const file = createCsvFile('name,age\nAlice,30') - Object.defineProperty(file, 'size', { - value: 26 * 1024 * 1024, - }) - const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer') + it('streams a CSV upload into a new table and reports the row count', async () => { + const response = await POST(makeRequest(uploadParts(csvWithRows(250)))) + const data = await response.json() - const response = await callPost(createFormData(file)) + expect(response.status).toBe(200) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + expect(data.data.table.id).toBe('tbl_1') + expect(data.data.table.rowCount).toBe(250) + // 250 rows = a 100-row schema-sample batch + a 150-row remainder batch. + expect(mockBatchInsertRows).toHaveBeenCalledTimes(2) + }) + + it('parses a body delivered in tiny chunks (regression: missing final boundary)', async () => { + const response = await POST(makeRequest(uploadParts(csvWithRows(5)), 7)) const data = await response.json() - expect(response.status).toBe(413) - expect(data.error).toMatch(/CSV import file exceeds maximum size/) - expect(arrayBufferSpy).not.toHaveBeenCalled() - expect(mockParseCsvBuffer).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(data.data.table.rowCount).toBe(5) + }) + + it('returns 400 for a CSV with no data rows', async () => { + const response = await POST(makeRequest(uploadParts('name,age\n'))) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toMatch(/no data rows/i) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('returns 400 when the file precedes required fields', async () => { + const response = await POST( + makeRequest([ + { name: 'file', filename: 'data.csv', value: csvWithRows(3) }, + { name: 'workspaceId', value: 'workspace-1' }, + ]) + ) + + expect(response.status).toBe(400) expect(mockCreateTable).not.toHaveBeenCalled() }) - it('accepts chunked multipart requests without a content-length header', async () => { - const req = { - headers: new Headers({ 'transfer-encoding': 'chunked' }), - formData: vi.fn(async () => createFormData(createCsvFile('name\nAlice'))), - } as unknown as NextRequest + it('returns 400 when no file part is present', async () => { + const response = await POST(makeRequest([{ name: 'workspaceId', value: 'workspace-1' }])) + expect(response.status).toBe(400) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('rolls back the created table when a batch insert fails mid-stream', async () => { + mockBatchInsertRows + .mockResolvedValueOnce(Array.from({ length: 100 }, () => ({ id: 'row' }))) + .mockRejectedValueOnce(new Error('insert boom')) + + const response = await POST(makeRequest(uploadParts(csvWithRows(250)))) - const response = await POST(req) + expect(response.status).toBe(500) + expect(mockDeleteTable).toHaveBeenCalledWith('tbl_1', expect.any(String)) + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + expect(response.status).toBe(401) + }) - expect(response.status).not.toBe(411) - expect(req.formData).toHaveBeenCalled() + it('returns 403 without write permission', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + expect(response.status).toBe(403) }) }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 31927889202..4ab4d26920e 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,3 +1,4 @@ +import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -5,163 +6,213 @@ import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' -import { - isPayloadSizeLimitError, - readFileToBufferWithLimit, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchInsertRows, CSV_MAX_BATCH_SIZE, CSV_MAX_FILE_SIZE_BYTES, + CSV_SCHEMA_SAMPLE_SIZE, coerceRowsForTable, + createCsvParser, createTable, deleteTable, getWorkspaceTableLimits, inferSchemaFromCsv, - parseCsvBuffer, sanitizeName, TABLE_LIMITS, + type TableDefinition, type TableSchema, } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { normalizeColumn } from '@/app/api/table/utils' +import { + csvProxyBodyCapResponse, + multipartErrorResponse, + normalizeColumn, +} from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() + let fileStream: Readable | undefined try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } + const userId = authResult.userId - const formData = await readFormDataWithLimit(request, { - maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'CSV import body', - }) - const validation = csvImportFormSchema.safeParse({ - file: formData.get('file'), - workspaceId: formData.get('workspaceId'), - }) + const oversize = csvProxyBodyCapResponse(request) + if (oversize) return oversize + + let parsed: Awaited> + try { + parsed = await readMultipart(request, { + maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + requiredFieldsBeforeFile: ['workspaceId'], + signal: request.signal, + }) + } catch (err) { + if (isMultipartError(err)) return multipartErrorResponse(err) + throw err + } - if (!validation.success) { - const message = getValidationErrorMessage(validation.error) - const isSizeLimit = message.includes('File exceeds maximum allowed size') + const { fields, file } = parsed + if (!file) { + return NextResponse.json({ error: 'CSV file is required' }, { status: 400 }) + } + fileStream = file.stream + + const workspaceIdResult = csvImportFormSchema.shape.workspaceId.safeParse(fields.workspaceId) + if (!workspaceIdResult.success) { return NextResponse.json( - { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message }, - { status: isSizeLimit ? 413 : 400 } + { error: getValidationErrorMessage(workspaceIdResult.error) }, + { status: 400 } ) } + const workspaceId = workspaceIdResult.data - const { file, workspaceId } = validation.data - - const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId) + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const ext = file.name.split('.').pop()?.toLowerCase() - const extensionValidation = csvExtensionSchema.safeParse(ext) - if (!extensionValidation.success) { + const ext = file.filename.split('.').pop()?.toLowerCase() + const extensionResult = csvExtensionSchema.safeParse(ext) + if (!extensionResult.success) { return NextResponse.json( - { error: getValidationErrorMessage(extensionValidation.error) }, + { error: getValidationErrorMessage(extensionResult.error) }, { status: 400 } ) } + const delimiter = extensionResult.data === 'tsv' ? '\t' : ',' - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: CSV_MAX_FILE_SIZE_BYTES, - label: 'CSV import file', - }) - const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvBuffer(buffer, delimiter) + const parser = createCsvParser(delimiter) + // `.pipe` doesn't forward source errors; forward them so the iterator throws. + file.stream.on('error', (err) => parser.destroy(err)) + file.stream.pipe(parser) - const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) - const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const planLimits = await getWorkspaceTableLimits(workspaceId) + interface ImportState { + table: TableDefinition + schema: TableSchema + headerToColumn: Map + } - const normalizedSchema: TableSchema = { - columns: columns.map(normalizeColumn), + const insertRows = async (rows: Record[], state: ImportState) => { + if (rows.length === 0) return 0 + const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn) + const result = await batchInsertRows( + { tableId: state.table.id, rows: coerced, workspaceId, userId }, + state.table, + generateId().slice(0, 8) + ) + return result.length } - const table = await createTable( - { - name: tableName, - description: `Imported from ${file.name}`, - schema: normalizedSchema, - workspaceId, - userId: authResult.userId, - maxRows: planLimits.maxRowsPerTable, - maxTables: planLimits.maxTables, - }, - requestId - ) + /** Infer the schema from the buffered sample and create the (empty) table. */ + const buildTable = async (sampleRows: Record[]): Promise => { + const inferred = inferSchemaFromCsv(Object.keys(sampleRows[0]), sampleRows) + const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } + const planLimits = await getWorkspaceTableLimits(workspaceId) + const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const table = await createTable( + { + name: tableName, + description: `Imported from ${file.filename}`, + schema, + workspaceId, + userId, + maxRows: planLimits.maxRowsPerTable, + maxTables: planLimits.maxTables, + }, + requestId + ) + return { table, schema, headerToColumn: inferred.headerToColumn } + } + + let state: ImportState | null = null + let inserted = 0 + const sample: Record[] = [] + let batch: Record[] = [] try { - const coerced = coerceRowsForTable(rows, normalizedSchema, headerToColumn) - let inserted = 0 - for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { - const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) - const batchRequestId = generateId().slice(0, 8) - const result = await batchInsertRows( - { tableId: table.id, rows: batch, workspaceId, userId: authResult.userId }, - table, - batchRequestId - ) - inserted += result.length + for await (const record of parser as AsyncIterable>) { + if (!state) { + sample.push(record) + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + state = await buildTable(sample) + inserted += await insertRows(sample, state) + } + continue + } + batch.push(record) + if (batch.length >= CSV_MAX_BATCH_SIZE) { + inserted += await insertRows(batch, state) + batch = [] + } } - logger.info(`[${requestId}] CSV imported`, { - tableId: table.id, - fileName: file.name, - columns: columns.length, - rows: inserted, - }) + if (!state) { + if (sample.length === 0) { + return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) + } + state = await buildTable(sample) + inserted += await insertRows(sample, state) + } else { + inserted += await insertRows(batch, state) + } + } catch (streamError) { + if (state) await deleteTable(state.table.id, requestId).catch(() => {}) + throw streamError + } - return NextResponse.json({ - success: true, - data: { - table: { - id: table.id, - name: table.name, - description: table.description, - schema: normalizedSchema, - rowCount: inserted, - }, + logger.info(`[${requestId}] CSV imported`, { + tableId: state.table.id, + fileName: file.filename, + columns: state.schema.columns.length, + rows: inserted, + }) + + return NextResponse.json({ + success: true, + data: { + table: { + id: state.table.id, + name: state.table.name, + description: state.table.description, + schema: state.schema, + rowCount: inserted, }, - }) - } catch (insertError) { - await deleteTable(table.id, requestId).catch(() => {}) - throw insertError - } + }, + }) } catch (error) { + if (isMultipartError(error)) return multipartErrorResponse(error) + const message = toError(error).message logger.error(`[${requestId}] CSV import failed:`, error) - const isSizeLimitError = - isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size') const isClientError = message.includes('maximum table limit') || message.includes('CSV file has no') || message.includes('Invalid table name') || message.includes('Invalid schema') || - message.includes('already exists') || - isSizeLimitError + message.includes('already exists') return NextResponse.json( { error: isClientError ? message : 'Failed to import CSV' }, - { - status: isSizeLimitError ? 413 : isClientError ? 400 : 500, - } + { status: isClientError ? 400 : 500 } ) + } finally { + fileStream?.destroy() } }) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 89a48b80896..2d97dc4f639 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -203,6 +203,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : t.archivedAt ? String(t.archivedAt) : null, + importStatus: t.importStatus ?? null, + importId: t.importId ?? null, + importError: t.importError ?? null, + importRowsProcessed: t.importRowsProcessed ?? 0, } }) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 114271a9401..eef507c94ba 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -5,12 +5,46 @@ import { deleteTableColumnBodySchema, updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' +import type { MultipartError } from '@/lib/core/utils/multipart' import type { ColumnDefinition, TableDefinition } from '@/lib/table' import { getTableById } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableUtils') +/** + * Next.js buffers the request body for the proxy and silently truncates it past this + * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV + * import routes reject bodies over the cap up front; larger files use the async + * direct-to-storage path instead. + */ +export const CSV_IMPORT_PROXY_BODY_CAP_BYTES = 10 * 1024 * 1024 + +/** 413 response when a synchronous CSV upload would exceed (and be truncated at) the proxy cap; `null` otherwise. */ +export function csvProxyBodyCapResponse(request: { headers: Headers }): NextResponse | null { + const contentLength = Number(request.headers.get('content-length') ?? 0) + if (contentLength > CSV_IMPORT_PROXY_BODY_CAP_BYTES) { + return NextResponse.json( + { + error: + 'File too large to import through the server. Files over 10MB import in the background.', + }, + { status: 413 } + ) + } + return null +} + +/** Maps a {@link MultipartError} from the streaming CSV parser to its HTTP response. */ +export function multipartErrorResponse(error: MultipartError): NextResponse { + if (error.code === 'FILE_TOO_LARGE') { + return NextResponse.json({ error: 'CSV import file exceeds maximum size' }, { status: 413 }) + } + const message = + error.code === 'NO_FILE' ? 'CSV file is required' : `Invalid CSV upload: ${error.message}` + return NextResponse.json({ error: message }, { status: 400 }) +} + interface TableAccessResult { hasAccess: true table: TableDefinition diff --git a/apps/sim/app/api/tools/clickhouse/count-rows/route.ts b/apps/sim/app/api/tools/clickhouse/count-rows/route.ts new file mode 100644 index 00000000000..5b7b90821ca --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/count-rows/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseCountRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseCountRows } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseCountRowsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse count rows attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseCountRowsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const count = await executeClickHouseCountRows(params, params.table, params.where) + + return NextResponse.json({ + message: `Table contains ${count} row(s).`, + count, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse count rows failed:`, error) + + return NextResponse.json( + { error: `ClickHouse count rows failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/create-database/route.ts b/apps/sim/app/api/tools/clickhouse/create-database/route.ts new file mode 100644 index 00000000000..b748a20595e --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/create-database/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseCreateDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseCreateDatabase } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseCreateDatabaseAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse create database attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseCreateDatabaseContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseCreateDatabase(params, params.name) + + return NextResponse.json({ + message: `Database '${params.name}' created.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse create database failed:`, error) + + return NextResponse.json( + { error: `ClickHouse create database failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/create-table/route.ts b/apps/sim/app/api/tools/clickhouse/create-table/route.ts new file mode 100644 index 00000000000..47cc3ff5f7f --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/create-table/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseCreateTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseCreateTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse create table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseCreateTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseCreateTable( + params, + params.table, + params.columns, + params.engine, + params.orderBy, + params.partitionBy + ) + + return NextResponse.json({ + message: `Table '${params.table}' created.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse create table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse create table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/delete/route.ts b/apps/sim/app/api/tools/clickhouse/delete/route.ts new file mode 100644 index 00000000000..f773aabba4a --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/delete/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDeleteContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDelete } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDeleteAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse delete attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseDelete(params, params.table, params.where) + + logger.info(`[${requestId}] Delete mutation submitted, ${result.rowCount} row(s) affected`) + + return NextResponse.json({ + message: `Delete mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse delete failed:`, error) + + return NextResponse.json( + { error: `ClickHouse delete failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/describe-table/route.ts b/apps/sim/app/api/tools/clickhouse/describe-table/route.ts new file mode 100644 index 00000000000..e258d781bc1 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/describe-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDescribeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDescribeTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDescribeTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse describe table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDescribeTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseDescribeTable(params, params.table) + + return NextResponse.json({ + message: `Described table with ${result.rowCount} column(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse describe table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse describe table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/drop-database/route.ts b/apps/sim/app/api/tools/clickhouse/drop-database/route.ts new file mode 100644 index 00000000000..e06f897b337 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/drop-database/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDropDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDropDatabase } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDropDatabaseAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse drop database attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDropDatabaseContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseDropDatabase(params, params.name) + + return NextResponse.json({ + message: `Database '${params.name}' dropped.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse drop database failed:`, error) + + return NextResponse.json( + { error: `ClickHouse drop database failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts b/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts new file mode 100644 index 00000000000..790526586ba --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDropPartitionContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDropPartition } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDropPartitionAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse drop partition attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDropPartitionContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseDropPartition(params, params.table, params.partition) + + return NextResponse.json({ + message: `Dropped partition from table '${params.table}'.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse drop partition failed:`, error) + + return NextResponse.json( + { error: `ClickHouse drop partition failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/drop-table/route.ts b/apps/sim/app/api/tools/clickhouse/drop-table/route.ts new file mode 100644 index 00000000000..1ae9f6832a8 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/drop-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDropTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDropTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDropTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse drop table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDropTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseDropTable(params, params.table) + + return NextResponse.json({ + message: `Table '${params.table}' dropped.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse drop table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse drop table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/execute/route.ts b/apps/sim/app/api/tools/clickhouse/execute/route.ts new file mode 100644 index 00000000000..3e2c4baacf6 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/execute/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseExecuteContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseExecuteAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse execute attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Executing ClickHouse statement on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseQuery(params, params.query) + + logger.info(`[${requestId}] Statement executed successfully, ${result.rowCount} row(s)`) + + return NextResponse.json({ + message: `Statement executed successfully. ${result.rowCount} row(s) returned or affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse execute failed:`, error) + + return NextResponse.json( + { error: `ClickHouse execute failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts b/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts new file mode 100644 index 00000000000..fb4f90b8634 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseInsertRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseInsertRows } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseInsertRowsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse insert rows attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseInsertRowsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseInsertRows(params, params.table, params.rows) + + return NextResponse.json({ + message: `Inserted ${result.rowCount} row(s) into '${params.table}'.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse insert rows failed:`, error) + + return NextResponse.json( + { error: `ClickHouse insert rows failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/insert/route.ts b/apps/sim/app/api/tools/clickhouse/insert/route.ts new file mode 100644 index 00000000000..a7cc4ed908f --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/insert/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseInsertContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseInsert } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseInsertAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse insert attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseInsertContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseInsert(params, params.table, params.data) + + logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`) + + return NextResponse.json({ + message: `Data inserted successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse insert failed:`, error) + + return NextResponse.json( + { error: `ClickHouse insert failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/introspect/route.ts b/apps/sim/app/api/tools/clickhouse/introspect/route.ts new file mode 100644 index 00000000000..cd3257c6275 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/introspect/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseIntrospectContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseIntrospect } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseIntrospectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse introspect attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Introspecting ClickHouse schema on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseIntrospect(params) + + logger.info( + `[${requestId}] Introspection completed successfully, found ${result.tables.length} tables` + ) + + return NextResponse.json({ + message: `Schema introspection completed. Found ${result.tables.length} table(s) in database '${params.database}'.`, + tables: result.tables, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse introspection failed:`, error) + + return NextResponse.json( + { error: `ClickHouse introspection failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/kill-query/route.ts b/apps/sim/app/api/tools/clickhouse/kill-query/route.ts new file mode 100644 index 00000000000..c46f6d1393c --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/kill-query/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseKillQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseKillQuery } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseKillQueryAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse kill query attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseKillQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseKillQuery(params, params.queryId) + + return NextResponse.json({ + message: `Kill command executed for query '${params.queryId}'.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse kill query failed:`, error) + + return NextResponse.json( + { error: `ClickHouse kill query failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts b/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts new file mode 100644 index 00000000000..643c7be9621 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListClustersContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListClusters } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListClustersAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list clusters attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListClustersContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListClusters(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} cluster node(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list clusters failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list clusters failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-databases/route.ts b/apps/sim/app/api/tools/clickhouse/list-databases/route.ts new file mode 100644 index 00000000000..c524b162474 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-databases/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListDatabasesContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListDatabases } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListDatabasesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list databases attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListDatabasesContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListDatabases(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} database(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list databases failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list databases failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts b/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts new file mode 100644 index 00000000000..84034b42436 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListMutationsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListMutations } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListMutationsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list mutations attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListMutationsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListMutations(params, params.table, params.onlyRunning) + + return NextResponse.json({ + message: `Found ${result.rowCount} mutation(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list mutations failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list mutations failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts b/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts new file mode 100644 index 00000000000..d064850ad1f --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListPartitionsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListPartitions } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListPartitionsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list partitions attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListPartitionsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListPartitions(params, params.table) + + return NextResponse.json({ + message: `Found ${result.rowCount} partition(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list partitions failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list partitions failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts b/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts new file mode 100644 index 00000000000..d542966d5d0 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListRunningQueriesContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListRunningQueries } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListRunningQueriesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list running queries attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListRunningQueriesContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListRunningQueries(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} running query(ies).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list running queries failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list running queries failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-tables/route.ts b/apps/sim/app/api/tools/clickhouse/list-tables/route.ts new file mode 100644 index 00000000000..4d9df7a2dc7 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-tables/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListTablesContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListTables } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListTablesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list tables attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListTablesContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListTables(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} table(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list tables failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list tables failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts b/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts new file mode 100644 index 00000000000..3d22b8b3788 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseOptimizeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseOptimizeTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseOptimizeTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse optimize table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseOptimizeTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseOptimizeTable(params, params.table, params.final) + + return NextResponse.json({ + message: `Optimize submitted for table '${params.table}'.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse optimize table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse optimize table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/query/route.ts b/apps/sim/app/api/tools/clickhouse/query/route.ts new file mode 100644 index 00000000000..4d70b48b55b --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/query/route.ts @@ -0,0 +1,46 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseQueryAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse query attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Executing ClickHouse query on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseQuery(params, params.query, { enforceReadOnly: true }) + + logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`) + + return NextResponse.json({ + message: `Query executed successfully. ${result.rowCount} row(s) returned.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse query failed:`, error) + + return NextResponse.json({ error: `ClickHouse query failed: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/rename-table/route.ts b/apps/sim/app/api/tools/clickhouse/rename-table/route.ts new file mode 100644 index 00000000000..eec1f7ec436 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/rename-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseRenameTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseRenameTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseRenameTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse rename table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseRenameTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseRenameTable(params, params.table, params.newTable) + + return NextResponse.json({ + message: `Renamed table '${params.table}' to '${params.newTable}'.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse rename table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse rename table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts b/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts new file mode 100644 index 00000000000..8c93d402803 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseShowCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseShowCreateTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseShowCreateTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse show create table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseShowCreateTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const ddl = await executeClickHouseShowCreateTable(params, params.table) + + return NextResponse.json({ + message: 'Retrieved CREATE statement.', + ddl, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse show create table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse show create table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/table-stats/route.ts b/apps/sim/app/api/tools/clickhouse/table-stats/route.ts new file mode 100644 index 00000000000..405fbaf06cc --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/table-stats/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseTableStatsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseTableStats } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseTableStatsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse table stats attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseTableStatsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseTableStats(params, params.table) + + return NextResponse.json({ + message: `Retrieved stats for ${result.rowCount} table(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse table stats failed:`, error) + + return NextResponse.json( + { error: `ClickHouse table stats failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts b/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts new file mode 100644 index 00000000000..27452eb9849 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseTruncateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseTruncateTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseTruncateTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse truncate table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseTruncateTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseTruncateTable(params, params.table) + + return NextResponse.json({ + message: `Table '${params.table}' truncated.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse truncate table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse truncate table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/update/route.ts b/apps/sim/app/api/tools/clickhouse/update/route.ts new file mode 100644 index 00000000000..9d43755da4c --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/update/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseUpdateContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseUpdate } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseUpdateAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse update attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseUpdate(params, params.table, params.data, params.where) + + logger.info(`[${requestId}] Update mutation submitted, ${result.rowCount} row(s) written`) + + return NextResponse.json({ + message: `Update mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) written.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse update failed:`, error) + + return NextResponse.json( + { error: `ClickHouse update failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/utils.ts b/apps/sim/app/api/tools/clickhouse/utils.ts new file mode 100644 index 00000000000..ce4ad3afcbd --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/utils.ts @@ -0,0 +1,852 @@ +import { + validateDatabaseHost, + validateSqlWhereClause, +} from '@/lib/core/security/input-validation.server' +import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types' + +const REQUEST_TIMEOUT_MS = 30_000 + +interface ClickHouseSummary { + read_rows?: string + written_rows?: string + result_rows?: string +} + +interface ClickHouseHttpResult { + text: string + summary: ClickHouseSummary | null +} + +export interface ClickHouseRowsResult { + rows: unknown[] + rowCount: number +} + +interface ClickHouseColumnRow { + table: string + name: string + type: string + default_kind?: string + default_expression?: string + is_in_primary_key?: number | string + is_in_sorting_key?: number | string + position?: number | string +} + +interface ClickHouseTableRow { + name: string + engine?: string + total_rows?: number | string | null +} + +export interface ClickHouseIntrospectionResult { + tables: Array<{ + name: string + database: string + engine: string + totalRows?: number + columns: Array<{ + name: string + type: string + defaultKind?: string + defaultExpression?: string + isInPrimaryKey: boolean + isInSortingKey: boolean + }> + }> +} + +/** + * Sends a single statement to the ClickHouse HTTP interface and returns the raw + * response body alongside the parsed `X-ClickHouse-Summary` header. + * @see https://clickhouse.com/docs/interfaces/http + */ +async function clickhouseRequest( + config: ClickHouseConnectionConfig, + statement: string, + options: { readOnly?: boolean } = {} +): Promise { + const hostValidation = await validateDatabaseHost(config.host, 'host') + if (!hostValidation.isValid) { + throw new Error(hostValidation.error) + } + + const protocol = config.secure ? 'https' : 'http' + const url = new URL(`${protocol}://${config.host}:${config.port}/`) + url.searchParams.set('database', config.database) + if (options.readOnly) { + // Server-enforced read-only: ClickHouse rejects any write/DDL and forbids the + // query from re-enabling writes via `SET readonly=0`. This is the real boundary + // for the query operation; the SQL-shape checks below are defense-in-depth. + url.searchParams.set('readonly', '1') + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + let response: Response + try { + response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'X-ClickHouse-User': config.username, + 'X-ClickHouse-Key': config.password, + 'Content-Type': 'text/plain; charset=utf-8', + }, + body: statement, + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + } + + const text = await response.text() + + if (!response.ok) { + throw new Error(text.trim() || `ClickHouse request failed with status ${response.status}`) + } + + return { text, summary: parseSummary(response.headers.get('x-clickhouse-summary')) } +} + +function parseSummary(header: string | null): ClickHouseSummary | null { + if (!header) return null + try { + return JSON.parse(header) as ClickHouseSummary + } catch { + return null + } +} + +/** + * Parses a ClickHouse `FORMAT JSON` response body into rows, falling back to the + * summary header's row counts for statements that do not return a result set. + */ +function parseRowsResult(result: ClickHouseHttpResult): ClickHouseRowsResult { + const trimmed = result.text.trim() + if (trimmed) { + try { + const parsed = JSON.parse(trimmed) as { data?: unknown[]; rows?: number } + if (parsed && Array.isArray(parsed.data)) { + const rowCount = typeof parsed.rows === 'number' ? parsed.rows : parsed.data.length + return { rows: parsed.data, rowCount } + } + } catch { + // Body was not JSON (e.g. a non-SELECT statement); fall through to summary. + } + } + + const written = Number(result.summary?.written_rows ?? 0) + const read = Number(result.summary?.read_rows ?? 0) + return { rows: [], rowCount: written || read || 0 } +} + +/** Read-only statement leaders that return a result set and never mutate data. */ +const READ_ONLY_STATEMENT = /^(select|with|show|describe|desc|explain|exists)\b/i + +/** + * Normalizes the output format of a read statement to JSON so the HTTP response + * can always be parsed into rows. Strips every `FORMAT ` clause — wherever + * it sits relative to a trailing `SETTINGS` clause — and appends a single canonical + * `FORMAT JSON`. The `format()` function and `FORMAT`/format names appearing inside + * strings or comments are ignored (the scan runs on comment/string-masked SQL). + * Non-read statements are returned untouched (their own FORMAT, e.g. JSONEachRow + * for inserts, is preserved). + */ +function ensureJsonFormat(query: string): string { + const trimmed = query.trim().replace(/;+\s*$/, '') + if (!READ_ONLY_STATEMENT.test(trimmed)) { + return trimmed + } + const masked = maskSqlNoise(trimmed) + const formatClause = /\bformat\s+[a-z0-9_]+\b/gi + const spans: Array<[number, number]> = [] + for (let match = formatClause.exec(masked); match !== null; match = formatClause.exec(masked)) { + spans.push([match.index, match.index + match[0].length]) + } + let result = trimmed + for (let i = spans.length - 1; i >= 0; i--) { + result = result.slice(0, spans[i][0]) + result.slice(spans[i][1]) + } + return `${result.replace(/\s+$/, '')}\nFORMAT JSON` +} + +/** + * Replaces string literals ('...'), quoted identifiers ("..." / `...`), and SQL + * comments (`-- …` and `/* … *​/`) with spaces so that structural scans (e.g. for + * statement-chaining semicolons) only see actual SQL code, not data or comments. + */ +function maskSqlNoise(sql: string): string { + let out = '' + let i = 0 + while (i < sql.length) { + const ch = sql[i] + if (ch === "'" || ch === '"' || ch === '`') { + out += ' ' + i++ + while (i < sql.length && sql[i] !== ch) { + if (ch !== '`' && sql[i] === '\\') { + out += ' ' + i += 2 + continue + } + out += ' ' + i++ + } + if (i < sql.length) { + out += ' ' + i++ + } + continue + } + if (ch === '-' && sql[i + 1] === '-') { + const newline = sql.indexOf('\n', i + 2) + const end = newline === -1 ? sql.length : newline + out += ' '.repeat(end - i) + i = end + continue + } + if (ch === '/' && sql[i + 1] === '*') { + const close = sql.indexOf('*/', i + 2) + const end = close === -1 ? sql.length : close + 2 + out += ' '.repeat(end - i) + i = end + continue + } + out += ch + i++ + } + return out +} + +/** + * Detects whether a statement chains a second statement after a `;`, ignoring + * semicolons inside string literals, quoted identifiers, and comments. A trailing + * semicolon (with only whitespace/comments after it) is allowed. + */ +function hasChainedStatement(sql: string): boolean { + return /;\s*\S/.test(maskSqlNoise(sql)) +} + +/** + * Write/DDL statement shapes that must never run under the read-only query + * operation, even when wrapped by a leading `WITH` CTE (e.g. `WITH … INSERT INTO …`). + * Patterns require the keyword's statement context (e.g. `insert into`, `alter table`) + * so SQL functions/columns like `truncate(x)` or `created_at` are not false-positives. + */ +const MUTATING_STATEMENT = [ + /\binsert\s+into\b/i, + /\bdelete\s+from\b/i, + /\bupdate\s+[\w.`"]+\s+set\b/i, + /\balter\s+table\b/i, + /\b(?:create|attach)\s+(?:or\s+replace\s+)?(?:temporary\s+)?(?:table|database|dictionary|view|materialized\s+view|live\s+view|function|user|role)\b/i, + /\bdrop\s+(?:table|database|dictionary|view|column|partition|index|function|user|role)\b/i, + /\btruncate\s+table\b/i, + /\brename\s+(?:table|database|dictionary)\b/i, + /\bdetach\s+(?:table|database|dictionary|view|permanently)\b/i, + /\b(?:grant|revoke)\b/i, + /\boptimize\s+table\b/i, +] + +/** Whether a statement performs a write/DDL anywhere (comments and strings masked out). */ +function isMutatingStatement(sql: string): boolean { + const masked = maskSqlNoise(sql) + return MUTATING_STATEMENT.some((pattern) => pattern.test(masked)) +} + +/** + * Strips leading whitespace, `--`/`/* … *​/` comments, and opening parens from a + * statement so the read-only leader keyword can be detected even when a query + * starts with a comment (e.g. `-- note\nSELECT …`) or wrapping parens. + */ +function stripLeadingNoise(sql: string): string { + let s = sql.trim() + for (;;) { + if (s.startsWith('--')) { + const newline = s.indexOf('\n') + s = (newline === -1 ? '' : s.slice(newline + 1)).trim() + } else if (s.startsWith('/*')) { + const close = s.indexOf('*/') + s = (close === -1 ? '' : s.slice(close + 2)).trim() + } else if (s.startsWith('(')) { + s = s.slice(1).trim() + } else { + return s + } + } +} + +export async function executeClickHouseQuery( + config: ClickHouseConnectionConfig, + query: string, + options: { enforceReadOnly?: boolean } = {} +): Promise { + if (options.enforceReadOnly) { + // Strip leading comments/parens so wrapped or commented selects still validate. + const leader = stripLeadingNoise(query) + if (!READ_ONLY_STATEMENT.test(leader)) { + throw new Error( + 'The query operation only allows read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, EXISTS). Use the Execute Raw SQL operation to run writes or DDL.' + ) + } + if (hasChainedStatement(query)) { + throw new Error( + 'The query operation only allows a single statement; chained statements separated by ";" are not allowed. Use the Execute Raw SQL operation to run multiple statements.' + ) + } + if (isMutatingStatement(query)) { + throw new Error( + 'The query operation only allows read-only statements; a write or DDL statement (e.g. INSERT/ALTER/DROP, including after a WITH clause) was detected. Use the Execute Raw SQL operation instead.' + ) + } + } + const result = await clickhouseRequest(config, ensureJsonFormat(query), { + readOnly: options.enforceReadOnly, + }) + return parseRowsResult(result) +} + +export async function executeClickHouseInsert( + config: ClickHouseConnectionConfig, + table: string, + data: Record +): Promise { + const sanitizedTable = sanitizeIdentifier(table) + const statement = `INSERT INTO ${sanitizedTable} FORMAT JSONEachRow\n${JSON.stringify(data)}` + const result = await clickhouseRequest(config, statement) + const written = Number(result.summary?.written_rows ?? 0) + return { rows: [], rowCount: written || 1 } +} + +export async function executeClickHouseUpdate( + config: ClickHouseConnectionConfig, + table: string, + data: Record, + where: string +): Promise { + validateWhereClause(where) + const sanitizedTable = sanitizeIdentifier(table) + const assignments = Object.entries(data) + .map(([column, value]) => `${sanitizeIdentifier(column)} = ${formatValue(value)}`) + .join(', ') + + if (!assignments) { + throw new Error('Update data object cannot be empty') + } + + const statement = `ALTER TABLE ${sanitizedTable} UPDATE ${assignments} WHERE ${where}` + const result = await clickhouseRequest(config, statement) + return { rows: [], rowCount: Number(result.summary?.written_rows ?? 0) } +} + +export async function executeClickHouseDelete( + config: ClickHouseConnectionConfig, + table: string, + where: string +): Promise { + validateWhereClause(where) + const sanitizedTable = sanitizeIdentifier(table) + const statement = `ALTER TABLE ${sanitizedTable} DELETE WHERE ${where}` + const result = await clickhouseRequest(config, statement) + return { rows: [], rowCount: Number(result.summary?.written_rows ?? 0) } +} + +export async function executeClickHouseIntrospect( + config: ClickHouseConnectionConfig +): Promise { + const database = quoteString(config.database) + + const tablesResult = await clickhouseRequest( + config, + `SELECT name, engine, total_rows FROM system.tables WHERE database = ${database} ORDER BY name FORMAT JSON` + ) + const tableRows = parseDataArray(tablesResult.text) + + const columnsResult = await clickhouseRequest( + config, + `SELECT table, name, type, default_kind, default_expression, is_in_primary_key, is_in_sorting_key, position FROM system.columns WHERE database = ${database} ORDER BY table, position FORMAT JSON` + ) + const columnRows = parseDataArray(columnsResult.text) + + const columnsByTable = new Map< + string, + ClickHouseIntrospectionResult['tables'][number]['columns'] + >() + for (const column of columnRows) { + const columns = columnsByTable.get(column.table) ?? [] + columns.push({ + name: column.name, + type: column.type, + defaultKind: column.default_kind || undefined, + defaultExpression: column.default_expression || undefined, + isInPrimaryKey: toBoolean(column.is_in_primary_key), + isInSortingKey: toBoolean(column.is_in_sorting_key), + }) + columnsByTable.set(column.table, columns) + } + + const tables = tableRows.map((table) => ({ + name: table.name, + database: config.database, + engine: table.engine ?? '', + totalRows: table.total_rows != null ? Number(table.total_rows) : undefined, + columns: columnsByTable.get(table.name) ?? [], + })) + + return { tables } +} + +function parseDataArray(text: string): T[] { + const trimmed = text.trim() + if (!trimmed) return [] + try { + const parsed = JSON.parse(trimmed) as { data?: T[] } + return Array.isArray(parsed.data) ? parsed.data : [] + } catch { + return [] + } +} + +function toBoolean(value: number | string | undefined): boolean { + return value === 1 || value === '1' +} + +/** + * Quotes and escapes a value for inline use in a ClickHouse statement. + * Strings use ClickHouse's backslash escaping for single quotes and backslashes. + */ +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return 'NULL' + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : 'NULL' + } + if (typeof value === 'boolean') { + return value ? '1' : '0' + } + if (typeof value === 'object') { + return quoteString(JSON.stringify(value)) + } + return quoteString(String(value)) +} + +function quoteString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` +} + +/** + * Validates and backtick-quotes a ClickHouse identifier, supporting + * `database.table` qualified names. + */ +export function sanitizeIdentifier(identifier: string): string { + if (identifier.includes('.')) { + return identifier + .split('.') + .map((part) => sanitizeSingleIdentifier(part)) + .join('.') + } + return sanitizeSingleIdentifier(identifier) +} + +function sanitizeSingleIdentifier(identifier: string): string { + const cleaned = identifier.replace(/`/g, '') + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error( + `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.` + ) + } + return `\`${cleaned}\`` +} + +/** + * Rejects WHERE clauses containing SQL-injection or always-true tautology + * patterns so user-supplied conditions cannot broaden a mutation to every row. + * Delegates to the shared {@link validateSqlWhereClause} guard (defense-in-depth). + */ +function validateWhereClause(where: string): void { + const result = validateSqlWhereClause(where, 'WHERE clause') + if (!result.isValid) { + throw new Error(result.error) + } +} + +/** + * Runs a SELECT statement (which must already include `FORMAT JSON`) and returns + * the parsed rows and row count. + */ +async function runSelect( + config: ClickHouseConnectionConfig, + statement: string +): Promise { + const result = await clickhouseRequest(config, statement) + return parseRowsResult(result) +} + +/** + * Runs a statement that does not return a result set (DDL or mutation) and + * returns the number of written rows reported by the summary header. + */ +async function runStatement( + config: ClickHouseConnectionConfig, + statement: string +): Promise { + const result = await clickhouseRequest(config, statement) + return Number(result.summary?.written_rows ?? 0) +} + +/** + * Validates a free-form SQL expression (ORDER BY, PARTITION BY, engine args) + * rejecting statement terminators and comment sequences. + */ +function validateExpression(expression: string, label: string): void { + if (/;|--|\/\*|\*\//.test(expression)) { + throw new Error(`${label} contains a disallowed character`) + } +} + +/** + * Validates an ORDER BY / PARTITION BY expression that is spliced inside wrapping + * parentheses in the generated DDL. In addition to rejecting terminators/comments, + * it requires balanced parentheses (quote-aware) so the expression cannot close + * the wrapping `(...)` early and append extra clauses (e.g. `id) SETTINGS …`). + */ +function validateClauseExpression(expression: string, label: string): void { + const trimmed = expression.trim() + if (!trimmed) { + throw new Error(`${label} is required`) + } + if (/;|--|\/\*|\*\//.test(trimmed)) { + throw new Error(`${label} contains a disallowed sequence`) + } + let depth = 0 + let inString = false + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i] + if (inString) { + if (ch === '\\') i++ + else if (ch === "'") inString = false + continue + } + if (ch === "'") inString = true + else if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth < 0) { + throw new Error(`${label} has unbalanced parentheses`) + } + } + } + if (inString || depth !== 0) { + throw new Error(`${label} has unbalanced parentheses or quotes`) + } +} + +/** + * Validates a partition value for `DROP PARTITION`. ClickHouse partition values + * are literals (signed numbers or single-quoted strings) or a parenthesised tuple + * of such literals, so anything else is rejected — barewords like `ALL`, function + * calls, operators, and extra tokens that could broaden the statement beyond + * dropping a single partition. + */ +function validatePartitionExpression(partition: string): void { + const partitionPattern = + /^\(?\s*(?:'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?)(?:\s*,\s*(?:'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?))*\s*\)?$/ + if (!partitionPattern.test(partition.trim())) { + throw new Error( + "Partition must be a literal value or a tuple of literals (number or single-quoted string), e.g. 202401, '2024-01', or (2024, 'EU')" + ) + } +} + +export function executeClickHouseListDatabases( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + 'SELECT name, engine, comment FROM system.databases ORDER BY name FORMAT JSON' + ) +} + +export function executeClickHouseListTables( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + `SELECT name, engine, total_rows AS totalRows, total_bytes AS totalBytes, comment FROM system.tables WHERE database = ${quoteString(config.database)} ORDER BY name FORMAT JSON` + ) +} + +export function executeClickHouseDescribeTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + const tableName = stripDatabasePrefix(table) + return runSelect( + config, + `SELECT name, type, default_kind AS defaultKind, default_expression AS defaultExpression, comment, is_in_primary_key AS isInPrimaryKey, is_in_sorting_key AS isInSortingKey FROM system.columns WHERE database = ${quoteString(config.database)} AND table = ${quoteString(tableName)} ORDER BY position FORMAT JSON` + ) +} + +export async function executeClickHouseShowCreateTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + const result = await runSelect( + config, + `SHOW CREATE TABLE ${sanitizeIdentifier(table)} FORMAT JSON` + ) + const firstRow = result.rows[0] as Record | undefined + if (!firstRow) { + return '' + } + // ClickHouse returns the DDL in a single String column (named `statement`); + // fall back to the first column value to stay robust to column-name changes. + const value = firstRow.statement ?? Object.values(firstRow)[0] + return typeof value === 'string' ? value : '' +} + +export async function executeClickHouseCountRows( + config: ClickHouseConnectionConfig, + table: string, + where?: string +): Promise { + let statement = `SELECT count() AS count FROM ${sanitizeIdentifier(table)}` + if (where?.trim()) { + validateWhereClause(where) + statement += ` WHERE ${where}` + } + const result = await runSelect(config, `${statement} FORMAT JSON`) + const firstRow = result.rows[0] as { count?: number | string } | undefined + return firstRow?.count != null ? Number(firstRow.count) : 0 +} + +export function executeClickHouseListPartitions( + config: ClickHouseConnectionConfig, + table: string +): Promise { + const tableName = stripDatabasePrefix(table) + return runSelect( + config, + `SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytesOnDisk FROM system.parts WHERE database = ${quoteString(config.database)} AND table = ${quoteString(tableName)} AND active GROUP BY partition ORDER BY partition FORMAT JSON` + ) +} + +export function executeClickHouseListMutations( + config: ClickHouseConnectionConfig, + table?: string, + onlyRunning = false +): Promise { + const filters = [`database = ${quoteString(config.database)}`] + if (table?.trim()) { + filters.push(`table = ${quoteString(stripDatabasePrefix(table))}`) + } + if (onlyRunning) { + filters.push('is_done = 0') + } + return runSelect( + config, + `SELECT table, mutation_id AS mutationId, command, create_time AS createTime, is_done AS isDone, parts_to_do AS partsToDo, latest_fail_reason AS latestFailReason FROM system.mutations WHERE ${filters.join(' AND ')} ORDER BY create_time DESC FORMAT JSON` + ) +} + +export function executeClickHouseListRunningQueries( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + 'SELECT query_id AS queryId, user, toFloat64(elapsed) AS elapsedSeconds, formatReadableSize(memory_usage) AS memoryUsage, query FROM system.processes ORDER BY elapsed DESC FORMAT JSON' + ) +} + +export function executeClickHouseTableStats( + config: ClickHouseConnectionConfig, + table?: string +): Promise { + const filters = ['active', `database = ${quoteString(config.database)}`] + if (table?.trim()) { + filters.push(`table = ${quoteString(stripDatabasePrefix(table))}`) + } + return runSelect( + config, + `SELECT database, table, sum(rows) AS rows, sum(bytes_on_disk) AS bytesOnDisk, formatReadableSize(sum(bytes_on_disk)) AS sizeOnDisk, count() AS parts FROM system.parts WHERE ${filters.join(' AND ')} GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC FORMAT JSON` + ) +} + +export function executeClickHouseListClusters( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + 'SELECT cluster, shard_num AS shardNum, replica_num AS replicaNum, host_name AS hostName, port, is_local AS isLocal FROM system.clusters ORDER BY cluster, shard_num, replica_num FORMAT JSON' + ) +} + +export async function executeClickHouseCreateDatabase( + config: ClickHouseConnectionConfig, + name: string +): Promise { + await clickhouseRequest(config, `CREATE DATABASE IF NOT EXISTS ${sanitizeIdentifier(name)}`) +} + +export async function executeClickHouseDropDatabase( + config: ClickHouseConnectionConfig, + name: string +): Promise { + await clickhouseRequest(config, `DROP DATABASE IF EXISTS ${sanitizeIdentifier(name)}`) +} + +/** + * Validates a single ClickHouse column type. Types may legitimately contain + * commas, single-quoted strings, `=`, and `-` inside their parameter parentheses + * (e.g. `Decimal(10, 2)`, `Enum8('a' = 1, 'b' = -2)`, `Map(String, UInt64)`, + * `Array(Tuple(a UInt8, b String))`). We allow those but reject anything that + * could break out of the single type literal and inject another column or SQL: + * comment/terminator sequences, a top-level (unparenthesised) comma, or an + * unbalanced closing paren. + */ +function validateColumnType(type: string): void { + const trimmed = type.trim() + if (!trimmed || !/^[A-Za-z_]/.test(trimmed)) { + throw new Error(`Invalid column type: ${type}`) + } + if (!/^[A-Za-z0-9_(),.\s'"=-]+$/.test(trimmed) || /--|;/.test(trimmed)) { + throw new Error(`Invalid column type: ${type}`) + } + let depth = 0 + let inString = false + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i] + if (inString) { + if (ch === '\\') i++ + else if (ch === "'") inString = false + continue + } + if (ch === "'") inString = true + else if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth < 0) throw new Error(`Invalid column type: ${type}`) + } else if (ch === ',' && depth === 0) { + throw new Error(`Invalid column type: ${type}`) + } + } + if (inString || depth !== 0) { + throw new Error(`Invalid column type: ${type}`) + } +} + +export async function executeClickHouseCreateTable( + config: ClickHouseConnectionConfig, + table: string, + columns: Array<{ name: string; type: string }>, + engine: string, + orderBy: string, + partitionBy?: string +): Promise { + if (!Array.isArray(columns) || columns.length === 0) { + throw new Error('At least one column definition is required') + } + + const columnDefs = columns.map((column) => { + if (!column?.name || !column?.type) { + throw new Error('Each column requires a name and type') + } + validateColumnType(column.type) + return `${sanitizeIdentifier(column.name)} ${column.type.trim()}` + }) + + if (!/^[A-Za-z][A-Za-z0-9]*(\(.*\))?$/.test(engine.trim())) { + throw new Error(`Invalid table engine: ${engine}`) + } + validateExpression(engine, 'Engine') + + if (!orderBy?.trim()) { + throw new Error('ORDER BY expression is required') + } + validateClauseExpression(orderBy, 'ORDER BY') + + let statement = `CREATE TABLE IF NOT EXISTS ${sanitizeIdentifier(table)} (${columnDefs.join(', ')}) ENGINE = ${engine.trim()}` + if (partitionBy?.trim()) { + validateClauseExpression(partitionBy, 'PARTITION BY') + statement += ` PARTITION BY (${partitionBy.trim()})` + } + statement += ` ORDER BY (${orderBy.trim()})` + + await clickhouseRequest(config, statement) +} + +export async function executeClickHouseDropTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + await clickhouseRequest(config, `DROP TABLE IF EXISTS ${sanitizeIdentifier(table)}`) +} + +export async function executeClickHouseTruncateTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + await clickhouseRequest(config, `TRUNCATE TABLE IF EXISTS ${sanitizeIdentifier(table)}`) +} + +export async function executeClickHouseRenameTable( + config: ClickHouseConnectionConfig, + fromTable: string, + toTable: string +): Promise { + await clickhouseRequest( + config, + `RENAME TABLE ${sanitizeIdentifier(fromTable)} TO ${sanitizeIdentifier(toTable)}` + ) +} + +export async function executeClickHouseOptimizeTable( + config: ClickHouseConnectionConfig, + table: string, + final: boolean +): Promise { + await clickhouseRequest( + config, + `OPTIMIZE TABLE ${sanitizeIdentifier(table)}${final ? ' FINAL' : ''}` + ) +} + +export async function executeClickHouseDropPartition( + config: ClickHouseConnectionConfig, + table: string, + partition: string +): Promise { + validatePartitionExpression(partition) + await clickhouseRequest( + config, + `ALTER TABLE ${sanitizeIdentifier(table)} DROP PARTITION ${partition.trim()}` + ) +} + +export function executeClickHouseKillQuery( + config: ClickHouseConnectionConfig, + queryId: string +): Promise { + return runSelect(config, `KILL QUERY WHERE query_id = ${quoteString(queryId)} SYNC FORMAT JSON`) +} + +export async function executeClickHouseInsertRows( + config: ClickHouseConnectionConfig, + table: string, + rows: Array> +): Promise { + if (!Array.isArray(rows) || rows.length === 0) { + throw new Error('At least one row is required') + } + const sanitizedTable = sanitizeIdentifier(table) + const payload = rows.map((row) => JSON.stringify(row)).join('\n') + const statement = `INSERT INTO ${sanitizedTable} FORMAT JSONEachRow\n${payload}` + const written = await runStatement(config, statement) + return { rows: [], rowCount: written || rows.length } +} + +function stripDatabasePrefix(table: string): string { + const parts = table.split('.') + return parts[parts.length - 1].replace(/`/g, '') +} diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index 6476b53f5c9..e6560c71f64 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -39,7 +39,12 @@ const MAX_IMAGE_BYTES = 25 * 1024 * 1024 const MAX_IMAGE_JSON_BYTES = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 256 * 1024 export const dynamic = 'force-dynamic' -export const maxDuration = 600 +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the provider polling loop below. Next.js requires a + * static literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 type ImageProvider = (typeof imageProviders)[number] diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index a320bcce008..fd9a6dc12d7 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -7,7 +7,7 @@ import { sttToolContract } from '@/lib/api/contracts/tools/media/stt' import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { secureFetchWithPinnedIP, validateUrlWithDNS, @@ -25,7 +25,12 @@ const logger = createLogger('SttProxyAPI') const ELEVENLABS_STT_MODEL = 'scribe_v2' export const dynamic = 'force-dynamic' -export const maxDuration = 300 // 5 minutes for large files +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the transcript polling loop below. Next.js requires a + * static literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() @@ -629,7 +634,7 @@ async function transcribeWithAssemblyAI( let transcript: any let attempts = 0 const pollIntervalMs = 5000 - const maxAttempts = Math.ceil(DEFAULT_EXECUTION_TIMEOUT_MS / pollIntervalMs) + const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs) while (attempts < maxAttempts) { const statusResponse = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, { diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 48e6f07899f..b93cbbed4d9 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { validateS3BucketName } from '@/lib/core/security/input-validation' import { secureFetchWithPinnedIP, @@ -22,7 +22,12 @@ import { import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' -export const maxDuration = 300 // 5 minutes for large multi-page PDF processing +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the job polling loop below. Next.js requires a static + * literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 const logger = createLogger('TextractParseAPI') @@ -184,7 +189,7 @@ async function pollForJobCompletion( requestId: string ): Promise> { const pollIntervalMs = 5000 - const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS + const maxPollTimeMs = getMaxExecutionTimeout() const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) const getTarget = useAnalyzeDocument diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 1110432a473..9980121ed4c 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -28,7 +28,12 @@ const MAX_VIDEO_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024 const MAX_VIDEO_JSON_BYTES = 2 * 1024 * 1024 export const dynamic = 'force-dynamic' -export const maxDuration = 600 // 10 minutes for video generation +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the provider polling loops below. Next.js requires a + * static literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 async function readVideoResponseBuffer(response: Response, label: string): Promise { return readResponseToBufferWithLimit(response, { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index e736a859eaa..55cf776dc7b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -1,8 +1,5 @@ -import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { type V1BatchInsertTableRowsBody, @@ -24,13 +21,13 @@ import { deleteRowsByFilter, deleteRowsByIds, insertRow, - USER_TABLE_ROWS_SQL_NAME, updateRowsByFilter, validateBatchRows, validateRowData, validateRowSize, } from '@/lib/table' -import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql' +import { queryRows } from '@/lib/table/service' +import { TableQueryValidationError } from '@/lib/table/sql' import { accessError, checkAccess } from '@/app/api/table/utils' import { checkRateLimit, @@ -153,92 +150,33 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const baseConditions = [ - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId), - ] - - const schema = table.schema as TableSchema - - if (validated.filter) { - const filterClause = buildFilterClause( - validated.filter as Filter, - USER_TABLE_ROWS_SQL_NAME, - schema.columns - ) - if (filterClause) { - baseConditions.push(filterClause) - } - } - - let query = db - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where(and(...baseConditions)) - - if (validated.sort) { - const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) - if (sortClause) { - query = query.orderBy(sortClause) as typeof query - } else { - query = query.orderBy(userTableRows.position) as typeof query - } - } else { - query = query.orderBy(userTableRows.position) as typeof query - } - - const rowsPromise = query.limit(validated.limit).offset(validated.offset) - - let totalCount: number | null = null - if (validated.includeTotal) { - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) - const [countResult, rows] = await Promise.all([countQuery, rowsPromise]) - totalCount = Number(countResult[0].count) - return NextResponse.json({ - success: true, - data: { - rows: rows.map((r) => ({ - id: r.id, - data: r.data, - position: r.position, - createdAt: - r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: - r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: rows.length, - totalCount, - limit: validated.limit, - offset: validated.offset, - }, - }) - } - - const rows = await rowsPromise + const result = await queryRows( + table, + { + filter: validated.filter as Filter | undefined, + sort: validated.sort, + limit: validated.limit, + offset: validated.offset, + includeTotal: validated.includeTotal, + withExecutions: false, + }, + requestId + ) return NextResponse.json({ success: true, data: { - rows: rows.map((r) => ({ + rows: result.rows.map((r) => ({ id: r.id, data: r.data, position: r.position, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), })), - rowCount: rows.length, - totalCount, - limit: validated.limit, - offset: validated.offset, + rowCount: result.rowCount, + totalCount: result.totalCount, + limit: result.limit, + offset: result.offset, }, }) } catch (error) { diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 5bd6ac264bf..eb2f0e5c3e7 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -38,6 +38,49 @@ export interface ChatMessage { files?: ChatFile[] } +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} as const + +/** + * Escapes HTML entities so untrusted strings are safe to interpolate into markup. + */ +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] || c) +} + +/** + * Opens an image attachment preview in a new tab via a blob URL, + * escaping the user-controlled filename and data URL to prevent XSS. + */ +function openAttachmentPreview(name: string, dataUrl: string): void { + const safeName = escapeHtml(name) + const safeUrl = escapeHtml(dataUrl) + const html = ` + + + + ${safeName} + + + + ${safeName} + + + ` + const blob = new Blob([html], { type: 'text/html' }) + const blobUrl = URL.createObjectURL(blob) + window.open(blobUrl, '_blank', 'noopener,noreferrer') + setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000) +} + export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) @@ -103,25 +146,7 @@ export const ClientChatMessage = memo( if (validDataUrl?.startsWith('data:')) { e.preventDefault() e.stopPropagation() - const newWindow = window.open('', '_blank') - if (newWindow) { - newWindow.document.write(` - - - - ${attachment.name} - - - - ${attachment.name} - - - `) - newWindow.document.close() - } + openAttachmentPreview(attachment.name, validDataUrl) } }} onKeyDown={(event) => { @@ -129,17 +154,7 @@ export const ClientChatMessage = memo( if (!validDataUrl?.startsWith('data:')) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - const newWindow = window.open('', '_blank') - if (newWindow) { - newWindow.document.write(` - - ${attachment.name} - - ${attachment.name} - - - `) - } + openAttachmentPreview(attachment.name, validDataUrl) } }} > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 065385a9f05..c4c7904a711 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -22,6 +22,7 @@ export type CellRenderKind = | { kind: 'error' } | { kind: 'waiting'; labels: string[] } | { kind: 'not-found' } + | { kind: 'no-output' } // Plain typed cells | { kind: 'boolean'; checked: boolean } | { kind: 'json'; text: string } @@ -106,6 +107,9 @@ export function resolveCellRender({ if (exec?.status === 'error') return { kind: 'error' } // Enrichment ran to completion but matched nothing → "Not found". if (isEnrichmentOutput && exec?.status === 'completed') return { kind: 'not-found' } + // Workflow output: the group's run completed but this block produced no + // value for the cell → grey "No output" (distinct from a never-run blank). + if (exec?.status === 'completed') return { kind: 'no-output' } return { kind: 'empty' } } @@ -394,6 +398,15 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) + case 'no-output': + return ( + + + No output + + + ) + case 'empty': return null diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index 219a3376e78..c6b1703d0f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -194,10 +194,28 @@ export const DataRow = React.memo(function DataRow({ }, [workflowGroups, row]) const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) const isRowSelected = isRowChecked + /** + * Whether the selection's left edge sits at column 0 for this row. The blue + * edge is drawn inside the sticky checkbox cell — over its gray right + * border — rather than as the col-0 overlay's `border-l`, so the sticky + * cell can never paint over it and the gray/blue lines never double up at + * the column boundary. The strip overlaps the row gridlines (`-top-px` / + * `-bottom-px`) so consecutive selected rows form one continuous line. + */ + const rowInRange = sel !== null && rowIndex >= sel.startRow && rowIndex <= sel.endRow + const isLeftEdgeSelected = isRowChecked || (isMultiCell && rowInRange && sel!.startCol === 0) return ( onContextMenu(e, row)}> + {isLeftEdgeSelected && ( +
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 463927819f7..f6218e2bac2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -237,6 +237,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ setMenuOpen(true) } + // Column whose workflow source block was deleted — the header icon swaps to + // `WorkflowX` with an explanatory tooltip. + const blockMissing = Boolean(sourceInfo?.blockMissing) + return ( {column.workflowGroupId ? column.headerLabel : column.name} @@ -305,6 +311,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ type={column.type} isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'} blockIconInfo={sourceInfo?.blockIconInfo} + blockMissing={blockMissing} /> {column.workflowGroupId ? column.headerLabel : column.name} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx index e6a5a015f30..d8c7bbded1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -1,6 +1,7 @@ 'use client' import type React from 'react' +import { Tooltip } from '@/components/emcn' import { Calendar as CalendarIcon, PlayOutline, @@ -8,6 +9,7 @@ import { TypeJson, TypeNumber, TypeText, + WorkflowX, } from '@/components/emcn/icons' import type { BlockIconInfo } from '../types' @@ -32,16 +34,39 @@ interface ColumnTypeIconProps { * ignored — icons render in the plain `text-[var(--text-icon)]` tone like * every other column-type icon, no per-block tint. */ blockIconInfo?: BlockIconInfo + /** Workflow-output column whose source block no longer exists in the + * workflow — renders the `WorkflowX` "not found" icon with a tooltip. */ + blockMissing?: boolean } /** * Tiny icon shown next to a column header. Workflow-output columns get the * producing block's icon (falling back to `PlayOutline`); plain columns get * their scalar type icon. Both render in the same `text-[var(--text-icon)]` - * tone — no per-workflow color, no colored swatch. + * tone — no per-workflow color, no colored swatch. A workflow column whose + * source block was deleted renders a `WorkflowX` with an explanatory tooltip. */ -export function ColumnTypeIcon({ type, isWorkflowColumn, blockIconInfo }: ColumnTypeIconProps) { +export function ColumnTypeIcon({ + type, + isWorkflowColumn, + blockIconInfo, + blockMissing, +}: ColumnTypeIconProps) { if (isWorkflowColumn) { + if (blockMissing) { + return ( + + + + + + + + This column's source block no longer exists in the workflow. + + + ) + } const Icon = blockIconInfo?.icon ?? PlayOutline return } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index de9888b539c..f5057e602df 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -837,9 +837,12 @@ export function TableGrid({ function handleInsertRow(offset: 0 | 1) { if (!contextMenu.row) return + const anchorId = contextMenu.row.id + // Fractional ordering: express intent by neighbor id, not integer position. + const intent = offset === 0 ? { beforeRowId: anchorId } : { afterRowId: anchorId } const position = contextMenu.row.position + offset createRef.current( - { data: {}, position }, + { data: {}, ...intent }, { onSuccess: (response: Record) => { const newRowId = extractCreatedRowId(response) @@ -904,7 +907,7 @@ export function TableGrid({ const sourceArrayIndex = rowsRef.current.findIndex((r) => r.id === contextRow.id) closeContextMenu() createRef.current( - { data: rowData, position }, + { data: rowData, afterRowId: contextRow.id }, { onSuccess: (response: Record) => { const newRowId = extractCreatedRowId(response) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts index 431cfc48789..af5cceea88c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts @@ -9,6 +9,9 @@ export interface BlockIconInfo { export interface ColumnSourceInfo { blockIconInfo?: BlockIconInfo blockName?: string + /** Workflow loaded but the column's source block no longer exists — the + * header renders a "Not found" badge. Only set for loaded states. */ + blockMissing?: boolean } /** diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 6a7f53b2185..46d3bcac739 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -302,7 +302,12 @@ export function computeNormalizedSelection( export function collectRowSnapshots(rows: Iterable): DeletedRowSnapshot[] { const snapshots: DeletedRowSnapshot[] = [] for (const row of rows) { - snapshots.push({ rowId: row.id, data: { ...row.data }, position: row.position }) + snapshots.push({ + rowId: row.id, + data: { ...row.data }, + position: row.position, + orderKey: row.orderKey, + }) } return snapshots } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx index c667fc04c08..e255938c0be 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx @@ -39,7 +39,6 @@ export function InputMappingSection({
{namedFields.length === 0 ? (

diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index f36cb0ac0ae..fa7c0f8cc30 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -8,6 +8,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { ExternalLink, RepeatIcon, SplitIcon, X } from 'lucide-react' import { Button, + ButtonGroup, + ButtonGroupItem, Combobox, type ComboboxOptionGroup, FieldDivider, @@ -34,6 +36,7 @@ import type { ColumnDefinition, WorkflowGroup, WorkflowGroupDependencies, + WorkflowGroupDeploymentMode, WorkflowGroupInputMapping, WorkflowGroupOutput, } from '@/lib/table' @@ -347,6 +350,11 @@ export function WorkflowSidebarBody({ const [autoRun, setAutoRun] = useState(() => existingGroup ? existingGroup.autoRun !== false : false ) + // Which workflow state per-cell runs execute against. Defaults to `'live'` + // (the editable draft) for both new and pre-feature groups. + const [deploymentMode, setDeploymentMode] = useState( + () => existingGroup?.deploymentMode ?? 'live' + ) // Deps default to none selected. With auto-run on, at least one is required // (enforced via `depsValid` below); a legacy group with empty deps will // surface the error on first open until the user picks at least one column. @@ -709,6 +717,7 @@ export function WorkflowSidebarBody({ outputs: fullOutputs, ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), inputMappings: inputMappingsList, + deploymentMode, autoRun, }) toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) @@ -740,6 +749,7 @@ export function WorkflowSidebarBody({ dependencies, outputs: groupOutputs, inputMappings: inputMappingsList, + deploymentMode, autoRun, } await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) @@ -1027,12 +1037,31 @@ export function WorkflowSidebarBody({

{showAdvanced && ( - + <> + {!isEnrichment && ( + <> +
+ + + setDeploymentMode(v === 'deployed' ? 'deployed' : 'live') + } + > + Live + Deployed + +
+ + + )} + + )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 78eb7e96103..29cfbfd9478 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import type { ActiveDispatch } from '@/lib/api/contracts/tables' -import type { RowData, RowExecutionMetadata, RowExecutions } from '@/lib/table' +import type { RowData, RowExecutionMetadata, RowExecutions, TableDefinition } from '@/lib/table' import { isExecInFlight } from '@/lib/table/deps' import type { TableEvent, TableEventEntry } from '@/lib/table/events' import { snapshotAndMutateRows, type TableRunState, tableKeys } from '@/hooks/queries/tables' @@ -92,6 +92,17 @@ export function useTableEventStream({ }, DISPATCH_INVALIDATE_DEBOUNCE_MS) } + // Live-fill: import progress ticks arrive every N rows; coalesce the row + // refetches into one per debounce window instead of refetching per tick. + let importInvalidateTimer: ReturnType | null = null + const scheduleRowsInvalidate = (): void => { + if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer) + importInvalidateTimer = setTimeout(() => { + importInvalidateTimer = null + void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + }, DISPATCH_INVALIDATE_DEBOUNCE_MS) + } + // Keeps the per-row gutter (`runningByRowId`) live between dispatch events. // `runningCellCount` (the "X running" badge) is NOT touched here — it's the // server's dispatch-scope count, seeded optimistically on click and @@ -213,6 +224,41 @@ export function useTableEventStream({ scheduleDispatchInvalidate() } + const applyImport = (event: Extract): void => { + const { status, progress, error, importId } = event + const isTerminal = status === 'ready' || status === 'failed' || status === 'canceled' + + // The SSE buffer replays on (re)connect and can hold a *prior* import's events for this + // table. Ignore anything from a superseded run, and don't trust a replayed terminal before + // we know the active run's id. + const prev = queryClient.getQueryData(tableKeys.detail(tableId)) + const lockedId = prev?.importId + if (lockedId && importId && importId !== lockedId) return + if (!lockedId && isTerminal) return + + queryClient.setQueryData(tableKeys.detail(tableId), (p) => + p + ? { + ...p, + importStatus: status, + importId: importId ?? p.importId, + importRowsProcessed: progress ?? p.importRowsProcessed, + importError: error ?? null, + } + : p + ) + // The header tray + completion toast are owned by `useImportTrayPoll`. Here we only keep the + // detail cache + grid in sync: live-fill rows per batch (debounced), and on the terminal + // event refetch rows + the definition (the worker may have rewritten the schema). + if (isTerminal) { + if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer) + void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) + } else { + scheduleRowsInvalidate() + } + } + const applyUsageLimit = (event: Extract): void => { // Drop the halted dispatch from the overlay so the "running" UI clears // immediately (the dispatcher was marked complete server-side). Cascade / @@ -283,6 +329,7 @@ export function useTableEventStream({ savePointer(tableId, lastEventId) if (entry.event?.kind === 'cell') applyCell(entry.event) else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event) + else if (entry.event?.kind === 'import') applyImport(entry.event) else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event) } catch (err) { logger.warn('Failed to parse table event', { tableId, err }) @@ -317,6 +364,7 @@ export function useTableEventStream({ cancelled = true if (reconnectTimer !== null) clearTimeout(reconnectTimer) if (dispatchInvalidateTimer !== null) clearTimeout(dispatchInvalidateTimer) + if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer) eventSource?.close() eventSource = null } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 211c623f2a6..844c87a7c3c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -195,6 +195,10 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) if (group.type === 'enrichment') continue const state = workflowStates.get(group.workflowId) const blocks = (state as { blocks?: Record } | null)?.blocks + // `useWorkflowStates` only fetches the live draft, so we can only judge + // "block missing" for live-mode groups. A deployed-mode group runs a + // different graph we don't load client-side — don't risk a false badge. + const isLiveMode = group.deploymentMode !== 'deployed' for (const out of group.outputs) { const block = blocks?.[out.blockId] const blockConfig = block?.type ? getBlock(block.type) : undefined @@ -202,7 +206,10 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) ? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' } : undefined const blockName = block?.name?.trim() || undefined - map.set(out.columnName, { blockIconInfo, blockName }) + // Flag a missing source block only once the workflow state has loaded + // (truthy `blocks`), so a still-loading workflow never flashes the badge. + const blockMissing = Boolean(isLiveMode && blocks && out.blockId && !block) + map.set(out.columnName, { blockIconInfo, blockName, blockMissing }) } } return map diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 5ba7f380d01..55993d1fa63 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -27,6 +27,7 @@ import { import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' +import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' import { useLogByExecutionId } from '@/hooks/queries/logs' import { downloadTableExport, @@ -519,13 +520,16 @@ export function Table({ createTrigger={createTrigger} actions={headerActions} leadingActions={ - selection.totalRunning > 0 || selection.hasActiveDispatch ? ( - - ) : null + <> + + {selection.totalRunning > 0 || selection.hasActiveDispatch ? ( + + ) : null} + } /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index b6f7e5becaa..b104f5c6c34 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -25,14 +25,24 @@ import { toast, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants' import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' -import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables' +import { + type CsvImportMode, + cancelTableImport, + useImportCsvIntoTable, + useImportCsvIntoTableAsync, +} from '@/hooks/queries/tables' +import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('ImportCsvDialog') const MAX_SAMPLE_ROWS = 5 const MAX_EXAMPLES_IN_ERROR = 3 +/** Bytes read for the preview/mapping. We never parse the whole file client-side — the importer + * streams it server-side and the DB trigger enforces the row limit. */ +const CSV_PREVIEW_BYTES = 512 * 1024 /** * Sentinel value for the "Do not import" option in the mapping combobox. The * whitespace is intentional: valid column names must match `NAME_PATTERN` @@ -94,7 +104,18 @@ interface ParsedCsv { file: File headers: string[] sampleRows: Record[] - totalRows: number +} + +/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */ +async function parseCsvPreview(file: File, delimiter: ',' | '\t') { + const sliced = file.size > CSV_PREVIEW_BYTES + const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file + let bytes = new Uint8Array(await blob.arrayBuffer()) + if (sliced) { + const lastNewline = bytes.lastIndexOf(0x0a) + if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1) + } + return parseCsvBuffer(bytes, delimiter) } export function ImportCsvDialog({ @@ -114,6 +135,7 @@ export function ImportCsvDialog({ const [isDragging, setIsDragging] = useState(false) const fileInputRef = useRef(null) const importMutation = useImportCsvIntoTable() + const importAsyncMutation = useImportCsvIntoTableAsync() function resetState() { setParsed(null) @@ -161,15 +183,13 @@ export function ImportCsvDialog({ setParsing(true) setParseError(null) try { - const arrayBuffer = await file.arrayBuffer() - const delimiter = ext === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter) + const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ',' + const { headers, rows } = await parseCsvPreview(file, delimiter) const autoMapping = buildAutoMapping(headers, table.schema) setParsed({ file, headers, sampleRows: rows.slice(0, MAX_SAMPLE_ROWS), - totalRows: rows.length, }) setMapping(autoMapping) } catch (err) { @@ -283,28 +303,69 @@ export function ImportCsvDialog({ } }, [mapping, parsed?.headers, table.schema.columns, createHeaders]) - const appendCapacityDeficit = - parsed && mode === 'append' && table.rowCount + parsed.totalRows > table.maxRows - ? table.rowCount + parsed.totalRows - table.maxRows - : 0 - - const replaceCapacityDeficit = - parsed && mode === 'replace' && parsed.totalRows > table.maxRows - ? parsed.totalRows - table.maxRows - : 0 - const canSubmit = parsed !== null && !importMutation.isPending && + !importAsyncMutation.isPending && missingRequired.length === 0 && duplicateTargets.length === 0 && - mappedCount + createCount > 0 && - appendCapacityDeficit === 0 && - replaceCapacityDeficit === 0 + mappedCount + createCount > 0 async function handleSubmit() { if (!parsed || !canSubmit) return setSubmitError(null) + const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined + + // Large files can't be POSTed through the server (request-body cap) — upload them + // straight to storage and import in the background instead. Seed the header tray and + // close the dialog immediately so the indicator is visible during the upload, then run + // the upload + kickoff in the background (don't block the dialog on it). + if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) { + useImportTrayStore.getState().startUpload({ + uploadId: table.id, + workspaceId, + title: parsed.file.name, + }) + onOpenChange(false) + toast({ + message: `Importing "${parsed.file.name}" into "${table.name}"…`, + action: { + label: 'View', + onClick: () => useImportTrayStore.getState().setMenuOpen(true), + }, + }) + importAsyncMutation.mutate( + { + workspaceId, + tableId: table.id, + file: parsed.file, + mode, + mapping, + createColumns, + onProgress: (percent) => { + useImportTrayStore.getState().setUploadPercent(table.id, percent) + }, + }, + { + onSuccess: (data) => { + useImportTrayStore.getState().endUpload(table.id) + // The server row drives the tray once the list refetches. If canceled mid-upload, flag + // the id so it's not shown and cancel the worker server-side. + if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) { + useImportTrayStore.getState().cancel(table.id) + void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {}) + } + }, + onError: (err) => { + useImportTrayStore.getState().endUpload(table.id) + toast.error(getErrorMessage(err, 'Failed to start import')) + logger.error('Async CSV import failed to start', err) + }, + } + ) + return + } + try { const result = await importMutation.mutateAsync({ workspaceId, @@ -312,7 +373,7 @@ export function ImportCsvDialog({ file: parsed.file, mode, mapping, - createColumns: createHeaders.size > 0 ? [...createHeaders] : undefined, + createColumns, }) const data = result.data if (mode === 'append') { @@ -334,11 +395,7 @@ export function ImportCsvDialog({ } } - const hasWarning = - missingRequired.length > 0 || - duplicateTargets.length > 0 || - appendCapacityDeficit > 0 || - replaceCapacityDeficit > 0 + const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0 return ( @@ -397,7 +454,7 @@ export function ImportCsvDialog({ {parsed.file.name} - {parsed.totalRows.toLocaleString()} rows · {parsed.headers.length} columns + {parsed.headers.length} columns
)} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx new file mode 100644 index 00000000000..1c1bf48fa48 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -0,0 +1,76 @@ +'use client' + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + ProgressItem, +} from '@/components/emcn' +import { Upload } from '@/components/emcn/icons' +import { cancelTableImport } from '@/hooks/queries/tables' +import { useImportTrayStore } from '@/stores/table/import-tray/store' +import { getImportStage } from './import-stage' +import { type ImportRow, useWorkspaceImports } from './use-workspace-imports' + +interface ImportProgressMenuProps { + workspaceId: string | undefined + /** When mounted inside a specific table's header, the indicator is scoped to that table. */ + tableId?: string +} + +/** + * Header affordance for background CSV imports: a clickable `{done}/{total}` count that opens a + * dropdown of per-import progress rows. Renders nothing when there are no imports. The single + * import-progress surface for both the tables list and the in-table view. + */ +export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuProps) { + const imports = useWorkspaceImports(workspaceId, tableId) + const dismiss = useImportTrayStore((state) => state.dismiss) + const cancelId = useImportTrayStore((state) => state.cancel) + const menuOpen = useImportTrayStore((state) => state.menuOpen) + const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen) + + if (imports.length === 0) return null + + const total = imports.length + const done = imports.filter((e) => e.phase === 'ready').length + + const cancel = (row: ImportRow) => { + cancelId(row.id) + // Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by + // the kickoff handler once its importId is known; see the `consumeCanceled` branches.) + if (row.importId) { + void cancelTableImport(row.workspaceId, row.id, row.importId).catch(() => {}) + } + } + + return ( + + + + + + {imports.map((row) => { + const stage = getImportStage(row) + return ( + cancel(row) : undefined} + onDismiss={stage.dismissible ? () => dismiss(row.id) : undefined} + /> + ) + })} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts new file mode 100644 index 00000000000..56e0fb77739 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts @@ -0,0 +1,58 @@ +import type { ImportRow } from './use-workspace-imports' + +type ProgressStatus = 'pending' | 'success' | 'error' + +/** Uniform view model for a tray entry — every stage fills the same slots. */ +export interface ImportStageView { + status: ProgressStatus + /** Primary line: `{status} {name}`, e.g. `Processing data.csv`. */ + title: string + /** Right-aligned on the title row: the percent (when known). */ + meta?: string + /** Secondary line: the row count, or the error message on failure. */ + detail?: string + dismissible: boolean +} + +/** + * Maps a tray entry to the stage shown in the import dropdown. The single place the import + * stages (Uploading → Processing → Imported / Failed) are defined; the row component just + * renders the returned slots, so every stage looks consistent: `{status} {name}`. While + * uploading, the right slot shows the byte-based upload percent (from the client XHR). Once the + * server is processing we only know the committed row count (polled from the table row), so the + * detail line reads `{rows} rows` with no percent. + */ +export function getImportStage(entry: ImportRow): ImportStageView { + const rows = entry.rowsProcessed.toLocaleString() + const name = entry.title + const meta = typeof entry.percent === 'number' ? `${entry.percent}%` : undefined + + if (entry.phase === 'failed') { + return { + status: 'error', + title: `Failed ${name}`, + detail: entry.error ?? 'Something went wrong', + dismissible: true, + } + } + + if (entry.phase === 'ready') { + return { + status: 'success', + title: `Imported ${name}`, + detail: `${rows} rows`, + dismissible: true, + } + } + + // importing: rows only start arriving once the worker is processing; before that it's the upload. + if (entry.rowsProcessed > 0) { + return { + status: 'pending', + title: `Processing ${name}`, + detail: `${rows} rows`, + dismissible: false, + } + } + return { status: 'pending', title: `Uploading ${name}`, meta, dismissible: false } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/index.ts new file mode 100644 index 00000000000..b7ade906b1e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/index.ts @@ -0,0 +1 @@ +export * from './import-progress-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts new file mode 100644 index 00000000000..a4f1acb25e0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts @@ -0,0 +1,118 @@ +'use client' + +import { useEffect, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { toast } from '@/components/emcn' +import { useTablesList } from '@/hooks/queries/tables' +import { useImportTrayStore } from '@/stores/table/import-tray/store' + +const READY_AUTO_CLEAR_MS = 6000 +const POLL_INTERVAL_MS = 2000 + +export type ImportPhase = 'importing' | 'ready' | 'failed' + +/** A row rendered in the import tray. Importing rows come live from the table list; uploads are + * client-only until their server row exists. */ +export interface ImportRow { + id: string + workspaceId: string + title: string + phase: ImportPhase + rowsProcessed: number + /** Upload byte percent (upload phase only). */ + percent?: number + error?: string + importId?: string +} + +/** + * Single source for the import tray. Importing rows are derived live from the table list (polled + * while any import is in flight) rather than mirrored into a store; the store only supplies + * optimistic uploads and which terminal completions to surface this session. Also fires the + * completion toasts on the importing → terminal transition. + */ +export function useWorkspaceImports( + workspaceId: string | undefined, + scopeTableId?: string +): ImportRow[] { + const { data: tables } = useTablesList(workspaceId, 'active', { + refetchInterval: (list) => + list?.some((t) => t.importStatus === 'importing') ? POLL_INTERVAL_MS : false, + }) + + const prevStatus = useRef>(new Map()) + useEffect(() => { + if (!tables) return + const store = useImportTrayStore.getState() + for (const table of tables) { + const before = prevStatus.current.get(table.id) + const now = table.importStatus ?? 'none' + if (before === 'importing' && now === 'ready') { + const rows = (table.importRowsProcessed ?? 0).toLocaleString() + toast.success(`Imported ${rows} rows into "${table.name}"`) + store.notify(table.id) + setTimeout(() => useImportTrayStore.getState().dismiss(table.id), READY_AUTO_CLEAR_MS) + } else if (before === 'importing' && now === 'failed') { + toast.error(table.importError || `Import failed for "${table.name}"`) + store.notify(table.id) + } + if (now !== 'importing' && store.isCanceled(table.id)) store.consumeCanceled(table.id) + prevStatus.current.set(table.id, now) + } + }, [tables]) + + const uploads = useImportTrayStore(useShallow((s) => Object.values(s.uploads))) + const notified = useImportTrayStore((s) => s.notified) + const canceledIds = useImportTrayStore((s) => s.canceledIds) + + return useMemo(() => { + const rows: ImportRow[] = [] + const seen = new Set() + + for (const table of tables ?? []) { + if (scopeTableId && table.id !== scopeTableId) continue + if (table.importStatus === 'importing') { + if (canceledIds[table.id]) continue + rows.push({ + id: table.id, + workspaceId: table.workspaceId, + title: table.name, + phase: 'importing', + rowsProcessed: table.importRowsProcessed ?? 0, + importId: table.importId ?? undefined, + }) + seen.add(table.id) + } else if ( + (table.importStatus === 'ready' || table.importStatus === 'failed') && + notified[table.id] + ) { + rows.push({ + id: table.id, + workspaceId: table.workspaceId, + title: table.name, + phase: table.importStatus, + rowsProcessed: table.importRowsProcessed ?? 0, + error: table.importError ?? undefined, + }) + seen.add(table.id) + } + } + + for (const upload of uploads) { + if (upload.workspaceId !== workspaceId) continue + if (scopeTableId && upload.uploadId !== scopeTableId) continue + if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue + rows.push({ + id: upload.uploadId, + workspaceId: upload.workspaceId, + title: upload.title, + phase: 'importing', + rowsProcessed: 0, + percent: upload.percent, + }) + } + + rows.sort((a, b) => (a.phase === b.phase ? 0 : a.phase === 'importing' ? -1 : 1)) + return rows + }, [tables, uploads, notified, canceledIds, workspaceId, scopeTableId]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts index 4a74ec95484..ea88eac2fdb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts @@ -1,3 +1,4 @@ export * from './import-csv-dialog' +export * from './import-progress-menu' export * from './table-context-menu' export * from './tables-list-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 5cf881a2f4b..14639a60d20 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import type { ComboboxOption } from '@/components/emcn' import { @@ -18,7 +19,7 @@ import { } from '@/components/emcn' import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons' import type { TableDefinition } from '@/lib/table' -import { generateUniqueTableName } from '@/lib/table/constants' +import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' import type { FilterTag, ResourceColumn, @@ -30,20 +31,24 @@ import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/com import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog, + ImportProgressMenu, TablesListContextMenu, } from '@/app/workspace/[workspaceId]/tables/components' import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { + cancelTableImport, downloadTableExport, useCreateTable, useDeleteTable, + useImportCsvAsync, useTablesList, useUploadCsvToTable, } from '@/hooks/queries/tables' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('Tables') @@ -79,6 +84,7 @@ export function Tables() { const deleteTable = useDeleteTable(workspaceId) const createTable = useCreateTable(workspaceId) const uploadCsv = useUploadCsvToTable() + const importCsvAsync = useImportCsvAsync() const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) @@ -91,8 +97,6 @@ export function Tables() { } | null>(null) const [rowCountFilter, setRowCountFilter] = useState([]) const [ownerFilter, setOwnerFilter] = useState([]) - const [uploading, setUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }) const csvInputRef = useRef(null) const { @@ -386,25 +390,65 @@ export function Tables() { const list = e.target.files if (!list || list.length === 0 || !workspaceId) return - try { - setUploading(true) - - const csvFiles = Array.from(list).filter((f) => { - const ext = f.name.split('.').pop()?.toLowerCase() - return ext === 'csv' || ext === 'tsv' - }) - - if (csvFiles.length === 0) { - toast.error('No CSV or TSV files selected') - return - } + // Reset the input up front so the user can immediately pick another CSV (even the same + // file) while this batch is still uploading in the background — imports never block. + const csvFiles = Array.from(list).filter((f) => { + const ext = f.name.split('.').pop()?.toLowerCase() + return ext === 'csv' || ext === 'tsv' + }) + if (e.target) e.target.value = '' - setUploadProgress({ completed: 0, total: csvFiles.length }) + if (csvFiles.length === 0) { + toast.error('No CSV or TSV files selected') + return + } + try { for (let i = 0; i < csvFiles.length; i++) { + const file = csvFiles[i] try { - const result = await uploadCsv.mutateAsync({ workspaceId, file: csvFiles[i] }) + // Large files can't be POSTed through the server (request-body cap) — upload + // them straight to storage and import in the background instead. Show the + // indicator immediately under a temporary id (the real table id doesn't exist + // until kickoff returns), then swap to the real id. Don't redirect — the table + // is still empty/importing, so stay on the list and let the indicator track it. + if (file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) { + const pendingId = `pending_${generateId()}` + useImportTrayStore + .getState() + .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) + toast({ + message: `Importing "${file.name}"…`, + action: { + label: 'View', + onClick: () => useImportTrayStore.getState().setMenuOpen(true), + }, + }) + try { + const result = await importCsvAsync.mutateAsync({ + workspaceId, + file, + onProgress: (percent) => { + useImportTrayStore.getState().setUploadPercent(pendingId, percent) + }, + }) + useImportTrayStore.getState().endUpload(pendingId) + // The server row drives the tray once the list refetches (mutation invalidates it). + // If canceled mid-upload, flag the real id so it's not shown and cancel server-side. + if (result?.tableId && useImportTrayStore.getState().consumeCanceled(pendingId)) { + useImportTrayStore.getState().cancel(result.tableId) + void cancelTableImport(workspaceId, result.tableId, result.importId).catch( + () => {} + ) + } + } catch (err) { + useImportTrayStore.getState().endUpload(pendingId) + throw err + } + continue + } + const result = await uploadCsv.mutateAsync({ workspaceId, file }) if (csvFiles.length === 1) { const tableId = result?.data?.table?.id if (tableId) { @@ -413,21 +457,13 @@ export function Tables() { } } catch (err) { logger.error('Error uploading CSV:', err) - } finally { - setUploadProgress({ completed: i + 1, total: csvFiles.length }) } } } catch (err) { logger.error('Error uploading CSV:', err) - } finally { - setUploading(false) - setUploadProgress({ completed: 0, total: 0 }) - if (csvInputRef.current) { - csvInputRef.current.value = '' - } } }, - [workspaceId, router, uploadCsv] + [workspaceId, router, uploadCsv, importCsvAsync] ) const handleListUploadCsv = useCallback(() => { @@ -435,13 +471,6 @@ export function Tables() { closeListContextMenu() }, [closeListContextMenu]) - const uploadButtonLabel = - uploading && uploadProgress.total > 0 - ? `${uploadProgress.completed}/${uploadProgress.total}` - : uploading - ? 'Uploading...' - : 'Import CSV' - const handleCreateTable = useCallback(async () => { const existingNames = tables.map((t) => t.name) const name = generateUniqueTableName(existingNames) @@ -470,7 +499,7 @@ export function Tables() { create={{ label: 'New table', onClick: handleCreateTable, - disabled: uploading || userPermissions.canEdit !== true || createTable.isPending, + disabled: userPermissions.canEdit !== true || createTable.isPending, }} search={searchConfig} sort={sortConfig} @@ -478,12 +507,13 @@ export function Tables() { filterTags={filterTags} headerActions={[ { - label: uploadButtonLabel, + label: 'Import CSV', icon: Upload, onClick: () => csvInputRef.current?.click(), - disabled: uploading || userPermissions.canEdit !== true, + disabled: userPermissions.canEdit !== true, }, ]} + leadingActions={} columns={COLUMNS} rows={rows} onRowClick={handleRowClick} @@ -497,7 +527,6 @@ export function Tables() { type='file' className='hidden' onChange={handleCsvChange} - disabled={uploading} accept='.csv,.tsv' multiple /> @@ -509,7 +538,7 @@ export function Tables() { onCreateTable={handleCreateTable} onUploadCsv={handleListUploadCsv} disableCreate={userPermissions.canEdit !== true || createTable.isPending} - disableUpload={uploading || userPermissions.canEdit !== true} + disableUpload={userPermissions.canEdit !== true} /> { + const trimmed = toolName.trim() + if (!trimmed) return null + if (trimmed.length > MAX_TOOL_NAME_LENGTH) { + return `Tool name must be ${MAX_TOOL_NAME_LENGTH} characters or fewer` + } + if (!TOOL_NAME_PATTERN.test(trimmed)) { + return 'Use lowercase letters and numbers, separated by single underscores' + } + return null + }, [toolName]) + const [serverToolsMap, setServerToolsMap] = useState< Record >({}) @@ -270,11 +291,11 @@ export function McpDeploy({ (hasToolConfigurationChanges && selectedServerIdsForForm.length > 0) useEffect(() => { - onCanSaveChange?.(hasChanges && !!toolName.trim()) - }, [hasChanges, toolName, onCanSaveChange]) + onCanSaveChange?.(hasChanges && !!toolName.trim() && !toolNameError) + }, [hasChanges, toolName, toolNameError, onCanSaveChange]) const handleSave = async () => { - if (!toolName.trim()) return + if (!toolName.trim() || toolNameError) return const currentIds = new Set(selectedServerIds) const nextIds = new Set(selectedServerIdsForForm) @@ -492,9 +513,16 @@ export function McpDeploy({ value={toolName} onChange={(e) => setToolName(e.target.value)} placeholder='e.g., book_flight' + aria-invalid={!!toolNameError} + className={cn(toolNameError && 'border-[var(--text-error)]')} /> -

- Use lowercase letters, numbers, and underscores only +

+ {toolNameError ?? 'Use lowercase letters, numbers, and underscores only'}

@@ -564,16 +592,20 @@ export function McpDeploy({ placeholder='Select servers...' searchable searchPlaceholder='Search servers...' - disabled={!toolName.trim() || isPending} + disabled={!toolName.trim() || !!toolNameError || isPending} overlayContent={ {selectedServersLabel} } /> - {!toolName.trim() && ( + {!toolName.trim() ? (

Enter a tool name to select servers

- )} + ) : toolNameError ? ( +

+ Fix the tool name to select servers +

+ ) : null} {saveErrors.length > 0 && ( diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 9f617cd5144..fd91f6d3cda 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -165,12 +165,18 @@ async function runWorkflowAndWriteTerminal( ): Promise<'completed' | 'error' | 'paused' | 'blocked'> { const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId, dispatchId } = payload + // Read from the live `group`, not the payload: in a cascade the payload is the + // first group's snapshot, so a downstream group with a different version must + // use its own setting (same reason `workflowId` is re-derived per iteration). + const deploymentMode = group.deploymentMode const requestId = `wfgrp-${executionId}` return runWithRequestContext({ requestId }, async () => { const { getRowById } = await import('@/lib/table/service') const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow') - const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils') + const { loadWorkflowFromNormalizedTables, loadDeployedWorkflowState } = await import( + '@/lib/workflows/persistence/utils' + ) const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } = await import('@/lib/table/cell-write') const { stashCellContextForResume } = await import('@/lib/table/workflow-columns') @@ -382,7 +388,28 @@ async function runWorkflowAndWriteTerminal( return 'error' } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + // `deployed` groups run the workflow's latest active deployment; `live` + // (default) runs the editable draft. A `deployed` group whose workflow + // has never been deployed fails the cell — no silent fallback to draft. + let normalizedData: Awaited> + if (deploymentMode === 'deployed') { + try { + normalizedData = await loadDeployedWorkflowState(workflowId, workspaceId) + } catch (err) { + // Surface the real reason (missing deployment vs. transient DB/migration + // failure) rather than always claiming the workflow isn't deployed. + await writeState({ + status: 'error', + executionId, + jobId: null, + workflowId, + error: toError(err).message, + }) + return 'error' + } + } else { + normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + } const startBlock = normalizedData ? Object.values(normalizedData.blocks).find((b) => b?.type === 'start_trigger') : undefined @@ -562,7 +589,6 @@ async function runWorkflowAndWriteTerminal( changedColumns: [], rowId, headers, - rowNumber: row.position, tableId, tableName, timestamp: new Date().toISOString(), @@ -665,7 +691,10 @@ async function runWorkflowAndWriteTerminal( executionMode: 'sync', workflowTriggerType: 'table', triggerBlockId: startBlock.id, - useDraftState: true, + // `deployed` groups execute the latest active deployment; everything + // else runs the editable draft (the table default). Matches the + // state loaded above for start-block / output-block resolution. + useDraftState: deploymentMode !== 'deployed', abortSignal, onBlockStart, onBlockComplete, diff --git a/apps/sim/blocks/blocks/clickhouse.ts b/apps/sim/blocks/blocks/clickhouse.ts new file mode 100644 index 00000000000..41562f2a351 --- /dev/null +++ b/apps/sim/blocks/blocks/clickhouse.ts @@ -0,0 +1,466 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { ClickHouseIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { IntegrationType } from '@/blocks/types' +import type { ClickHouseResponse } from '@/tools/clickhouse/types' + +const CLICKHOUSE_QUERY_PROMPT = `You are an expert ClickHouse database developer. Write ClickHouse SQL queries based on the user's request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the SQL query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw SQL query. + +### QUERY GUIDELINES +1. **Syntax**: Use ClickHouse-specific SQL syntax and functions +2. **Performance**: Filter on primary/sorting key columns and use PREWHERE where helpful +3. **Readability**: Format queries with proper indentation and spacing +4. **Best Practices**: Add a LIMIT clause for exploratory queries + +### CLICKHOUSE FEATURES +- Use ClickHouse functions (toDateTime, toStartOfInterval, uniqExact, quantile, arrayJoin, etc.) +- Use ClickHouse data types (UInt64, Float64, String, DateTime, LowCardinality, etc.) +- Leverage aggregate combinators (-If, -Array, -State, -Merge) when appropriate + +### EXAMPLES + +**Simple Select**: "Get the 100 most recent events" +→ SELECT event_time, user_id, event_type + FROM events + ORDER BY event_time DESC + LIMIT 100; + +**Aggregation**: "Count unique users per day for the last 7 days" +→ SELECT + toDate(event_time) AS day, + uniqExact(user_id) AS unique_users + FROM events + WHERE event_time >= now() - INTERVAL 7 DAY + GROUP BY day + ORDER BY day; + +### REMEMBER +Return ONLY the SQL query - no explanations, no markdown, no extra text.` + +const TABLE_REQUIRED_OPERATIONS = [ + 'insert', + 'insert_rows', + 'update', + 'delete', + 'describe_table', + 'show_create_table', + 'count_rows', + 'list_partitions', + 'create_table', + 'drop_table', + 'truncate_table', + 'rename_table', + 'optimize_table', + 'drop_partition', +] + +export const ClickHouseBlock: BlockConfig = { + type: 'clickhouse', + name: 'ClickHouse', + description: 'Connect to a ClickHouse database', + longDescription: + 'Integrate ClickHouse into the workflow. Query and insert data, manage databases and tables, inspect schemas, monitor mutations and running queries, manage partitions, and execute raw SQL over the ClickHouse HTTP interface.', + docsLink: 'https://docs.sim.ai/tools/clickhouse', + category: 'tools', + integrationType: IntegrationType.Databases, + tags: ['data-warehouse', 'data-analytics'], + bgColor: '#f9ff69', + icon: ClickHouseIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query (SELECT)', id: 'query' }, + { label: 'Execute Raw SQL', id: 'execute' }, + { label: 'Insert Row', id: 'insert' }, + { label: 'Insert Rows (Bulk)', id: 'insert_rows' }, + { label: 'Update Data', id: 'update' }, + { label: 'Delete Data', id: 'delete' }, + { label: 'List Databases', id: 'list_databases' }, + { label: 'List Tables', id: 'list_tables' }, + { label: 'Describe Table', id: 'describe_table' }, + { label: 'Show Create Table', id: 'show_create_table' }, + { label: 'Count Rows', id: 'count_rows' }, + { label: 'Introspect Schema', id: 'introspect' }, + { label: 'Create Database', id: 'create_database' }, + { label: 'Drop Database', id: 'drop_database' }, + { label: 'Create Table', id: 'create_table' }, + { label: 'Drop Table', id: 'drop_table' }, + { label: 'Truncate Table', id: 'truncate_table' }, + { label: 'Rename Table', id: 'rename_table' }, + { label: 'Optimize Table', id: 'optimize_table' }, + { label: 'List Partitions', id: 'list_partitions' }, + { label: 'Drop Partition', id: 'drop_partition' }, + { label: 'List Mutations', id: 'list_mutations' }, + { label: 'List Running Queries', id: 'list_running_queries' }, + { label: 'Kill Query', id: 'kill_query' }, + { label: 'Table Stats', id: 'table_stats' }, + { label: 'List Clusters', id: 'list_clusters' }, + ], + value: () => 'query', + }, + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'your-instance.clickhouse.cloud', + required: true, + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '8443', + value: () => '8443', + required: true, + }, + { + id: 'database', + title: 'Database Name', + type: 'short-input', + placeholder: 'default', + value: () => 'default', + required: true, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'default', + value: () => 'default', + required: true, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + password: true, + placeholder: 'Your ClickHouse password', + }, + { + id: 'secure', + title: 'Use HTTPS', + type: 'switch', + value: () => 'true', + }, + { + id: 'table', + title: 'Table Name', + type: 'short-input', + placeholder: 'events', + condition: { field: 'operation', value: TABLE_REQUIRED_OPERATIONS }, + required: { field: 'operation', value: TABLE_REQUIRED_OPERATIONS }, + }, + { + id: 'table', + title: 'Table Name (Optional)', + type: 'short-input', + placeholder: 'Leave blank for all tables', + condition: { field: 'operation', value: ['list_mutations', 'table_stats'] }, + }, + { + id: 'query', + title: 'SQL Query', + type: 'code', + placeholder: 'SELECT * FROM events ORDER BY event_time DESC LIMIT 100', + condition: { field: 'operation', value: 'query' }, + required: { field: 'operation', value: 'query' }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: CLICKHOUSE_QUERY_PROMPT, + placeholder: 'Describe the ClickHouse query you need...', + generationType: 'sql-query', + }, + }, + { + id: 'query', + title: 'SQL Statement', + type: 'code', + placeholder: 'CREATE TABLE events (id UInt64, name String) ENGINE = MergeTree ORDER BY id', + condition: { field: 'operation', value: 'execute' }, + required: { field: 'operation', value: 'execute' }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: CLICKHOUSE_QUERY_PROMPT, + placeholder: 'Describe the ClickHouse statement you need...', + generationType: 'sql-query', + }, + }, + { + id: 'data', + title: 'Data (JSON)', + type: 'code', + placeholder: '{\n "id": 1,\n "name": "Example",\n "created_at": "2024-01-01 00:00:00"\n}', + condition: { field: 'operation', value: 'insert' }, + required: { field: 'operation', value: 'insert' }, + }, + { + id: 'rows', + title: 'Rows (JSON Array)', + type: 'code', + placeholder: '[\n { "id": 1, "name": "A" },\n { "id": 2, "name": "B" }\n]', + condition: { field: 'operation', value: 'insert_rows' }, + required: { field: 'operation', value: 'insert_rows' }, + }, + { + id: 'data', + title: 'Update Data (JSON)', + type: 'code', + placeholder: '{\n "name": "Updated name",\n "status": "active"\n}', + condition: { field: 'operation', value: 'update' }, + required: { field: 'operation', value: 'update' }, + }, + { + id: 'where', + title: 'WHERE Condition', + type: 'short-input', + placeholder: 'id = 1', + condition: { field: 'operation', value: 'update' }, + required: { field: 'operation', value: 'update' }, + }, + { + id: 'where', + title: 'WHERE Condition', + type: 'short-input', + placeholder: 'id = 1', + condition: { field: 'operation', value: 'delete' }, + required: { field: 'operation', value: 'delete' }, + }, + { + id: 'where', + title: 'WHERE Condition (Optional)', + type: 'short-input', + placeholder: "status = 'active'", + condition: { field: 'operation', value: 'count_rows' }, + }, + { + id: 'name', + title: 'Database Name', + type: 'short-input', + placeholder: 'analytics', + condition: { field: 'operation', value: ['create_database', 'drop_database'] }, + required: { field: 'operation', value: ['create_database', 'drop_database'] }, + }, + { + id: 'columns', + title: 'Columns (JSON Array)', + type: 'code', + placeholder: + '[\n { "name": "id", "type": "UInt64" },\n { "name": "ts", "type": "DateTime" }\n]', + condition: { field: 'operation', value: 'create_table' }, + required: { field: 'operation', value: 'create_table' }, + }, + { + id: 'engine', + title: 'Engine', + type: 'short-input', + placeholder: 'MergeTree', + value: () => 'MergeTree', + condition: { field: 'operation', value: 'create_table' }, + }, + { + id: 'orderBy', + title: 'Order By', + type: 'short-input', + placeholder: 'id or (id, ts)', + condition: { field: 'operation', value: 'create_table' }, + required: { field: 'operation', value: 'create_table' }, + }, + { + id: 'partitionBy', + title: 'Partition By (Optional)', + type: 'short-input', + placeholder: 'toYYYYMM(ts)', + condition: { field: 'operation', value: 'create_table' }, + }, + { + id: 'newTable', + title: 'New Table Name', + type: 'short-input', + placeholder: 'events_archive', + condition: { field: 'operation', value: 'rename_table' }, + required: { field: 'operation', value: 'rename_table' }, + }, + { + id: 'final', + title: 'Force Final Merge', + type: 'switch', + condition: { field: 'operation', value: 'optimize_table' }, + }, + { + id: 'partition', + title: 'Partition', + type: 'short-input', + placeholder: "202401 or '2024-01'", + condition: { field: 'operation', value: 'drop_partition' }, + required: { field: 'operation', value: 'drop_partition' }, + }, + { + id: 'queryId', + title: 'Query ID', + type: 'short-input', + placeholder: 'The query_id to kill', + condition: { field: 'operation', value: 'kill_query' }, + required: { field: 'operation', value: 'kill_query' }, + }, + { + id: 'onlyRunning', + title: 'Only Running Mutations', + type: 'switch', + condition: { field: 'operation', value: 'list_mutations' }, + }, + ], + tools: { + access: [ + 'clickhouse_query', + 'clickhouse_execute', + 'clickhouse_insert', + 'clickhouse_insert_rows', + 'clickhouse_update', + 'clickhouse_delete', + 'clickhouse_list_databases', + 'clickhouse_list_tables', + 'clickhouse_describe_table', + 'clickhouse_show_create_table', + 'clickhouse_count_rows', + 'clickhouse_introspect', + 'clickhouse_create_database', + 'clickhouse_drop_database', + 'clickhouse_create_table', + 'clickhouse_drop_table', + 'clickhouse_truncate_table', + 'clickhouse_rename_table', + 'clickhouse_optimize_table', + 'clickhouse_list_partitions', + 'clickhouse_drop_partition', + 'clickhouse_list_mutations', + 'clickhouse_list_running_queries', + 'clickhouse_kill_query', + 'clickhouse_table_stats', + 'clickhouse_list_clusters', + ], + config: { + tool: (params) => { + if (!params.operation) { + throw new Error('Operation is required') + } + return `clickhouse_${params.operation}` + }, + params: (params) => { + const { operation, data, columns, rows, secure, ...rest } = params + + const parseJsonField = (value: unknown, label: string): unknown => { + if (value && typeof value === 'string' && value.trim()) { + try { + return JSON.parse(value) + } catch (parseError) { + const errorMsg = getErrorMessage(parseError, 'Unknown JSON error') + throw new Error(`Invalid JSON in ${label}: ${errorMsg}. Please check your syntax.`) + } + } + if (value && typeof value === 'object') { + return value + } + return undefined + } + + const parsedData = parseJsonField(data, 'data') + const parsedColumns = parseJsonField(columns, 'columns') + const parsedRows = parseJsonField(rows, 'rows') + + const isSecure = secure !== false && secure !== 'false' + + const result: Record = { + host: rest.host, + port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 8443, + database: rest.database || 'default', + username: rest.username || 'default', + password: rest.password ?? '', + secure: isSecure, + } + + if (rest.table) result.table = rest.table + if (rest.query) result.query = rest.query + if (rest.where) result.where = rest.where + if (rest.name) result.name = rest.name + if (rest.newTable) result.newTable = rest.newTable + if (rest.partition) result.partition = rest.partition + if (rest.queryId) result.queryId = rest.queryId + if (rest.engine) result.engine = rest.engine + if (rest.orderBy) result.orderBy = rest.orderBy + if (rest.partitionBy) result.partitionBy = rest.partitionBy + if (rest.final !== undefined) { + result.final = rest.final === true || rest.final === 'true' + } + if (rest.onlyRunning !== undefined) { + result.onlyRunning = rest.onlyRunning === true || rest.onlyRunning === 'true' + } + if (parsedData !== undefined) result.data = parsedData + if (parsedColumns !== undefined) result.columns = parsedColumns + if (parsedRows !== undefined) result.rows = parsedRows + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Database operation to perform' }, + host: { type: 'string', description: 'ClickHouse host' }, + port: { type: 'string', description: 'ClickHouse HTTP port' }, + database: { type: 'string', description: 'Database name' }, + username: { type: 'string', description: 'ClickHouse username' }, + password: { type: 'string', description: 'ClickHouse password' }, + secure: { type: 'boolean', description: 'Use a secure HTTPS connection' }, + table: { type: 'string', description: 'Table name' }, + query: { type: 'string', description: 'SQL statement to execute' }, + data: { type: 'json', description: 'Data for insert/update operations' }, + rows: { type: 'json', description: 'Array of row objects for bulk insert' }, + columns: { type: 'json', description: 'Column definitions for create table' }, + where: { type: 'string', description: 'WHERE clause for update/delete/count' }, + name: { type: 'string', description: 'Database name for create/drop database' }, + newTable: { type: 'string', description: 'Target table name for rename' }, + partition: { type: 'string', description: 'Partition expression for drop partition' }, + queryId: { type: 'string', description: 'Query ID for kill query' }, + engine: { type: 'string', description: 'Table engine for create table' }, + orderBy: { type: 'string', description: 'ORDER BY expression for create table' }, + partitionBy: { type: 'string', description: 'PARTITION BY expression for create table' }, + final: { type: 'boolean', description: 'Force a final merge for optimize table' }, + onlyRunning: { type: 'boolean', description: 'Filter to running mutations only' }, + }, + outputs: { + message: { + type: 'string', + description: 'Success or error message describing the operation outcome', + }, + rows: { + type: 'array', + description: 'Array of rows returned from the operation', + }, + rowCount: { + type: 'number', + description: 'Number of rows returned or affected by the operation', + }, + count: { + type: 'number', + description: 'Row count (count rows operation)', + }, + ddl: { + type: 'string', + description: 'CREATE TABLE statement (show create table operation)', + }, + tables: { + type: 'array', + description: 'Array of table schemas with columns and engines (introspect operation)', + }, + }, +} diff --git a/apps/sim/blocks/blocks/dagster.ts b/apps/sim/blocks/blocks/dagster.ts index 2446a4af417..0a907e4eac0 100644 --- a/apps/sim/blocks/blocks/dagster.ts +++ b/apps/sim/blocks/blocks/dagster.ts @@ -3,6 +3,13 @@ import type { BlockConfig } from '@/blocks/types' import { IntegrationType } from '@/blocks/types' import type { DagsterResponse } from '@/tools/dagster/types' +/** Coerces a subBlock value to a finite number, returning undefined for empty or non-numeric input. */ +function toFiniteNumber(value: unknown): number | undefined { + if (value == null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + export const DagsterBlock: BlockConfig = { type: 'dagster', name: 'Dagster', @@ -37,6 +44,11 @@ export const DagsterBlock: BlockConfig = { { label: 'List Sensors', id: 'list_sensors' }, { label: 'Start Sensor', id: 'start_sensor' }, { label: 'Stop Sensor', id: 'stop_sensor' }, + { label: 'List Assets', id: 'list_assets' }, + { label: 'Get Asset', id: 'get_asset' }, + { label: 'Materialize Assets', id: 'materialize_assets' }, + { label: 'Report Asset Materialization', id: 'report_asset_materialization' }, + { label: 'Wipe Asset', id: 'wipe_asset' }, ], value: () => 'launch_run', }, @@ -49,11 +61,25 @@ export const DagsterBlock: BlockConfig = { placeholder: 'e.g., my_code_location', condition: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, required: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, }, { @@ -63,11 +89,25 @@ export const DagsterBlock: BlockConfig = { placeholder: 'e.g., __repository__', condition: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, required: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, }, @@ -105,7 +145,7 @@ Return ONLY a valid JSON object - no explanations, no extra text.`, title: 'Tags', type: 'code', placeholder: '[{"key": "env", "value": "prod"}]', - condition: { field: 'operation', value: 'launch_run' }, + condition: { field: 'operation', value: ['launch_run', 'materialize_assets'] }, mode: 'advanced', wandConfig: { enabled: true, @@ -210,6 +250,46 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` condition: { field: 'operation', value: 'list_runs' }, mode: 'advanced', }, + { + id: 'createdAfter', + title: 'Created After', + type: 'short-input', + placeholder: 'Unix timestamp in seconds (optional)', + condition: { field: 'operation', value: 'list_runs' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a start time into a Unix timestamp in seconds. + +Return ONLY the integer Unix timestamp in seconds - no explanations, no extra text.`, + placeholder: 'Describe the earliest creation time...', + generationType: 'timestamp', + }, + }, + { + id: 'createdBefore', + title: 'Created Before', + type: 'short-input', + placeholder: 'Unix timestamp in seconds (optional)', + condition: { field: 'operation', value: 'list_runs' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of an end time into a Unix timestamp in seconds. + +Return ONLY the integer Unix timestamp in seconds - no explanations, no extra text.`, + placeholder: 'Describe the latest creation time...', + generationType: 'timestamp', + }, + }, + { + id: 'runsCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Run ID from a previous response cursor (for pagination)', + condition: { field: 'operation', value: 'list_runs' }, + mode: 'advanced', + }, // ── Schedule operations ──────────────────────────────────────────────────── { @@ -267,6 +347,95 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` required: { field: 'operation', value: ['stop_schedule', 'stop_sensor'] }, }, + // ── Asset operations ─────────────────────────────────────────────────────── + { + id: 'assetKey', + title: 'Asset Key', + type: 'short-input', + placeholder: 'e.g., my_asset or raw/events', + condition: { + field: 'operation', + value: ['get_asset', 'report_asset_materialization', 'wipe_asset'], + }, + required: { + field: 'operation', + value: ['get_asset', 'report_asset_materialization', 'wipe_asset'], + }, + }, + { + id: 'assetJobName', + title: 'Asset Job', + type: 'short-input', + placeholder: 'e.g., __ASSET_JOB or a named asset job', + condition: { field: 'operation', value: 'materialize_assets' }, + required: { field: 'operation', value: 'materialize_assets' }, + }, + { + id: 'assetSelection', + title: 'Asset Selection', + type: 'long-input', + placeholder: 'Comma- or newline-separated asset keys, e.g. raw/events, summary', + condition: { field: 'operation', value: 'materialize_assets' }, + required: { field: 'operation', value: 'materialize_assets' }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Dagster asset keys to materialize based on the user's description. Multi-part keys use slashes (e.g. raw/events). + +Return ONLY the comma-separated asset keys - no explanations, no extra text.`, + placeholder: 'Describe which assets to materialize...', + }, + }, + { + id: 'assetPrefix', + title: 'Key Prefix', + type: 'short-input', + placeholder: 'Filter by asset key prefix, e.g. raw (optional)', + condition: { field: 'operation', value: 'list_assets' }, + }, + { + id: 'assetsLimit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'list_assets' }, + mode: 'advanced', + }, + { + id: 'assetsCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Cursor from a previous list_assets response (for pagination)', + condition: { field: 'operation', value: 'list_assets' }, + mode: 'advanced', + }, + { + id: 'reportEventType', + title: 'Event Type', + type: 'dropdown', + options: [ + { label: 'Materialization', id: 'ASSET_MATERIALIZATION' }, + { label: 'Observation', id: 'ASSET_OBSERVATION' }, + ], + value: () => 'ASSET_MATERIALIZATION', + condition: { field: 'operation', value: 'report_asset_materialization' }, + }, + { + id: 'reportPartitionKeys', + title: 'Partition Keys', + type: 'short-input', + placeholder: 'Comma-separated partition keys (optional)', + condition: { field: 'operation', value: 'report_asset_materialization' }, + mode: 'advanced', + }, + { + id: 'reportDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Description for the reported event (optional)', + condition: { field: 'operation', value: 'report_asset_materialization' }, + mode: 'advanced', + }, + // ── Connection (common to all operations) ────────────────────────────────── { id: 'host', @@ -300,22 +469,29 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` 'dagster_list_sensors', 'dagster_start_sensor', 'dagster_stop_sensor', + 'dagster_list_assets', + 'dagster_get_asset', + 'dagster_materialize_assets', + 'dagster_report_asset_materialization', + 'dagster_wipe_asset', ], config: { tool: (params) => `dagster_${params.operation}`, params: (params) => { const result: Record = {} - // list_runs: type-coerce limit and remap job name filter + // list_runs: type-coerce limit + time filters, remap job name filter and cursor if (params.operation === 'list_runs') { - if (params.limit != null && params.limit !== '') result.limit = Number(params.limit) + result.limit = toFiniteNumber(params.limit) result.jobName = params.listRunsJobName || undefined + result.createdAfter = toFiniteNumber(params.createdAfter) + result.createdBefore = toFiniteNumber(params.createdBefore) + result.cursor = params.runsCursor || undefined } // get_run_logs: remap logsLimit → limit if (params.operation === 'get_run_logs') { - if (params.logsLimit != null && params.logsLimit !== '') - result.limit = Number(params.logsLimit) + result.limit = toFiniteNumber(params.logsLimit) } // reexecute_run: remap runId → parentRunId @@ -331,6 +507,25 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` result.sensorStatus = undefined } + // list_assets: type-coerce limit and remap prefix/cursor + if (params.operation === 'list_assets') { + result.prefix = params.assetPrefix || undefined + result.limit = toFiniteNumber(params.assetsLimit) + result.cursor = params.assetsCursor || undefined + } + + // materialize_assets: remap asset job name → jobName + if (params.operation === 'materialize_assets') { + result.jobName = params.assetJobName + } + + // report_asset_materialization: remap report-prefixed fields to tool params + if (params.operation === 'report_asset_materialization') { + result.eventType = params.reportEventType || 'ASSET_MATERIALIZATION' + result.partitionKeys = params.reportPartitionKeys || undefined + result.description = params.reportDescription || undefined + } + return result }, }, @@ -362,6 +557,15 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` // List Runs listRunsJobName: { type: 'string', description: 'Filter list_runs by job name' }, statuses: { type: 'string', description: 'Comma-separated run statuses to filter by' }, + createdAfter: { + type: 'number', + description: 'Only return runs created at/after this Unix time', + }, + createdBefore: { + type: 'number', + description: 'Only return runs created at/before this Unix time', + }, + runsCursor: { type: 'string', description: 'Run ID cursor for list_runs pagination' }, limit: { type: 'number', description: 'Maximum results to return' }, // Schedules scheduleName: { type: 'string', description: 'Schedule name' }, @@ -374,6 +578,25 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` sensorStatus: { type: 'string', description: 'Filter sensors by status (RUNNING or STOPPED)' }, // Stop schedule / sensor instigationStateId: { type: 'string', description: 'InstigationState ID for stop operations' }, + // Assets + assetKey: { type: 'string', description: 'Slash-delimited asset key' }, + assetJobName: { type: 'string', description: 'Asset job to launch for materialization' }, + assetSelection: { + type: 'string', + description: 'Comma/newline-separated asset keys to materialize', + }, + assetPrefix: { type: 'string', description: 'Filter list_assets by key prefix' }, + assetsLimit: { type: 'number', description: 'Maximum assets to return' }, + assetsCursor: { type: 'string', description: 'Cursor for list_assets pagination' }, + reportEventType: { + type: 'string', + description: 'Runless event type (ASSET_MATERIALIZATION or ASSET_OBSERVATION)', + }, + reportPartitionKeys: { + type: 'string', + description: 'Comma-separated partition keys for the reported event', + }, + reportDescription: { type: 'string', description: 'Description for the reported event' }, }, outputs: { @@ -382,8 +605,14 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` // Get Run jobName: { type: 'string', description: 'Job name the run belongs to' }, status: { type: 'string', description: 'Run or schedule/sensor status' }, + mode: { type: 'string', description: 'Execution mode of the run' }, startTime: { type: 'number', description: 'Run start time (Unix timestamp)' }, endTime: { type: 'number', description: 'Run end time (Unix timestamp)' }, + creationTime: { type: 'number', description: 'Run creation time (Unix timestamp)' }, + updateTime: { type: 'number', description: 'Run last-update time (Unix timestamp)' }, + parentRunId: { type: 'string', description: 'Immediate parent run ID (re-executions)' }, + rootRunId: { type: 'string', description: 'Root run ID of the re-execution group' }, + canTerminate: { type: 'boolean', description: 'Whether the run can be terminated' }, runConfigYaml: { type: 'string', description: 'Run configuration as YAML' }, tags: { type: 'json', description: 'Run tags as array of {key, value} objects' }, // List Runs @@ -401,10 +630,13 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` type: 'json', description: 'Log events (type, message, timestamp, level, stepKey, eventType)', }, - cursor: { type: 'string', description: 'Pagination cursor for the next page of logs' }, + cursor: { + type: 'string', + description: 'Pagination cursor for the next page (logs, runs, or assets)', + }, hasMore: { type: 'boolean', - description: 'Whether more log events are available beyond this page', + description: 'Whether more items are available beyond this page', }, // List Schedules schedules: { @@ -419,5 +651,21 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` }, // Start/Stop schedule or sensor id: { type: 'string', description: 'Instigator state ID of the schedule or sensor' }, + // Get Run / Get Asset (asset key selection) + assetSelection: { type: 'json', description: 'Asset keys targeted by the run' }, + // List Assets + assets: { type: 'json', description: 'List of assets (assetKey, path)' }, + // Get Asset + assetKey: { type: 'string', description: 'Slash-joined asset key' }, + path: { type: 'json', description: 'Asset key path segments' }, + groupName: { type: 'string', description: 'Asset group name' }, + description: { type: 'string', description: 'Asset description' }, + jobNames: { type: 'json', description: 'Jobs that can materialize the asset' }, + computeKind: { type: 'string', description: 'Asset compute kind tag' }, + isPartitioned: { type: 'boolean', description: 'Whether the asset is partitioned' }, + latestMaterialization: { + type: 'json', + description: 'Latest materialization (runId, timestamp, partition, stepKey)', + }, }, } diff --git a/apps/sim/blocks/blocks/tinybird.ts b/apps/sim/blocks/blocks/tinybird.ts index e1b1e08d4da..b2d02a998de 100644 --- a/apps/sim/blocks/blocks/tinybird.ts +++ b/apps/sim/blocks/blocks/tinybird.ts @@ -6,11 +6,11 @@ import type { TinybirdResponse } from '@/tools/tinybird/types' export const TinybirdBlock: BlockConfig = { type: 'tinybird', name: 'Tinybird', - description: 'Send events and query data with Tinybird', + description: 'Send events, query data, and manage Data Sources with Tinybird', authMode: AuthMode.ApiKey, longDescription: - 'Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.', - docsLink: 'https://www.tinybird.co/docs/api-reference', + 'Interact with Tinybird: stream JSON or NDJSON events with the Events API, run SQL with the Query API, call published Pipe API Endpoints by name with dynamic parameters, and manage Data Sources by appending from a URL, truncating, or deleting rows by condition.', + docsLink: 'https://docs.sim.ai/tools/tinybird', category: 'tools', integrationType: IntegrationType.Analytics, tags: ['data-warehouse', 'data-analytics'], @@ -24,6 +24,10 @@ export const TinybirdBlock: BlockConfig = { options: [ { label: 'Send Events', id: 'tinybird_events' }, { label: 'Query', id: 'tinybird_query' }, + { label: 'Query Pipe Endpoint', id: 'tinybird_query_pipe' }, + { label: 'Append Data Source (from URL)', id: 'tinybird_append_datasource' }, + { label: 'Truncate Data Source', id: 'tinybird_truncate_datasource' }, + { label: 'Delete Data Source Rows', id: 'tinybird_delete_datasource_rows' }, ], value: () => 'tinybird_events', }, @@ -42,13 +46,21 @@ export const TinybirdBlock: BlockConfig = { password: true, required: true, }, - // Send Events operation inputs + // Data Source name (Send Events + Data Source management operations) { id: 'datasource', title: 'Data Source', type: 'short-input', placeholder: 'my_events_datasource', - condition: { field: 'operation', value: 'tinybird_events' }, + condition: { + field: 'operation', + value: [ + 'tinybird_events', + 'tinybird_append_datasource', + 'tinybird_truncate_datasource', + 'tinybird_delete_datasource_rows', + ], + }, required: true, }, { @@ -105,11 +117,95 @@ export const TinybirdBlock: BlockConfig = { title: 'Pipeline Name', type: 'short-input', placeholder: 'my_pipe (optional)', + mode: 'advanced', condition: { field: 'operation', value: 'tinybird_query' }, }, + // Query Pipe Endpoint operation inputs + { + id: 'pipe', + title: 'Pipe Name', + type: 'short-input', + placeholder: 'top_pages', + condition: { field: 'operation', value: 'tinybird_query_pipe' }, + required: true, + }, + { + id: 'parameters', + title: 'Parameters', + type: 'code', + placeholder: '{\n "start_date": "2024-01-01",\n "limit": 10\n}', + condition: { field: 'operation', value: 'tinybird_query_pipe' }, + wandConfig: { + enabled: true, + generationType: 'json-object', + placeholder: 'Describe the Pipe parameters to pass', + prompt: + 'Generate a JSON object of dynamic parameters to pass to a Tinybird Pipe API Endpoint. Keys are parameter names and values are their values. Return ONLY the JSON object - no explanations, no extra text.', + }, + }, + { + id: 'pipe_sql', + title: 'SQL (on top of Pipe)', + type: 'code', + placeholder: 'SELECT count() FROM _', + mode: 'advanced', + condition: { field: 'operation', value: 'tinybird_query_pipe' }, + }, + // Append Data Source operation inputs + { + id: 'source_url', + title: 'Source File URL', + type: 'short-input', + placeholder: 'https://example.com/data.csv', + condition: { field: 'operation', value: 'tinybird_append_datasource' }, + required: true, + }, + { + id: 'source_format', + title: 'Source Format', + type: 'dropdown', + options: [ + { label: 'CSV', id: 'csv' }, + { label: 'NDJSON', id: 'ndjson' }, + { label: 'Parquet', id: 'parquet' }, + ], + value: () => 'csv', + condition: { field: 'operation', value: 'tinybird_append_datasource' }, + }, + // Delete Data Source Rows operation inputs + { + id: 'delete_condition', + title: 'Delete Condition', + type: 'long-input', + placeholder: "country = 'ES'", + condition: { field: 'operation', value: 'tinybird_delete_datasource_rows' }, + required: true, + wandConfig: { + enabled: true, + generationType: 'sql-query', + placeholder: 'Describe which rows to delete', + prompt: + 'Generate a SQL WHERE-clause condition (without the WHERE keyword) selecting rows to delete from a table. Example: "event_date < \'2024-01-01\'". Return ONLY the SQL condition - no explanations, no extra text.', + }, + }, + { + id: 'dry_run', + title: 'Dry Run', + type: 'switch', + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'tinybird_delete_datasource_rows' }, + }, ], tools: { - access: ['tinybird_events', 'tinybird_query'], + access: [ + 'tinybird_events', + 'tinybird_query', + 'tinybird_query_pipe', + 'tinybird_append_datasource', + 'tinybird_truncate_datasource', + 'tinybird_delete_datasource_rows', + ], config: { tool: (params) => params.operation || 'tinybird_events', params: (params) => { @@ -133,7 +229,6 @@ export const TinybirdBlock: BlockConfig = { result.format = params.format || 'ndjson' result.compression = params.compression || 'none' - // Convert wait from string to boolean // Convert wait from string to boolean if (params.wait !== undefined) { const waitValue = @@ -150,6 +245,56 @@ export const TinybirdBlock: BlockConfig = { if (params.pipeline) { result.pipeline = params.pipeline } + } else if (operation === 'tinybird_query_pipe') { + // Query Pipe Endpoint operation + if (!params.pipe) { + throw new Error('Pipe Name is required for Query Pipe Endpoint operation') + } + + result.pipe = params.pipe + if (params.parameters) { + result.parameters = params.parameters + } + if (params.pipe_sql) { + result.q = params.pipe_sql + } + } else if (operation === 'tinybird_append_datasource') { + // Append Data Source from URL operation + if (!params.datasource) { + throw new Error('Data Source is required for Append Data Source operation') + } + if (!params.source_url) { + throw new Error('Source File URL is required for Append Data Source operation') + } + + result.datasource = params.datasource + result.url = params.source_url + result.format = params.source_format || 'csv' + } else if (operation === 'tinybird_truncate_datasource') { + // Truncate Data Source operation + if (!params.datasource) { + throw new Error('Data Source is required for Truncate Data Source operation') + } + + result.datasource = params.datasource + } else if (operation === 'tinybird_delete_datasource_rows') { + // Delete Data Source Rows operation + if (!params.datasource) { + throw new Error('Data Source is required for Delete Data Source Rows operation') + } + if (!params.delete_condition) { + throw new Error('Delete Condition is required for Delete Data Source Rows operation') + } + + result.datasource = params.datasource + result.delete_condition = params.delete_condition + + // Convert dry_run from string to boolean + if (params.dry_run !== undefined) { + const dryRunValue = + typeof params.dry_run === 'string' ? params.dry_run.toLowerCase() : params.dry_run + result.dry_run = dryRunValue === 'true' || dryRunValue === true + } } return result @@ -180,6 +325,16 @@ export const TinybirdBlock: BlockConfig = { // Query inputs query: { type: 'string', description: 'SQL query to execute' }, pipeline: { type: 'string', description: 'Optional pipeline name' }, + // Query Pipe Endpoint inputs + pipe: { type: 'string', description: 'Published Pipe API Endpoint name' }, + parameters: { type: 'json', description: 'Dynamic Pipe parameters as a JSON object' }, + pipe_sql: { type: 'string', description: 'Optional SQL to run on top of the Pipe result' }, + // Append Data Source inputs + source_url: { type: 'string', description: 'URL of the file to append' }, + source_format: { type: 'string', description: 'Source file format (csv, ndjson, parquet)' }, + // Delete Data Source Rows inputs + delete_condition: { type: 'string', description: 'SQL condition selecting rows to delete' }, + dry_run: { type: 'boolean', description: 'Test the delete without removing data' }, // Common token: { type: 'string', description: 'Tinybird API Token' }, }, @@ -199,11 +354,33 @@ export const TinybirdBlock: BlockConfig = { description: 'Query result data. FORMAT JSON: array of objects. Other formats (CSV, TSV, etc.): raw text string.', }, + meta: { + type: 'json', + description: 'Column metadata for the result set: [{name, type}] (only with FORMAT JSON)', + }, rows: { type: 'number', description: 'Number of rows returned (only with FORMAT JSON)' }, + rows_before_limit_at_least: { + type: 'number', + description: 'Minimum rows without a LIMIT clause (only with FORMAT JSON)', + }, statistics: { type: 'json', description: 'Query execution statistics - elapsed time, rows read, bytes read (only with FORMAT JSON)', }, + // Data Source management outputs (append / truncate / delete) + id: { type: 'string', description: 'Operation identifier (append/delete)' }, + import_id: { type: 'string', description: 'Import identifier (append)' }, + job_id: { type: 'string', description: 'Job identifier to poll status (append/delete)' }, + delete_id: { type: 'string', description: 'Deletion identifier (delete)' }, + job_url: { type: 'string', description: 'URL to query job status (append/delete)' }, + status: { type: 'string', description: 'Current job status (append/delete)' }, + job: { + type: 'json', + description: 'Full job details: kind, id, status, datasource, rows_affected (append/delete)', + }, + datasource: { type: 'json', description: 'Target Data Source metadata (append)' }, + truncated: { type: 'boolean', description: 'Whether the Data Source was truncated' }, + result: { type: 'json', description: 'Raw truncate response body, if any' }, }, } diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index d13aa5d2bcc..10371ed1646 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -28,6 +28,7 @@ import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { CirclebackBlock } from '@/blocks/blocks/circleback' import { ClayBlock } from '@/blocks/blocks/clay' import { ClerkBlock } from '@/blocks/blocks/clerk' +import { ClickHouseBlock } from '@/blocks/blocks/clickhouse' import { CloudflareBlock } from '@/blocks/blocks/cloudflare' import { CloudFormationBlock } from '@/blocks/blocks/cloudformation' import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch' @@ -285,6 +286,7 @@ export const registry: Record = { crowdstrike: CrowdStrikeBlock, clay: ClayBlock, clerk: ClerkBlock, + clickhouse: ClickHouseBlock, condition: ConditionBlock, credential: CredentialBlock, confluence: ConfluenceBlock, diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 00646809a72..4c2dbbf636a 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -94,6 +94,7 @@ export { PopoverTrigger, usePopoverContext, } from './popover/popover' +export { ProgressItem, progressItemVariants } from './progress-item/progress-item' export { SModal, SModalClose, diff --git a/apps/sim/components/emcn/components/progress-item/progress-item.tsx b/apps/sim/components/emcn/components/progress-item/progress-item.tsx new file mode 100644 index 00000000000..03ac17da363 --- /dev/null +++ b/apps/sim/components/emcn/components/progress-item/progress-item.tsx @@ -0,0 +1,106 @@ +import { forwardRef, type HTMLAttributes } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { AlertTriangle } from 'lucide-react' +import { Check, Loader, Square, X } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +const progressItemVariants = cva('flex items-start gap-2.5 px-3 py-3 text-[12px]', { + variants: { + status: { + pending: '', + success: '', + error: '', + }, + }, + defaultVariants: { status: 'pending' }, +}) + +type ProgressStatus = NonNullable['status']> + +const ICON_CLASS = 'mt-px size-[14px] shrink-0' + +function StatusIcon({ status }: { status: ProgressStatus }) { + if (status === 'success') + return + if (status === 'error') + return + return +} + +export interface ProgressItemProps + extends Omit, 'title'>, + VariantProps { + status: ProgressStatus + /** Primary line (truncated). */ + title: React.ReactNode + /** Right-aligned status on the title row, e.g. `Processing · 45%`. */ + meta?: React.ReactNode + /** Secondary line under the title. */ + detail?: React.ReactNode + /** Renders a dismiss button when provided (terminal rows). */ + onDismiss?: () => void + /** Accessible label for the dismiss button. */ + dismissLabel?: string + /** Renders a cancel button when provided (active rows); takes precedence over `onDismiss`. */ + onCancel?: () => void +} + +/** + * A single status/progress row: a leading status icon (spinner / check / alert), a primary + * title, an optional right-aligned `meta` (status + percent), an optional secondary `detail` + * line, and an optional dismiss button. Every status renders through the same fixed layout — + * only the values change — so rows stay visually consistent across stages. + * + * @example + * ```tsx + * + * + * + * ``` + */ +const ProgressItem = forwardRef(function ProgressItem( + { className, status, title, meta, detail, onDismiss, dismissLabel, onCancel, ...props }, + ref +) { + const trailingAction = onCancel ?? onDismiss + const trailingLabel = onCancel ? 'Cancel' : (dismissLabel ?? 'Dismiss') + return ( +
+ +
+
+ + {title} + + {meta != null && ( + {meta} + )} +
+ {detail != null && ( + + {detail} + + )} +
+ {trailingAction && ( + + )} +
+ ) +}) +ProgressItem.displayName = 'ProgressItem' + +export { ProgressItem, progressItemVariants } diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 49d1d7ddf38..dce91bf9720 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2743,6 +2743,18 @@ export function ClerkIcon(props: SVGProps) { ) } +export function ClickHouseIcon(props: SVGProps) { + return ( + + + + + ) +} + export function MicrosoftIcon(props: SVGProps) { return ( diff --git a/apps/sim/connectors/ashby/ashby.ts b/apps/sim/connectors/ashby/ashby.ts index fdf1c21e8b2..d27853a4b02 100644 --- a/apps/sim/connectors/ashby/ashby.ts +++ b/apps/sim/connectors/ashby/ashby.ts @@ -298,7 +298,32 @@ function renderFeedbackValue(value: unknown): string { /** * Stable, metadata-based content hash for a candidate document. Identical between the - * listing stub and the fully-fetched document so unchanged candidates are skipped. + * listing stub and the fully-fetched document so unchanged candidates are skipped, + * which keeps the `getDocument` re-hydration (notes + feedback fetches) cheap: the + * sync engine only re-hydrates a deferred stub when this hash differs from the stored + * document's hash (see `lib/knowledge/connectors/sync-engine.ts`). + * + * Known limitation — notes/feedback freshness depends on `candidate.updatedAt`. + * Candidate notes (`candidate.listNotes`) and interview feedback + * (`applicationFeedback.list`) are separate Ashby objects, not candidate fields. This + * hash is derived solely from the candidate's own `updatedAt`, so a new note or newly + * submitted feedback is only re-synced if Ashby advances `candidate.updatedAt` as a + * side effect of that write. + * + * As of this writing Ashby's public API docs do not specify what counts as a + * "modification" for `candidate.updatedAt` or for `candidate.list` syncToken + * incremental sync, and no third-party ATS-integration vendor (Merge, Nango, Knit) + * documents it either — so this behavior is unverified. If Ashby does NOT touch + * `candidate.updatedAt` on note/feedback writes, those additions will not be picked up + * until some other candidate field changes; a forced full sync re-hydrates everything + * regardless. No cheaper listing-time signal exists to fold into this hash: the + * `candidate.list` object exposes no note/feedback count, and syncToken carries the + * same unspecified change semantics as `updatedAt`. + * + * Refs: + * - https://developers.ashbyhq.com/reference/candidatelist + * - https://developers.ashbyhq.com/reference/candidatecreatenote + * - https://developers.ashbyhq.com/docs/pagination-and-incremental-sync */ function buildContentHash(id: string, updatedAt: string | null): string { return `ashby:${id}:${updatedAt ?? ''}` diff --git a/apps/sim/connectors/azure-devops/azure-devops.ts b/apps/sim/connectors/azure-devops/azure-devops.ts new file mode 100644 index 00000000000..2c9727866e6 --- /dev/null +++ b/apps/sim/connectors/azure-devops/azure-devops.ts @@ -0,0 +1,1857 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { AzureDevOpsIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { htmlToPlainText, joinTagArray, parseTagDate, readBodyWithLimit } from '@/connectors/utils' + +const logger = createLogger('AzureDevOpsConnector') + +const ADO_BASE_URL = 'https://dev.azure.com' +const WIKI_API_VERSION = '7.1' +const WIKIS_LIST_API_VERSION = '7.1' +const WIQL_API_VERSION = '7.1' +const WORKITEMS_API_VERSION = '7.1' +const PROJECT_API_VERSION = '7.1' +const GIT_API_VERSION = '7.1' + +/** Page size for the wiki `pagesbatch` endpoint. */ +const WIKI_PAGE_BATCH_SIZE = 100 +/** Page size for the WIQL → workitemsbatch listing pipeline. ADO caps a batch at 200 ids. */ +const WORK_ITEM_BATCH_SIZE = 200 +/** Concurrency for per-page wiki ETag lookups during listing. */ +const WIKI_ETAG_CONCURRENCY = 5 +/** Page size for paginating repository-file stubs out of the in-memory tree. */ +const FILE_BATCH_SIZE = 100 +/** + * Max repository file size to index. The Items list API does not return file + * size, so this cap is enforced at content-fetch time in getDocument: the raw + * octet-stream body is read through `readBodyWithLimit`, which streams the bytes + * and aborts (returning null) the moment the cap is exceeded. Larger files are + * skipped without being fully buffered. + */ +const MAX_FILE_SIZE = 10 * 1024 * 1024 +/** Bytes sniffed for a NUL byte when detecting binary files (matches git's heuristic). */ +const BINARY_SNIFF_BYTES = 8000 +/** + * WIQL returns at most 20,000 work item references. We cap `$top` at this bound + * so the connector never silently relies on truncated results; users who need + * more should narrow the query via the work-item filters. + */ +const WIQL_MAX_RESULTS = 20000 + +/** + * externalId discriminators. Wiki pages are addressed by `wiki:{wikiId}:{path}`, + * work items by `wi:{id}`, and repository files by `file:{repoId}:{path}`. + */ +const FILE_PREFIX = 'file:' + +type ContentType = 'wiki' | 'workitems' | 'files' | 'both' | 'all' + +/** Listing phases, walked in order: wiki ➜ work items ➜ repository files. */ +type SyncPhase = 'wiki' | 'workitems' | 'file' + +/** + * Returns the ordered list of active sync phases for a content-type choice. + * Phase order is fixed (wiki ➜ workitems ➜ file) so the phase-encoded cursor and + * the maxItems phase-boundary guard compose deterministically. + */ +function activePhases(contentType: ContentType): SyncPhase[] { + const phases: SyncPhase[] = [] + if (contentType === 'wiki' || contentType === 'both' || contentType === 'all') phases.push('wiki') + if (contentType === 'workitems' || contentType === 'both' || contentType === 'all') { + phases.push('workitems') + } + if (contentType === 'files' || contentType === 'all') phases.push('file') + return phases +} + +/** + * Returns the phase following `current` for a content type, or undefined when + * `current` is the last active phase. + */ +function nextPhase(current: SyncPhase, contentType: ContentType): SyncPhase | undefined { + const phases = activePhases(contentType) + const idx = phases.indexOf(current) + return idx >= 0 && idx + 1 < phases.length ? phases[idx + 1] : undefined +} + +/** + * Builds the Azure DevOps PAT auth header. ADO PATs authenticate via HTTP Basic + * with an empty username and the token as the password. + */ +function patAuthHeader(accessToken: string): string { + return `Basic ${Buffer.from(`:${accessToken}`).toString('base64')}` +} + +/** + * Normalizes the configured content type, defaulting to wiki pages. + */ +function parseContentType(value: unknown): ContentType { + if (value === 'workitems' || value === 'files' || value === 'both' || value === 'all') { + return value + } + return 'wiki' +} + +/** + * Heuristic binary detection: a NUL byte in the first 8 KB marks the file as + * binary, matching `git diff` / `git grep` semantics. + */ +function isBinaryBuffer(buf: Buffer): boolean { + const len = Math.min(buf.length, BINARY_SNIFF_BYTES) + for (let i = 0; i < len; i++) { + if (buf[i] === 0) return true + } + return false +} + +/** + * Parses a comma-separated extension filter into a normalized set (leading dot, + * lowercased). Returns null when no filter is configured (accept all files). + */ +function parseExtensions(raw: unknown): Set | null { + const trimmed = typeof raw === 'string' ? raw.trim() : '' + if (!trimmed) return null + const exts = trimmed + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean) + .map((e) => (e.startsWith('.') ? e : `.${e}`)) + return exts.length > 0 ? new Set(exts) : null +} + +/** + * Returns true when the file path matches the extension filter (or no filter set). + */ +function matchesExtension(filePath: string, extSet: Set | null): boolean { + if (!extSet) return true + const lastDot = filePath.lastIndexOf('.') + if (lastDot === -1) return false + return extSet.has(filePath.slice(lastDot).toLowerCase()) +} + +/** + * Strips the `refs/heads/` prefix from a default-branch ref so it can be used as + * a `versionDescriptor.version` branch name. + */ +function stripRefsHeads(ref: string): string { + return ref.replace(/^refs\/heads\//, '') +} + +/** + * Reads a trimmed string config value, returning '' when absent. + */ +function readString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +/** + * Escapes a value for safe interpolation into a single-quoted WIQL string literal. + * WIQL escapes an embedded single quote by doubling it. + */ +function escapeWiql(value: string): string { + return value.replace(/'/g, "''") +} + +/** + * Encodes an external ID that combines a discriminator with its identifier, + * e.g. `wiki:{wikiId}:{pagePath}` or `wi:{id}`. + */ +function workItemExternalId(id: number): string { + return `wi:${id}` +} + +function wikiPageExternalId(wikiId: string, pagePath: string): string { + return `wiki:${wikiId}:${pagePath}` +} + +/** + * Parses a wiki external ID back into its wiki ID and page path. + */ +function parseWikiExternalId(externalId: string): { wikiId: string; pagePath: string } | null { + if (!externalId.startsWith('wiki:')) return null + const rest = externalId.slice('wiki:'.length) + const sep = rest.indexOf(':') + if (sep === -1) return null + return { wikiId: rest.slice(0, sep), pagePath: rest.slice(sep + 1) } +} + +/** + * Builds the externalId for a repository file: `file:{repoId}:{path}`. The path + * retains its leading slash as returned by the Items API. + */ +function fileExternalId(repoId: string, path: string): string { + return `${FILE_PREFIX}${repoId}:${path}` +} + +/** + * Parses a file externalId back into its repository ID and path. Returns null + * when the externalId is not a file ID. + */ +function parseFileExternalId(externalId: string): { repoId: string; path: string } | null { + if (!externalId.startsWith(FILE_PREFIX)) return null + const rest = externalId.slice(FILE_PREFIX.length) + const sep = rest.indexOf(':') + if (sep === -1) return null + return { repoId: rest.slice(0, sep), path: rest.slice(sep + 1) } +} + +/** + * Builds the change-detection hash for a repository file. The git blob objectId + * is content-addressable, so it changes exactly when the file content changes, + * and it is reported both by the tree listing (`objectId`) and the per-file + * metadata fetch (`objectId`) — so the listing stub and the hydrated document + * normally hash identically without a content fetch during listing. + * + * Hydration in getFileDocument is a two-step fetch against the same branch ref: + * a JSON metadata call yields the objectId used for this hash, then a raw + * octet-stream call yields the content. Both pin to the branch *name*, not a + * commit SHA, so if the branch advances between the two calls the stored hash + * (metadata call's objectId) and the stored content (content call's bytes) can + * be one commit apart. This window is bounded and self-heals: the next listing + * reports the branch's current objectId, which differs from the stored + * one-commit-old hash, queuing an update that re-fetches and re-converges + * content and hash. (A revert to identical bytes yields the identical objectId + * by content-addressing, so the stored content is already correct in that case.) + */ +function buildFileContentHash(repoId: string, objectId: string): string { + return `ado:file:${repoId}:${objectId}` +} + +interface WikiV2 { + id: string + name: string + remoteUrl?: string + type?: string +} + +interface GitRepository { + id: string + name: string + defaultBranch?: string + isDisabled?: boolean + remoteUrl?: string + webUrl?: string + size?: number +} + +interface GitItem { + objectId: string + gitObjectType?: string + path: string + isFolder?: boolean + content?: string + contentMetadata?: { + isBinary?: boolean + fileName?: string + encoding?: number + } +} + +/** + * A repository file flattened across all in-scope repositories, carrying enough + * context to build its stub and source URL during offset-based pagination. + */ +interface RepoFileEntry { + repoId: string + repoName: string + repoWebUrl?: string + branch: string + item: GitItem +} + +interface WikiPageDetail { + id: number + path: string +} + +interface WorkItemRef { + id: number +} + +interface RawWorkItem { + id: number + rev?: number + url?: string + fields?: Record +} + +/** + * Resolves the change-detection revision for a work item. ADO returns the + * revision as the top-level `rev` property on each batch item; `System.Rev` is + * not guaranteed to be echoed in the requested `fields`, so `rev` is the + * authoritative source. Falls back to the in-fields rev, then `System.ChangedDate`. + */ +function resolveWorkItemRev(raw: RawWorkItem, fields: Record): string { + if (typeof raw.rev === 'number') return String(raw.rev) + const fieldRev = fields['System.Rev'] + if (typeof fieldRev === 'number') return String(fieldRev) + const changed = fields['System.ChangedDate'] + if (typeof changed === 'string' && changed) return changed + return '0' +} + +/** + * Fetches the list of wikis in the configured project. Returns an empty list on + * 401/403/404 so a missing or inaccessible wiki feature degrades gracefully + * rather than aborting the sync. + */ +async function listWikis( + accessToken: string, + organization: string, + project: string, + retryOptions?: Parameters[2], + syncContext?: Record +): Promise { + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/wiki/wikis?api-version=${WIKIS_LIST_API_VERSION}` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }, + retryOptions + ) + if (!response.ok) { + if (response.status === 401 || response.status === 403 || response.status === 404) { + /** + * 401/403 mean the wikis still exist but this PAT cannot read them right + * now — flag the listing as incomplete so reconciliation does not delete + * previously synced wiki pages. A 404 means the wiki feature/content is + * genuinely absent, so reconciliation stays enabled. + */ + if ((response.status === 401 || response.status === 403) && syncContext) { + syncContext.listingCapped = true + } + logger.warn('Azure DevOps wikis unavailable; skipping wiki listing', { + organization, + project, + status: response.status, + }) + return [] + } + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Azure DevOps wikis', { status: response.status, error: errorText }) + throw new Error(`Failed to list wikis: ${response.status}`) + } + const data = await response.json() + return (data.value as WikiV2[] | undefined) ?? [] +} + +/** + * Resolves the wikis for the project, caching them on the sync context so a + * single sync (and its deferred getDocument calls) reuse one listing. + */ +async function resolveWikis( + accessToken: string, + organization: string, + project: string, + syncContext?: Record +): Promise { + const cached = syncContext?.wikis as WikiV2[] | undefined + if (cached) return cached + const wikis = await listWikis(accessToken, organization, project, undefined, syncContext) + if (syncContext) syncContext.wikis = wikis + return wikis +} + +/** + * Returns true when the wiki should be included given an optional wiki filter + * (matched case-insensitively against the wiki id or name). + */ +function wikiMatchesFilter(wiki: WikiV2, filter: string): boolean { + if (!filter) return true + const needle = filter.toLowerCase() + return wiki.id.toLowerCase() === needle || (wiki.name ?? '').toLowerCase() === needle +} + +/** + * Fetches the ETag for a single wiki page without downloading its content. + * The ETag changes whenever the page is edited, making it a reliable + * metadata-only change-detection hash for the deferred-content pattern. + */ +async function fetchWikiPageETag( + accessToken: string, + organization: string, + project: string, + wikiId: string, + pagePath: string +): Promise { + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages?path=${encodeURIComponent(pagePath)}&api-version=${WIKI_API_VERSION}` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }) + if (!response.ok) { + if (response.status === 404) return null + logger.warn('Failed to fetch wiki page ETag', { pagePath, status: response.status }) + return null + } + const etag = response.headers.get('etag') + return etag ? etag.replace(/"/g, '') : null +} + +/** + * Builds a wiki page stub. The contentHash is derived from the page ETag + * (falls back to the page id when no ETag is available), guaranteeing the + * hash is identical between listing and content fetch. + */ +function wikiPageToStub( + organization: string, + project: string, + wiki: WikiV2, + page: WikiPageDetail, + etag: string | null +): ExternalDocument { + const title = page.path.split('/').filter(Boolean).pop() || page.path || 'Untitled' + const sourceUrl = wiki.remoteUrl + ? `${wiki.remoteUrl}?pagePath=${encodeURIComponent(page.path)}` + : undefined + return { + externalId: wikiPageExternalId(wiki.id, page.path), + title, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl, + contentHash: `ado:wiki:${wiki.id}:${page.path}:${etag ?? page.id}`, + metadata: { + kind: 'wiki', + organization, + project, + wikiId: wiki.id, + wikiName: wiki.name, + pageId: page.id, + pagePath: page.path, + }, + } +} + +/** + * Builds a work item document. Work items are returned inline (not deferred) + * because the batch fetch already includes all field content. The contentHash + * uses the work item revision, which increments on every change. HTML-bearing + * fields (description, repro steps, acceptance criteria) are stripped to text. + */ +function workItemToDocument( + organization: string, + project: string, + raw: RawWorkItem +): ExternalDocument { + const fields = raw.fields ?? {} + const title = (fields['System.Title'] as string | undefined) ?? `Work Item ${raw.id}` + const workItemType = (fields['System.WorkItemType'] as string | undefined) ?? '' + const state = (fields['System.State'] as string | undefined) ?? '' + const rev = resolveWorkItemRev(raw, fields) + const changedDate = (fields['System.ChangedDate'] as string | undefined) ?? '' + const areaPath = (fields['System.AreaPath'] as string | undefined) ?? '' + const iterationPath = (fields['System.IterationPath'] as string | undefined) ?? '' + const rawTags = (fields['System.Tags'] as string | undefined) ?? '' + const tags = rawTags + .split(';') + .map((t) => t.trim()) + .filter(Boolean) + const description = htmlToPlainText((fields['System.Description'] as string | undefined) ?? '') + const reproSteps = htmlToPlainText( + (fields['Microsoft.VSTS.TCM.ReproSteps'] as string | undefined) ?? '' + ) + const acceptanceCriteria = htmlToPlainText( + (fields['Microsoft.VSTS.Common.AcceptanceCriteria'] as string | undefined) ?? '' + ) + + const contentParts: string[] = [`Title: ${title}`, `Type: ${workItemType}`, `State: ${state}`] + if (tags.length > 0) contentParts.push(`Tags: ${tags.join(', ')}`) + if (description) contentParts.push('', 'Description:', description) + if (reproSteps) contentParts.push('', 'Repro Steps:', reproSteps) + if (acceptanceCriteria) contentParts.push('', 'Acceptance Criteria:', acceptanceCriteria) + + return { + externalId: workItemExternalId(raw.id), + title: `#${raw.id}: ${title}`, + content: contentParts.join('\n'), + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_workitems/edit/${raw.id}`, + contentHash: `ado:wi:${raw.id}:${rev}`, + metadata: { + kind: 'workitem', + organization, + project, + workItemId: raw.id, + workItemType, + state, + areaPath, + iterationPath, + tags, + changedDate, + rev, + }, + } +} + +/** + * Reads the work-item filter configuration from sourceConfig. + */ +interface WorkItemFilters { + workItemType: string + state: string + areaPath: string + tags: string[] + customWiql: string +} + +function readWorkItemFilters(sourceConfig: Record): WorkItemFilters { + const tagsRaw = readString(sourceConfig.workItemTags) + const tags = tagsRaw + ? tagsRaw + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : [] + return { + workItemType: readString(sourceConfig.workItemType), + state: readString(sourceConfig.state), + areaPath: readString(sourceConfig.areaPath), + tags, + customWiql: readString(sourceConfig.customWiql), + } +} + +/** + * Builds the WIQL query for the configured work-item filters. User-supplied + * values are escaped against WIQL string-literal injection. `lastSyncAt` + * narrows results to items changed since the previous sync, and `idAfter` + * restricts to items with a greater id (used to probe past the 20,000-item + * WIQL cap). + * + * A custom WIQL query is used verbatim: neither the incremental changed-date + * filter nor the probe condition can be injected into arbitrary user WIQL + * safely, so custom queries always run as full listings on every sync. Change + * detection still short-circuits unchanged items via the content hash. + */ +function buildWiql(filters: WorkItemFilters, lastSyncAt?: Date, idAfter?: number): string { + if (filters.customWiql) return filters.customWiql + + const clauses: string[] = ['[System.TeamProject] = @project'] + if (filters.workItemType) { + clauses.push(`[System.WorkItemType] = '${escapeWiql(filters.workItemType)}'`) + } + if (filters.state) { + clauses.push(`[System.State] = '${escapeWiql(filters.state)}'`) + } + if (filters.areaPath) { + clauses.push(`[System.AreaPath] UNDER '${escapeWiql(filters.areaPath)}'`) + } + for (const tag of filters.tags) { + clauses.push(`[System.Tags] CONTAINS '${escapeWiql(tag)}'`) + } + if (lastSyncAt) { + clauses.push(`[System.ChangedDate] >= '${lastSyncAt.toISOString()}'`) + } + if (idAfter !== undefined) { + clauses.push(`[System.Id] > ${idAfter}`) + } + + return `SELECT [System.Id] FROM workitems WHERE ${clauses.join(' AND ')} ORDER BY [System.ChangedDate] DESC` +} + +/** + * Runs a WIQL query for work items in the project and returns their IDs. + * WIQL itself is not paginated and returns at most 20,000 ids; pagination + * happens over the resulting ID list via the workitemsbatch endpoint. + */ +async function queryWorkItemIds( + accessToken: string, + organization: string, + project: string, + wiql: string, + top: number = WIQL_MAX_RESULTS +): Promise { + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/wit/wiql?$top=${top}&api-version=${WIQL_API_VERSION}` + const response = await fetchWithRetry(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: patAuthHeader(accessToken), + }, + body: JSON.stringify({ query: wiql }), + }) + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to query Azure DevOps work items', { + status: response.status, + error: errorText, + }) + throw new Error(`Failed to query work items: ${response.status}`) + } + const data = await response.json() + const refs = (data.workItems as WorkItemRef[] | undefined) ?? [] + if (refs.length >= WIQL_MAX_RESULTS) { + logger.warn('WIQL result hit the 20,000-item cap; narrow work-item filters to sync all items', { + organization, + project, + }) + } + return refs.map((ref) => ref.id) +} + +/** + * Fetches full field details for a batch of work item IDs (max 200 per call). + * `errorPolicy: 'Omit'` keeps the batch resilient: a single inaccessible or + * deleted id is dropped from the response rather than failing the whole call. + */ +async function fetchWorkItemsBatch( + accessToken: string, + organization: string, + project: string, + ids: number[] +): Promise { + if (ids.length === 0) return [] + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/wit/workitemsbatch?api-version=${WORKITEMS_API_VERSION}` + const response = await fetchWithRetry(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: patAuthHeader(accessToken), + }, + body: JSON.stringify({ + ids, + errorPolicy: 'Omit', + fields: [ + 'System.Id', + 'System.Title', + 'System.WorkItemType', + 'System.State', + 'System.AreaPath', + 'System.IterationPath', + 'System.ChangedDate', + 'System.Tags', + 'System.Description', + 'Microsoft.VSTS.TCM.ReproSteps', + 'Microsoft.VSTS.Common.AcceptanceCriteria', + ], + }), + }) + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to fetch Azure DevOps work items batch', { + status: response.status, + error: errorText, + }) + throw new Error(`Failed to fetch work items batch: ${response.status}`) + } + const data = await response.json() + return (data.value as RawWorkItem[] | undefined) ?? [] +} + +/** + * Reads the repository-file filter configuration from sourceConfig. + */ +interface FileFilters { + repositoryName: string + branch: string + pathPrefix: string + extensions: Set | null +} + +function readFileFilters(sourceConfig: Record): FileFilters { + const rawPrefix = readString(sourceConfig.pathPrefix) + return { + repositoryName: readString(sourceConfig.repositoryName), + branch: readString(sourceConfig.branch), + pathPrefix: rawPrefix, + extensions: parseExtensions(sourceConfig.fileExtensions), + } +} + +/** + * Lists the project's git repositories. Returns an empty list on 401/403/404 so + * a project without Git or without repo access degrades gracefully instead of + * aborting the sync. + */ +async function listRepositories( + accessToken: string, + organization: string, + project: string, + retryOptions?: Parameters[2], + syncContext?: Record +): Promise { + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/git/repositories?api-version=${GIT_API_VERSION}` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }, + retryOptions + ) + if (!response.ok) { + if (response.status === 401 || response.status === 403 || response.status === 404) { + /** + * 401/403 mean repositories still exist but this PAT cannot read them + * right now — flag the listing as incomplete so reconciliation does not + * delete previously synced repository files. A 404 means the Git feature + * is genuinely absent, so reconciliation stays enabled. + */ + if ((response.status === 401 || response.status === 403) && syncContext) { + syncContext.listingCapped = true + } + logger.warn('Azure DevOps repositories unavailable; skipping file listing', { + organization, + project, + status: response.status, + }) + return [] + } + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Azure DevOps repositories', { + status: response.status, + error: errorText, + }) + throw new Error(`Failed to list repositories: ${response.status}`) + } + const data = await response.json() + return (data.value as GitRepository[] | undefined) ?? [] +} + +/** + * Resolves the in-scope repositories for the project, caching them on the sync + * context so a single sync reuses one listing. Disabled repositories and, when a + * filter is set, non-matching repositories are excluded. + */ +async function resolveRepositories( + accessToken: string, + organization: string, + project: string, + repositoryFilter: string, + syncContext?: Record +): Promise { + const cached = syncContext?.repositories as GitRepository[] | undefined + const all = + cached ?? (await listRepositories(accessToken, organization, project, undefined, syncContext)) + if (syncContext && !cached) syncContext.repositories = all + + const needle = repositoryFilter.toLowerCase() + return all.filter((repo) => { + if (repo.isDisabled) return false + if (!needle) return true + return repo.id.toLowerCase() === needle || (repo.name ?? '').toLowerCase() === needle + }) +} + +/** + * Lists every blob in a repository at the given branch via the non-paginated + * Items list API (recursionLevel=Full). Returns an empty list on 401/403/404 so + * a single inaccessible or empty repo does not abort the sync. + */ +async function listRepositoryBlobs( + accessToken: string, + organization: string, + project: string, + repoId: string, + branch: string, + syncContext?: Record +): Promise { + const params = new URLSearchParams({ + recursionLevel: 'Full', + 'versionDescriptor.version': branch, + 'versionDescriptor.versionType': 'Branch', + 'api-version': GIT_API_VERSION, + }) + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repoId)}/items?${params.toString()}` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }) + if (!response.ok) { + if (response.status === 401 || response.status === 403 || response.status === 404) { + /** + * 401/403 mean the repository's files still exist but this PAT cannot + * read them right now — flag the listing as incomplete so reconciliation + * does not delete previously synced files. A 404 means the branch/repo + * content is genuinely absent (empty repo, deleted branch), so + * reconciliation stays enabled. + */ + if ((response.status === 401 || response.status === 403) && syncContext) { + syncContext.listingCapped = true + } + logger.warn('Azure DevOps repository items unavailable; skipping repository', { + repoId, + branch, + status: response.status, + }) + return [] + } + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Azure DevOps repository items', { + repoId, + branch, + status: response.status, + error: errorText, + }) + throw new Error(`Failed to list repository items: ${response.status}`) + } + /** + * The Items list API documents no pagination, but very large trees may emit + * an `x-ms-continuationtoken` response header. No request parameter exists + * to follow it, so when it appears the tree is treated as incomplete: the + * listing is flagged so deletion reconciliation cannot remove files that + * were never returned. + */ + if (response.headers.get('x-ms-continuationtoken')) { + if (syncContext) syncContext.listingCapped = true + logger.warn( + 'Azure DevOps repository tree listing returned a continuation token; partial tree', + { + repoId, + branch, + } + ) + } + const data = await response.json() + const items = (data.value as GitItem[] | undefined) ?? [] + return items.filter((item) => item.gitObjectType === 'blob' && !item.isFolder && item.path) +} + +/** + * Builds the web UI URL for a repository file at a given branch. Azure DevOps + * file links use `{repoWebUrl}?path={path}&version=GB{branch}` (GB = Git Branch). + */ +function buildFileSourceUrl( + repoWebUrl: string | undefined, + branch: string, + path: string +): string | undefined { + if (!repoWebUrl) return undefined + return `${repoWebUrl}?path=${encodeURIComponent(path)}&version=GB${encodeURIComponent(branch)}` +} + +/** + * Builds a deferred stub for a repository file. Content is empty and fetched + * lazily via getDocument for new/changed files only. The contentHash is the git + * blob objectId, identical between the stub and the hydrated document. + */ +function fileToStub(organization: string, project: string, entry: RepoFileEntry): ExternalDocument { + const path = entry.item.path + const title = path.split('/').filter(Boolean).pop() || path + return { + externalId: fileExternalId(entry.repoId, path), + title, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: buildFileSourceUrl(entry.repoWebUrl, entry.branch, path), + contentHash: buildFileContentHash(entry.repoId, entry.item.objectId), + metadata: { + kind: 'file', + organization, + project, + repository: entry.repoName, + repositoryId: entry.repoId, + branch: entry.branch, + path, + }, + } +} + +/** + * Resolves the flattened, filtered list of repository files for the configured + * scope. Repositories are listed once, each is walked via the recursive Items + * API, and blobs are filtered by path prefix and extension. The result is cached + * on syncContext so offset-based pagination and the maxItems cap apply over a + * stable list across pages. + */ +async function resolveRepoFiles( + accessToken: string, + organization: string, + project: string, + filters: FileFilters, + syncContext?: Record +): Promise { + const cached = syncContext?.repoFiles as RepoFileEntry[] | undefined + if (cached) return cached + + const repositories = await resolveRepositories( + accessToken, + organization, + project, + filters.repositoryName, + syncContext + ) + + const normalizedPrefix = + filters.pathPrefix && !filters.pathPrefix.startsWith('/') + ? `/${filters.pathPrefix}` + : filters.pathPrefix + + const entries: RepoFileEntry[] = [] + for (const repo of repositories) { + const branch = filters.branch || stripRefsHeads(repo.defaultBranch ?? '') + if (!branch) { + /** + * No branch override and no resolvable default branch. An empty + * repository (size 0) has nothing to list and nothing previously synced, + * so it is skipped without flagging — flagging here would permanently + * suppress deletion reconciliation for any project containing an empty + * repo. A non-empty repository reaching this branch means content exists + * but its default branch ref is missing/unreadable, so the listing is + * flagged incomplete to protect previously synced files from + * reconciliation deletion. + */ + if ((repo.size ?? 0) > 0 && syncContext) { + syncContext.listingCapped = true + } + logger.warn('Skipping Azure DevOps repository with no default branch', { + repoId: repo.id, + repoName: repo.name, + size: repo.size ?? 0, + }) + continue + } + const blobs = await listRepositoryBlobs( + accessToken, + organization, + project, + repo.id, + branch, + syncContext + ) + for (const item of blobs) { + if (normalizedPrefix && !item.path.startsWith(normalizedPrefix)) continue + if (!matchesExtension(item.path, filters.extensions)) continue + entries.push({ + repoId: repo.id, + repoName: repo.name, + repoWebUrl: repo.webUrl, + branch, + item, + }) + } + } + + if (syncContext) syncContext.repoFiles = entries + return entries +} + +/** + * Lists a single batch of repository-file stubs. The full filtered file list is + * resolved once and cached on syncContext; the cursor is an offset into that + * list, of the form `file|{offset}`. + */ +async function listRepoFiles( + accessToken: string, + organization: string, + project: string, + filters: FileFilters, + maxItems: number, + cursor: string | undefined, + syncContext: Record | undefined +): Promise { + const entries = await resolveRepoFiles(accessToken, organization, project, filters, syncContext) + + if (entries.length === 0) { + return { documents: [], hasMore: false } + } + + let offset = 0 + if (cursor) { + const parts = cursor.split('|') + offset = Number(parts[1]) || 0 + } + + const chunk = entries.slice(offset, offset + FILE_BATCH_SIZE) + const documents = chunk.map((entry) => fileToStub(organization, project, entry)) + + const nextOffset = offset + FILE_BATCH_SIZE + const { documents: capped, capped: hitLimit } = applyMaxItemsCap( + documents, + maxItems, + syncContext, + nextOffset < entries.length + ) + + const hasMore = !hitLimit && nextOffset < entries.length + + return { + documents: capped, + nextCursor: hasMore ? `file|${nextOffset}` : undefined, + hasMore, + } +} + +/** + * Resolves the branch to fetch a single repository file from in getDocument. Uses + * the configured branch override when set, otherwise the repository's default + * branch (resolved from the cached or freshly-listed repository record). + */ +async function resolveFileBranch( + accessToken: string, + organization: string, + project: string, + repoId: string, + branchOverride: string, + syncContext?: Record +): Promise<{ branch: string; repo?: GitRepository }> { + if (branchOverride) { + const repos = (syncContext?.repositories as GitRepository[] | undefined) ?? [] + return { branch: branchOverride, repo: repos.find((r) => r.id === repoId) } + } + const repos = + (syncContext?.repositories as GitRepository[] | undefined) ?? + (await listRepositories(accessToken, organization, project)) + if (syncContext && !syncContext.repositories) syncContext.repositories = repos + const repo = repos.find((r) => r.id === repoId) + return { branch: stripRefsHeads(repo?.defaultBranch ?? ''), repo } +} + +/** + * Fetches and hydrates a single repository file by its externalId. Re-fetches the + * item with content, rebuilds the objectId-based hash identically to the stub, + * and skips binary, oversized, or empty files. Returns null for 404 / not found. + */ +async function getFileDocument( + accessToken: string, + organization: string, + project: string, + externalId: string, + branchOverride: string, + syncContext?: Record +): Promise { + const parsed = parseFileExternalId(externalId) + if (!parsed) return null + const { repoId, path } = parsed + + const { branch, repo } = await resolveFileBranch( + accessToken, + organization, + project, + repoId, + branchOverride, + syncContext + ) + if (!branch) { + logger.warn('Cannot resolve branch for Azure DevOps file', { externalId }) + return null + } + + const metadataParams = new URLSearchParams({ + path, + 'versionDescriptor.version': branch, + 'versionDescriptor.versionType': 'Branch', + includeContentMetadata: 'true', + $format: 'json', + 'api-version': GIT_API_VERSION, + }) + const metadataUrl = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repoId)}/items?${metadataParams.toString()}` + const metadataResponse = await fetchWithRetry(metadataUrl, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }) + + if (!metadataResponse.ok) { + if (metadataResponse.status === 404) return null + throw new Error(`Failed to fetch repository file metadata: ${metadataResponse.status}`) + } + + const item = (await metadataResponse.json()) as GitItem + if (!item.objectId) return null + if (item.contentMetadata?.isBinary) { + logger.info('Skipping binary Azure DevOps file', { path }) + return null + } + + /** + * Content is fetched as raw bytes (Accept: application/octet-stream) rather + * than via `includeContent=true` JSON. The JSON `content` field's encoding is + * ambiguous (the API may deliver base64 or codepage-transcoded text per + * `ItemContentType`), whereas the octet-stream response is the byte-exact git + * blob, which is then binary-sniffed and decoded as UTF-8. + */ + const contentParams = new URLSearchParams({ + path, + 'versionDescriptor.version': branch, + 'versionDescriptor.versionType': 'Branch', + 'api-version': GIT_API_VERSION, + }) + const contentUrl = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repoId)}/items?${contentParams.toString()}` + const contentResponse = await fetchWithRetry(contentUrl, { + method: 'GET', + headers: { Accept: 'application/octet-stream', Authorization: patAuthHeader(accessToken) }, + }) + + if (!contentResponse.ok) { + if (contentResponse.status === 404) return null + throw new Error(`Failed to fetch repository file content: ${contentResponse.status}`) + } + + const buffer = await readBodyWithLimit(contentResponse, MAX_FILE_SIZE) + if (buffer === null) { + logger.info('Skipping oversized Azure DevOps file', { path }) + return null + } + if (isBinaryBuffer(buffer)) { + logger.info('Skipping binary Azure DevOps file', { path }) + return null + } + + const content = buffer.toString('utf8') + if (!content.trim()) return null + + const title = path.split('/').filter(Boolean).pop() || path + return { + externalId, + title, + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: buildFileSourceUrl(repo?.webUrl, branch, path), + contentHash: buildFileContentHash(repoId, item.objectId), + metadata: { + kind: 'file', + organization, + project, + repository: repo?.name ?? '', + repositoryId: repoId, + branch, + path, + size: buffer.byteLength, + }, + } +} + +/** + * Applies the optional maxItems cap to a batch, tracking the running total in + * syncContext and flagging `listingCapped` when the cap actually truncated the + * listing. The sync engine reads `listingCapped` to suppress deletion + * reconciliation on a truncated listing — without it, a capped full sync would + * wrongly delete every source document beyond the cap. + * + * `moreAvailable` tells the helper whether the current phase has further items + * beyond this page. The flag is only set when documents were actually dropped + * (this page was sliced, or more pages exist) — when the cap merely coincides + * with source exhaustion, reconciliation stays enabled so deleted source + * documents are still cleaned up. + */ +function applyMaxItemsCap( + documents: ExternalDocument[], + maxItems: number, + syncContext: Record | undefined, + moreAvailable: boolean +): { documents: ExternalDocument[]; capped: boolean } { + if (maxItems <= 0) return { documents, capped: false } + const prevTotal = (syncContext?.totalDocsFetched as number) ?? 0 + const remaining = Math.max(0, maxItems - prevTotal) + const slicedSome = documents.length > remaining + const sliced = slicedSome ? documents.slice(0, remaining) : documents + const newTotal = prevTotal + sliced.length + if (syncContext) syncContext.totalDocsFetched = newTotal + const capped = newTotal >= maxItems + if (capped && (slicedSome || moreAvailable) && syncContext) syncContext.listingCapped = true + return { documents: sliced, capped } +} + +/** + * Lists a single batch of wiki pages across the project's wikis (optionally + * filtered to one wiki). Uses a compound cursor of the form + * `wiki|{wikiIndex}|{continuationToken}` so each wiki's `pagesbatch` pagination + * is tracked independently. + */ +async function listWikiPages( + accessToken: string, + organization: string, + project: string, + wikiFilter: string, + maxItems: number, + cursor: string | undefined, + syncContext?: Record +): Promise { + const allWikis = await resolveWikis(accessToken, organization, project, syncContext) + const wikis = allWikis.filter((w) => wikiMatchesFilter(w, wikiFilter)) + + if (wikis.length === 0) { + return { documents: [], hasMore: false } + } + + let wikiIndex = 0 + let continuationToken: string | undefined + if (cursor) { + // The continuation token is opaque and may contain `|`; keep everything after + // the second separator intact instead of truncating it with a naive split. + const firstSep = cursor.indexOf('|') + const secondSep = firstSep === -1 ? -1 : cursor.indexOf('|', firstSep + 1) + if (secondSep !== -1) { + wikiIndex = Number(cursor.slice(firstSep + 1, secondSep)) || 0 + const token = cursor.slice(secondSep + 1) + continuationToken = token || undefined + } + } + + if (wikiIndex >= wikis.length) { + return { documents: [], hasMore: false } + } + + const wiki = wikis[wikiIndex] + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/wiki/wikis/${encodeURIComponent(wiki.id)}/pagesbatch?api-version=${WIKI_API_VERSION}` + const body: Record = { top: WIKI_PAGE_BATCH_SIZE } + if (continuationToken) body.continuationToken = continuationToken + + const response = await fetchWithRetry(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: patAuthHeader(accessToken), + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Azure DevOps wiki pages', { + wikiId: wiki.id, + status: response.status, + error: errorText, + }) + throw new Error(`Failed to list wiki pages: ${response.status}`) + } + + const data = await response.json() + const pages = (data.value as WikiPageDetail[] | undefined) ?? [] + const nextContinuation = response.headers.get('x-ms-continuationtoken') || undefined + + const documents: ExternalDocument[] = [] + for (let i = 0; i < pages.length; i += WIKI_ETAG_CONCURRENCY) { + const batch = pages.slice(i, i + WIKI_ETAG_CONCURRENCY) + const stubs = await Promise.all( + batch.map(async (page) => { + const etag = await fetchWikiPageETag(accessToken, organization, project, wiki.id, page.path) + return wikiPageToStub(organization, project, wiki, page, etag) + }) + ) + documents.push(...stubs) + } + + const { documents: capped, capped: hitLimit } = applyMaxItemsCap( + documents, + maxItems, + syncContext, + Boolean(nextContinuation) || wikiIndex + 1 < wikis.length + ) + if (hitLimit) { + return { documents: capped, hasMore: false } + } + + let nextCursor: string | undefined + let hasMore: boolean + if (nextContinuation) { + nextCursor = `wiki|${wikiIndex}|${nextContinuation}` + hasMore = true + } else if (wikiIndex + 1 < wikis.length) { + nextCursor = `wiki|${wikiIndex + 1}|` + hasMore = true + } else { + hasMore = false + } + + return { documents: capped, nextCursor, hasMore } +} + +/** + * Lists a single batch of work items. The full ID list is resolved once via WIQL + * and cached on the sync context; the cursor is an offset into that list. + */ +async function listWorkItems( + accessToken: string, + organization: string, + project: string, + filters: WorkItemFilters, + maxItems: number, + cursor: string | undefined, + syncContext: Record | undefined, + lastSyncAt: Date | undefined +): Promise { + let ids = syncContext?.workItemIds as number[] | undefined + if (!ids) { + const wiql = buildWiql(filters, lastSyncAt) + ids = await queryWorkItemIds(accessToken, organization, project, wiql) + if (syncContext) syncContext.workItemIds = ids + + if (ids.length >= WIQL_MAX_RESULTS && syncContext) { + /** + * The WIQL result filled the 20,000-item cap. Distinguish an exact fit + * from genuine truncation: for structured filters, probe for any + * matching item with an id beyond the largest returned one and only + * flag the listing incomplete when one exists — otherwise deletion + * reconciliation would be disabled forever for a project with exactly + * 20,000 matching items. Custom WIQL cannot be probed (no safe clause + * injection), so it is flagged conservatively. + */ + let truncated = true + if (!filters.customWiql) { + let maxId = 0 + for (const id of ids) { + if (id > maxId) maxId = id + } + const probeWiql = buildWiql(filters, lastSyncAt, maxId) + const beyond = await queryWorkItemIds(accessToken, organization, project, probeWiql, 1) + truncated = beyond.length > 0 + } + if (truncated) { + syncContext.listingCapped = true + } + } + } + + if (ids.length === 0) { + return { documents: [], hasMore: false } + } + + let offset = 0 + if (cursor) { + const parts = cursor.split('|') + offset = Number(parts[1]) || 0 + } + + const chunk = ids.slice(offset, offset + WORK_ITEM_BATCH_SIZE) + const raw = await fetchWorkItemsBatch(accessToken, organization, project, chunk) + if (raw.length < chunk.length && syncContext) { + syncContext.listingCapped = true + logger.warn( + 'workitemsbatch omitted ids that WIQL returned; flagging listing as incomplete so reconciliation skips deletion', + { requested: chunk.length, returned: raw.length, organization, project } + ) + } + const documents = raw.map((item) => workItemToDocument(organization, project, item)) + + const nextOffset = offset + WORK_ITEM_BATCH_SIZE + const { documents: capped, capped: hitLimit } = applyMaxItemsCap( + documents, + maxItems, + syncContext, + nextOffset < ids.length + ) + + const hasMore = !hitLimit && nextOffset < ids.length + + return { + documents: capped, + nextCursor: hasMore ? `wi|${nextOffset}` : undefined, + hasMore, + } +} + +export const azureDevopsConnector: ConnectorConfig = { + id: 'azure_devops', + name: 'Azure DevOps', + description: + 'Sync wiki pages, work items, and repository files from an Azure DevOps project into your knowledge base', + version: '1.1.0', + icon: AzureDevOpsIcon, + + auth: { + mode: 'apiKey', + label: 'Personal Access Token', + placeholder: 'Enter your Azure DevOps PAT (scopes: Wiki Read, Work Items Read, Code Read)', + }, + + /** + * Incremental sync applies to work items only, via a `System.ChangedDate` + * WIQL filter derived from lastSyncAt. Wiki pages have no change timestamp on + * listing, so they are always re-listed and reconciled by ETag content hash. + * Repository files are likewise always re-listed in full and reconciled by the + * git blob objectId hash — a commit-diff incremental path is intentionally + * avoided to match the github/gitlab full-listing approach, keeping change + * detection correct without tracking per-branch commit state. Unchanged + * documents are skipped without a content fetch in every case. + */ + supportsIncrementalSync: true, + + configFields: [ + { + id: 'organization', + title: 'Organization', + type: 'short-input', + placeholder: 'e.g. my-org', + required: true, + }, + { + id: 'project', + title: 'Project', + type: 'short-input', + placeholder: 'e.g. my-project', + required: true, + }, + { + id: 'contentType', + title: 'Content', + type: 'dropdown', + required: false, + options: [ + { label: 'Wiki pages only', id: 'wiki' }, + { label: 'Work items only', id: 'workitems' }, + { label: 'Repository files only', id: 'files' }, + { label: 'Wiki pages and work items', id: 'both' }, + { label: 'Wiki, work items, and files', id: 'all' }, + ], + description: 'Which content to index from the project.', + }, + { + id: 'wikiName', + title: 'Wiki', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'Wiki name or ID (all wikis if blank)', + description: + 'Restrict syncing to a single wiki by name or ID. Applies only when syncing wiki pages.', + }, + { + id: 'workItemType', + title: 'Work Item Type', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. Bug, Task, User Story', + description: 'Only sync work items of this type. Applies only when syncing work items.', + }, + { + id: 'state', + title: 'State', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. Active, Closed', + description: 'Only sync work items in this state. Applies only when syncing work items.', + }, + { + id: 'areaPath', + title: 'Area Path', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. MyProject\\Team A', + description: + 'Only sync work items under this area path (and its children). Applies only when syncing work items.', + }, + { + id: 'workItemTags', + title: 'Tags', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. customer, urgent (comma-separated)', + description: + 'Only sync work items containing all of these tags (comma-separated). Applies only when syncing work items.', + }, + { + id: 'customWiql', + title: 'Custom WIQL Query', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'SELECT [System.Id] FROM workitems WHERE ...', + description: + 'Advanced: a full WIQL query selecting [System.Id]. Overrides the type, state, area path, and tag filters when set. Custom queries always run as full listings on every sync (the incremental changed-date filter is not applied).', + }, + { + id: 'repositoryName', + title: 'Repository', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'Repository name or ID (all repos if blank)', + description: + 'Restrict syncing to a single repository by name or ID. Applies only when syncing repository files.', + }, + { + id: 'branch', + title: 'Branch', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: "Each repo's default branch", + description: + 'Branch to sync repository files from. Defaults to each repository’s default branch. Applies only when syncing repository files.', + }, + { + id: 'pathPrefix', + title: 'Path Filter', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. docs/, src/', + description: + 'Only sync repository files under this path prefix. Applies only when syncing repository files.', + }, + { + id: 'fileExtensions', + title: 'File Extensions', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. .md, .txt, .ts', + description: + 'Only sync repository files with these extensions (comma-separated). Leave blank for all text files. Applies only when syncing repository files.', + }, + { + id: 'maxItems', + title: 'Max Items', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record, + lastSyncAt?: Date + ): Promise => { + const organization = readString(sourceConfig.organization) + const project = readString(sourceConfig.project) + const contentType = parseContentType(sourceConfig.contentType) + const wikiFilter = readString(sourceConfig.wikiName) + const filters = readWorkItemFilters(sourceConfig) + const fileFilters = readFileFilters(sourceConfig) + const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0 + + if (!organization || !project) { + throw new Error('Organization and project are required') + } + + const phases = activePhases(contentType) + if (phases.length === 0) return { documents: [], hasMore: false } + + /** + * Resolves which phase a cursor belongs to. Phases run in a fixed order + * (wiki ➜ workitems ➜ file) and each phase owns a cursor prefix + * (`wiki|`, `wi|`, `file|`). A missing cursor starts at the first active phase. + */ + const cursorPhase: SyncPhase = cursor?.startsWith('wi|') + ? 'workitems' + : cursor?.startsWith('file|') + ? 'file' + : 'wiki' + + /** + * A cursor from a phase that is no longer active (e.g. the content-type + * config changed) is discarded along with its offsets — otherwise another + * phase would misparse its tokens as numeric offsets and skip documents. + */ + const cursorIsActive = phases.includes(cursorPhase) + const phase = cursorIsActive ? cursorPhase : phases[0] + const initialCursor = cursorIsActive ? cursor : undefined + + /** Lists a single batch for the given phase. The cursor is passed only when it belongs to that phase. */ + const runPhase = (target: SyncPhase, phaseCursor: string | undefined) => { + if (target === 'wiki') { + return listWikiPages( + accessToken, + organization, + project, + wikiFilter, + maxItems, + phaseCursor, + syncContext + ) + } + if (target === 'workitems') { + return listWorkItems( + accessToken, + organization, + project, + filters, + maxItems, + phaseCursor, + syncContext, + lastSyncAt + ) + } + return listRepoFiles( + accessToken, + organization, + project, + fileFilters, + maxItems, + phaseCursor, + syncContext + ) + } + + /** True once the maxItems cap has been reached during this sync run. */ + const capReached = () => + maxItems > 0 && ((syncContext?.totalDocsFetched as number) ?? 0) >= maxItems + + /** + * Walks phases starting at `phase`, accumulating documents. Within a phase, + * pagination is driven by that phase's own cursor; when a phase is exhausted + * the walker advances to the next active phase (resetting its cursor). The + * maxItems cap is honored at phase boundaries so the cap is never exceeded + * across phases. + */ + let current: SyncPhase | undefined = phase + let phaseCursor = initialCursor + const documents: ExternalDocument[] = [] + + while (current) { + const result = await runPhase(current, phaseCursor) + documents.push(...result.documents) + + if (result.hasMore) { + return { documents, nextCursor: result.nextCursor, hasMore: true } + } + if (capReached()) { + const remainingPhase = nextPhase(current, contentType) + if (remainingPhase && syncContext) { + syncContext.listingCapped = true + } + return { documents, hasMore: false } + } + current = nextPhase(current, contentType) + phaseCursor = undefined + } + + return { documents, hasMore: false } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string, + syncContext?: Record + ): Promise => { + const organization = readString(sourceConfig.organization) + const project = readString(sourceConfig.project) + if (!organization || !project) { + throw new Error('Organization and project are required') + } + + /** + * Repository files are deferred and re-fetched here. Work items are returned + * inline during listing, so getDocument is otherwise only invoked for + * deferred wiki pages. Unknown IDs return null defensively. + */ + if (externalId.startsWith(FILE_PREFIX)) { + try { + return await getFileDocument( + accessToken, + organization, + project, + externalId, + readString(sourceConfig.branch), + syncContext + ) + } catch (error) { + logger.warn(`Failed to fetch Azure DevOps file ${externalId}`, { + error: toError(error).message, + }) + return null + } + } + + const parsed = parseWikiExternalId(externalId) + if (!parsed) return null + + const { wikiId, pagePath } = parsed + + let wikiName: string | undefined + let remoteUrl: string | undefined + try { + const wikis = await resolveWikis(accessToken, organization, project, syncContext) + const wiki = wikis.find((w) => w.id === wikiId) + wikiName = wiki?.name + remoteUrl = wiki?.remoteUrl + } catch (error) { + logger.warn('Failed to resolve wiki metadata for page', { + externalId, + error: toError(error).message, + }) + } + + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages?path=${encodeURIComponent(pagePath)}&includeContent=true&api-version=${WIKI_API_VERSION}` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error(`Failed to fetch wiki page: ${response.status}`) + } + + const etag = response.headers.get('etag') + const data = await response.json() + const content = (data.content as string | undefined) ?? '' + if (!content.trim()) return null + + const pageId = (data.id as number | undefined) ?? 0 + const title = pagePath.split('/').filter(Boolean).pop() || pagePath || 'Untitled' + const sourceUrl = remoteUrl + ? `${remoteUrl}?pagePath=${encodeURIComponent(pagePath)}` + : ((data.remoteUrl as string | undefined) ?? undefined) + + return { + externalId, + title, + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl, + contentHash: `ado:wiki:${wikiId}:${pagePath}:${etag ? etag.replace(/"/g, '') : pageId}`, + metadata: { + kind: 'wiki', + organization, + project, + wikiId, + wikiName, + pageId, + pagePath, + }, + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const organization = readString(sourceConfig.organization) + const project = readString(sourceConfig.project) + + if (!organization || !project) { + return { valid: false, error: 'Organization and project are required' } + } + + const maxItems = sourceConfig.maxItems as string | undefined + if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) { + return { valid: false, error: 'Max items must be a positive number' } + } + + const customWiql = readString(sourceConfig.customWiql) + if (customWiql && !/from\s+workitems/i.test(customWiql)) { + return { + valid: false, + error: 'Custom WIQL query must select work items (e.g. "... FROM workitems WHERE ...")', + } + } + + const contentType = parseContentType(sourceConfig.contentType) + const repositoryFilter = readString(sourceConfig.repositoryName) + + try { + const url = `${ADO_BASE_URL}/${encodeURIComponent(organization)}/_apis/projects/${encodeURIComponent(project)}?api-version=${PROJECT_API_VERSION}` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { Accept: 'application/json', Authorization: patAuthHeader(accessToken) }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: 'Invalid or unauthorized Personal Access Token' } + } + if (response.status === 404) { + return { + valid: false, + error: `Project "${project}" not found in organization "${organization}"`, + } + } + if (!response.ok) { + return { valid: false, error: `Cannot access project: ${response.status}` } + } + + if (activePhases(contentType).includes('file')) { + const repos = await listRepositories( + accessToken, + organization, + project, + VALIDATE_RETRY_OPTIONS + ) + if (repositoryFilter) { + const needle = repositoryFilter.toLowerCase() + const match = repos.find( + (r) => r.id.toLowerCase() === needle || (r.name ?? '').toLowerCase() === needle + ) + if (!match) { + return { + valid: false, + error: `Repository "${repositoryFilter}" not found in project "${project}"`, + } + } + if (match.isDisabled) { + return { + valid: false, + error: `Repository "${repositoryFilter}" is disabled`, + } + } + } else if (repos.length === 0) { + if (contentType === 'files') { + return { + valid: false, + error: `No accessible Git repositories found in project "${project}"`, + } + } + logger.warn('No accessible repositories; repository files will be skipped', { + organization, + project, + }) + } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + tagDefinitions: [ + { id: 'kind', displayName: 'Type', fieldType: 'text' }, + { id: 'wikiName', displayName: 'Wiki', fieldType: 'text' }, + { id: 'workItemType', displayName: 'Work Item Type', fieldType: 'text' }, + { id: 'state', displayName: 'State', fieldType: 'text' }, + { id: 'areaPath', displayName: 'Area Path', fieldType: 'text' }, + { id: 'tags', displayName: 'Tags', fieldType: 'text' }, + { id: 'repository', displayName: 'Repository', fieldType: 'text' }, + { id: 'path', displayName: 'File Path', fieldType: 'text' }, + { id: 'changedDate', displayName: 'Changed Date', fieldType: 'date' }, + ], + + /** + * Maps document metadata to tag slots. `kind` applies to every document. + * `wikiName` is wiki-only; `workItemType`/`state`/`areaPath`/`tags`/`changedDate` + * are work-item-only; `repository`/`path` are file-only. Each document type leaves + * the others' fields empty and the type/empty guards below skip them. + */ + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.kind === 'string') result.kind = metadata.kind + if (typeof metadata.wikiName === 'string' && metadata.wikiName) { + result.wikiName = metadata.wikiName + } + if (typeof metadata.workItemType === 'string' && metadata.workItemType) { + result.workItemType = metadata.workItemType + } + if (typeof metadata.state === 'string' && metadata.state) result.state = metadata.state + if (typeof metadata.areaPath === 'string' && metadata.areaPath) + result.areaPath = metadata.areaPath + + if (typeof metadata.repository === 'string' && metadata.repository) { + result.repository = metadata.repository + } + if (typeof metadata.path === 'string' && metadata.path) result.path = metadata.path + + const tags = joinTagArray(metadata.tags) + if (tags) result.tags = tags + + const changedDate = parseTagDate(metadata.changedDate) + if (changedDate) result.changedDate = changedDate + + return result + }, +} diff --git a/apps/sim/connectors/azure-devops/index.ts b/apps/sim/connectors/azure-devops/index.ts new file mode 100644 index 00000000000..25f6550e9a3 --- /dev/null +++ b/apps/sim/connectors/azure-devops/index.ts @@ -0,0 +1 @@ +export { azureDevopsConnector } from '@/connectors/azure-devops/azure-devops' diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index 66e71796d60..b41303bdf26 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -470,15 +470,18 @@ async function fetchProject( } /** - * Encodes the listing cursor. The cursor packs the resource phase (wiki ➜ issues) - * and the issues page number so a single sync walks wikis first, then paginates - * issues via the X-Next-Page header. + * Encodes the listing cursor. The cursor packs the resource phase (repo ➜ wiki ➜ + * issues) and a per-phase continuation token so a single sync walks the phases in + * order. The repository-tree and issues phases both use GitLab keyset pagination + * and store the full `rel="next"` URL from the Link header to fetch verbatim. */ interface CursorState { phase: SyncPhase issuePage: number /** Full `rel="next"` URL for the repository-tree keyset page to fetch next. */ fileNextUrl?: string + /** Full `rel="next"` URL for the issues keyset page to fetch next. */ + issueNextUrl?: string } function encodeCursor(state: CursorState): string { @@ -492,6 +495,7 @@ function decodeCursor(cursor: string | undefined, initialPhase: SyncPhase): Curs phase: SyncPhase issuePage: number fileNextUrl: string + issueNextUrl: string }> const phase: SyncPhase = parsed.phase === 'repo' || parsed.phase === 'issues' || parsed.phase === 'wiki' @@ -501,6 +505,7 @@ function decodeCursor(cursor: string | undefined, initialPhase: SyncPhase): Curs phase, issuePage: Number(parsed.issuePage) > 0 ? Number(parsed.issuePage) : 1, fileNextUrl: typeof parsed.fileNextUrl === 'string' ? parsed.fileNextUrl : undefined, + issueNextUrl: typeof parsed.issueNextUrl === 'string' ? parsed.issueNextUrl : undefined, } } catch { return { phase: initialPhase, issuePage: 1 } @@ -859,9 +864,9 @@ export const gitlabConnector: ConnectorConfig = { if (state.phase === 'issues') { const params = new URLSearchParams({ per_page: String(PAGE_SIZE), - page: String(state.issuePage), order_by: 'updated_at', sort: 'desc', + pagination: 'keyset', }) if (lastSyncAt) params.set('updated_after', lastSyncAt.toISOString()) const issueState = @@ -874,11 +879,15 @@ export const gitlabConnector: ConnectorConfig = { typeof sourceConfig.issueMilestone === 'string' ? sourceConfig.issueMilestone.trim() : '' if (issueMilestone) params.set('milestone', issueMilestone) - const url = `${apiBase}/projects/${encodedProject}/issues?${params.toString()}` + if (state.issueNextUrl && !isSameOrigin(state.issueNextUrl, apiBase)) { + throw new Error('GitLab pagination cursor points to an unexpected host') + } + const url = + state.issueNextUrl ?? `${apiBase}/projects/${encodedProject}/issues?${params.toString()}` logger.info('Listing GitLab issues', { host, project: encodedProject, - page: state.issuePage, + continued: Boolean(state.issueNextUrl), incremental: Boolean(lastSyncAt), }) @@ -909,18 +918,18 @@ export const gitlabConnector: ConnectorConfig = { maxItems, syncContext ) + if (hitLimit) return { documents: capped, hasMore: false } - const nextPageHeader = response.headers.get('x-next-page')?.trim() - const nextPage = nextPageHeader ? Number(nextPageHeader) : 0 - const hasMorePages = !hitLimit && Number.isFinite(nextPage) && nextPage > 0 - - return { - documents: capped, - nextCursor: hasMorePages - ? encodeCursor({ phase: 'issues', issuePage: nextPage }) - : undefined, - hasMore: hasMorePages, + const nextLink = parseNextLink(response.headers.get('link')) + if (nextLink) { + return { + documents: capped, + nextCursor: encodeCursor({ phase: 'issues', issuePage: 1, issueNextUrl: nextLink }), + hasMore: true, + } } + + return { documents: capped, hasMore: false } } return { documents: [], hasMore: false } diff --git a/apps/sim/connectors/gong/gong.ts b/apps/sim/connectors/gong/gong.ts index beb7391573c..69ce220c1fd 100644 --- a/apps/sim/connectors/gong/gong.ts +++ b/apps/sim/connectors/gong/gong.ts @@ -417,20 +417,32 @@ export const gongConnector: ConnectorConfig = { const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 let documents = allDocuments + let capDroppedDocs = false if (maxCalls > 0) { const remaining = Math.max(0, maxCalls - prevFetched) if (allDocuments.length > remaining) { documents = allDocuments.slice(0, remaining) + capDroppedDocs = true } } const totalFetched = prevFetched + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxCalls > 0 && totalFetched >= maxCalls - if (hitLimit && syncContext) syncContext.listingCapped = true - const hasMore = !hitLimit && Boolean(nextPageCursor) + /** + * Only flag the listing as capped when the `maxCalls` limit actually + * truncated calls that still exist in the source — either by dropping calls + * from the current page or by stopping while another page remains. Reaching + * the limit exactly at source exhaustion (no dropped calls, no further + * cursor) yields a complete listing, so deletion reconciliation must still + * run for calls removed in Gong. + */ + if (syncContext && (capDroppedDocs || (hitLimit && Boolean(nextPageCursor)))) { + syncContext.listingCapped = true + } + return { documents, nextCursor: hasMore ? nextPageCursor : undefined, diff --git a/apps/sim/connectors/google-docs/google-docs.ts b/apps/sim/connectors/google-docs/google-docs.ts index 0a89ea91650..f125f23a44c 100644 --- a/apps/sim/connectors/google-docs/google-docs.ts +++ b/apps/sim/connectors/google-docs/google-docs.ts @@ -235,13 +235,23 @@ export const googleDocsConnector: ConnectorConfig = { const data = await response.json() const files = (data.files || []) as DriveFile[] + /** + * Drive sets `incompleteSearch` when it could not search every corpus (it + * arises with the `allDrives` scope enabled by `includeItemsFromAllDrives`). + * A partial listing drops still-existing docs, so reconciliation must be + * suppressed to avoid hard-deleting valid documents. + */ + const incompleteSearch = data.incompleteSearch === true + const maxDocs = sourceConfig.maxDocs ? Number(sourceConfig.maxDocs) : 0 const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 let documents = files.map(fileToStub) + let slicedSome = false if (maxDocs > 0) { const remaining = maxDocs - previouslyFetched if (documents.length > remaining) { + slicedSome = true documents = documents.slice(0, remaining) } } @@ -252,6 +262,19 @@ export const googleDocsConnector: ConnectorConfig = { const nextPageToken = data.nextPageToken as string | undefined + /** + * Mark the listing as incomplete so the sync engine skips deletion + * reconciliation when this page does not represent the full source set: + * - `slicedSome`: the page held more docs than the `maxDocs` cap allowed. + * - `hitLimit` with a next page: the cap was reached while more pages remain. + * - `incompleteSearch`: Drive could not search every corpus, so the page is + * partial and may omit still-existing docs. + * Reconciliation against any of these would hard-delete valid documents. + */ + if (syncContext && (slicedSome || (hitLimit && Boolean(nextPageToken)) || incompleteSearch)) { + syncContext.listingCapped = true + } + return { documents, nextCursor: hitLimit ? undefined : nextPageToken, diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index 0516c47fd7e..41013cdb247 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -268,6 +268,14 @@ export const googleDriveConnector: ConnectorConfig = { const data = await response.json() const files = (data.files || []) as DriveFile[] + /** + * Drive sets `incompleteSearch` when it could not search every corpus (it + * arises with the `allDrives` scope enabled by `includeItemsFromAllDrives`). + * A partial listing drops still-existing files, so reconciliation must be + * suppressed to avoid hard-deleting valid documents. + */ + const incompleteSearch = data.incompleteSearch === true + const documents = files .filter((f) => isGoogleWorkspaceFile(f.mimeType) || isSupportedTextFile(f.mimeType)) .map(fileToStub) @@ -275,7 +283,7 @@ export const googleDriveConnector: ConnectorConfig = { const totalFetched = previouslyFetched + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxFiles > 0 && totalFetched >= maxFiles - if (hitLimit && syncContext) syncContext.listingCapped = true + if (syncContext && (hitLimit || incompleteSearch)) syncContext.listingCapped = true const nextPageToken = data.nextPageToken as string | undefined diff --git a/apps/sim/connectors/google-forms/google-forms.ts b/apps/sim/connectors/google-forms/google-forms.ts new file mode 100644 index 00000000000..d29bf5ef9b3 --- /dev/null +++ b/apps/sim/connectors/google-forms/google-forms.ts @@ -0,0 +1,819 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { GoogleFormsIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { joinTagArray, parseTagDate } from '@/connectors/utils' + +const logger = createLogger('GoogleFormsConnector') + +const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3' +const FORMS_API_BASE = 'https://forms.googleapis.com/v1' +const FORM_MIME_TYPE = 'application/vnd.google-apps.form' +const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' + +/** + * Hard cap on the number of responses appended to a single form document. + * Keeps individual documents within a reasonable size for embedding/indexing. + */ +const MAX_RESPONSES_PER_FORM = 500 + +/** + * Drive API page size when listing forms. The Drive API caps pageSize at 100. + */ +const DRIVE_PAGE_SIZE = 100 + +/** + * Maximum responses returned per Forms API page (API caps and defaults to 5000). + */ +const RESPONSES_PAGE_SIZE = 5000 + +/** + * Number of forms whose change indicators are fetched concurrently during + * listing. Keeps the Forms API call volume bounded while still parallelizing. + */ +const LIST_CONCURRENCY = 4 + +/** + * Content scope for a form document. `both` indexes the form's questions and its + * submitted responses; `structure` indexes only the questions (no response reads, + * so the responses scope is never exercised for that connector instance). + */ +type ContentScope = 'both' | 'structure' + +/** + * Resolves the content scope from sourceConfig, defaulting to `both`. + */ +function resolveContentScope(value: unknown): ContentScope { + return value === 'structure' ? 'structure' : 'both' +} + +/** + * Represents a Google Drive file entry for a form, returned by the Drive API. + */ +interface DriveFormFile { + id: string + name: string + mimeType: string + modifiedTime?: string + createdTime?: string + webViewLink?: string + owners?: { displayName?: string; emailAddress?: string }[] + trashed?: boolean +} + +/** + * A single answer entry inside a response answer container. + */ +interface FormTextAnswer { + value?: string +} + +/** + * A single question's answers within a form response. The Forms API keys the + * `answers` map by questionId and stores text values under + * `textAnswers.answers[].value`. + */ +interface FormAnswer { + questionId?: string + textAnswers?: { answers?: FormTextAnswer[] } +} + +/** + * A single submitted response to a form. + */ +interface FormResponse { + responseId?: string + createTime?: string + lastSubmittedTime?: string + respondentEmail?: string + answers?: Record +} + +/** + * Paginated response list from the Forms API. + */ +interface FormResponseList { + responses?: FormResponse[] + nextPageToken?: string +} + +/** + * A question item within a form's structure. + */ +interface FormQuestionItem { + question?: { + questionId?: string + required?: boolean + } +} + +/** + * A single structural item within a form (question, section, image, etc.). + */ +interface FormItem { + itemId?: string + title?: string + description?: string + questionItem?: FormQuestionItem +} + +/** + * The form structure returned by the Forms API `forms.get` endpoint. + */ +interface FormStructure { + formId?: string + info?: { + title?: string + description?: string + documentTitle?: string + } + items?: FormItem[] + revisionId?: string + responderUri?: string +} + +/** + * Lightweight metadata captured during listing, sufficient to build a stub + * and detect changes without downloading the full form content. + */ +interface FormStubInput { + file: DriveFormFile + formTitle?: string + revisionId?: string + latestResponseTime?: string + contentScope: ContentScope + responseCap: number +} + +/** + * Resolves the effective per-form response cap applied when rendering content: + * the user-configured `maxResponsesPerForm` clamped to the hard + * `MAX_RESPONSES_PER_FORM` ceiling. Part of the content hash so changing the + * cap re-syncs every form (the rendered content depends on it). + */ +function resolveResponseCap(sourceConfig: Record): number { + const configured = parsePositiveInt(sourceConfig.maxResponsesPerForm) + return configured > 0 ? Math.min(configured, MAX_RESPONSES_PER_FORM) : MAX_RESPONSES_PER_FORM +} + +/** + * Parses an optional positive-integer config value, returning 0 when unset/invalid. + */ +function parsePositiveInt(value: unknown): number { + if (value == null || value === '') return 0 + const num = Number(value) + return Number.isNaN(num) || num <= 0 ? 0 : Math.floor(num) +} + +/** + * Maps a small array over an async worker with a bounded concurrency, preserving + * input order in the returned results. + */ +async function mapWithConcurrency( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length) + let next = 0 + + async function run(): Promise { + while (next < items.length) { + const current = next++ + results[current] = await worker(items[current], current) + } + } + + const runners = Array.from({ length: Math.min(limit, items.length) }, run) + await Promise.all(runners) + return results +} + +/** + * Fetches the form structure via the Forms API. Returns null on 404 (form + * deleted or inaccessible). + */ +async function fetchFormStructure( + accessToken: string, + formId: string +): Promise { + const url = `${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error(`Failed to fetch form structure ${formId}: ${response.status}`) + } + + return (await response.json()) as FormStructure +} + +/** + * Result of fetching a form's responses: the collected responses (capped at + * `MAX_RESPONSES_PER_FORM` for rendering) plus the greatest submission timestamp + * across ALL response pages. + * + * `latestSubmittedTime` is tracked separately from the capped `responses` so the + * content hash computed in getDocument stays identical to the one computed during + * listing, which scans the same full set via `fetchLatestResponseTime`. If it + * were derived from the capped slice alone, a form with more than + * `MAX_RESPONSES_PER_FORM` responses could hash differently between the two paths + * and re-sync on every run. + */ +interface FetchedResponses { + responses: FormResponse[] + latestSubmittedTime?: string +} + +/** + * Fetches form responses, retaining up to `MAX_RESPONSES_PER_FORM` for rendering. + * Every page is scanned for the latest submission timestamp even after the + * render cap is reached — the Forms API does not guarantee response order, so + * the newest submission may sit on any page. `fetchLatestResponseTime` scans + * the same full set during listing, keeping the content hash identical across + * the listing and getDocument paths regardless of form size. + */ +async function fetchFormResponses(accessToken: string, formId: string): Promise { + const collected: FormResponse[] = [] + let latest = '' + let pageToken: string | undefined + + do { + const url = new URL(`${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses`) + url.searchParams.set('pageSize', String(RESPONSES_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + + const response = await fetchWithRetry(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to list responses for form ${formId}: ${response.status}`) + } + + const data = (await response.json()) as FormResponseList + const responses = data.responses ?? [] + + const pageLatest = latestResponseTime(responses) + if (pageLatest && pageLatest > latest) latest = pageLatest + + for (const r of responses) { + if (collected.length >= MAX_RESPONSES_PER_FORM) break + collected.push(r) + } + + pageToken = data.nextPageToken + } while (pageToken) + + return { responses: collected, latestSubmittedTime: latest || undefined } +} + +/** + * Reads the latest response submission time for change detection without + * retaining responses. Scans every page — the Forms API does not guarantee + * response order, so the newest submission may sit on any page. Returns the + * greatest `lastSubmittedTime` (falling back to `createTime`), or undefined + * when there are none. Throws on a failed read so the caller skips the form + * for this run instead of computing a hash from incomplete data — a swallowed + * error would poison the stub's content hash and re-process the form on every + * sync, while throwing routes into the per-form catch that sets + * `skippedOnError` → `listingCapped`. + */ +async function fetchLatestResponseTime( + accessToken: string, + formId: string +): Promise { + let latest = '' + let pageToken: string | undefined + + do { + const url = new URL(`${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses`) + url.searchParams.set('pageSize', String(RESPONSES_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + + const response = await fetchWithRetry(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error( + `Failed to read responses for change detection on form ${formId}: ${response.status}` + ) + } + + const data = (await response.json()) as FormResponseList + const pageLatest = latestResponseTime(data.responses ?? []) + if (pageLatest && pageLatest > latest) latest = pageLatest + pageToken = data.nextPageToken + } while (pageToken) + + return latest || undefined +} + +/** + * Returns the greatest submission timestamp across the given responses, or + * undefined when the list is empty. + */ +function latestResponseTime(responses: FormResponse[]): string | undefined { + let latest = '' + for (const r of responses) { + const t = r.lastSubmittedTime || r.createTime || '' + if (t > latest) latest = t + } + return latest || undefined +} + +/** + * Builds the content hash for a form. The hash must change when either the form + * structure (revisionId) or, when responses are indexed, the set of responses + * (latest submission time) changes. Drive `modifiedTime` alone is insufficient + * because new response submissions do not update the form's Drive modifiedTime. + * The content scope is part of the hash so that toggling response indexing + * forces a re-sync of every document. + */ +function formContentHash(input: FormStubInput): string { + const responsePart = + input.contentScope === 'both' + ? `${input.latestResponseTime ?? ''}:${input.responseCap}` + : 'none' + return `gforms:${input.file.id}:${input.contentScope}:${input.revisionId ?? ''}:${responsePart}` +} + +/** + * Creates a lightweight stub from a form's Drive file and change indicators. + * Content is deferred and only fetched via getDocument for new/changed forms. + */ +function formToStub(input: FormStubInput): ExternalDocument { + const { file } = input + const title = input.formTitle?.trim() || file.name || 'Untitled Form' + return { + externalId: file.id, + title, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: file.webViewLink || `https://docs.google.com/forms/d/${file.id}/edit`, + contentHash: formContentHash(input), + metadata: { + formTitle: title, + modifiedTime: file.modifiedTime, + createdTime: file.createdTime, + latestResponseTime: input.contentScope === 'both' ? input.latestResponseTime : undefined, + owners: file.owners?.map((o) => o.displayName || o.emailAddress).filter(Boolean), + }, + } +} + +/** + * Extracts the answer values for a single question from a response. + */ +function extractAnswerText(answer: FormAnswer | undefined): string { + const values = answer?.textAnswers?.answers + ?.map((a) => a.value) + .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + return values && values.length > 0 ? values.join(', ') : '' +} + +/** + * Builds a question-id → title map from the form structure, so responses can be + * rendered with human-readable question labels instead of opaque IDs. + */ +function buildQuestionTitleMap(form: FormStructure): Map { + const map = new Map() + for (const item of form.items ?? []) { + const questionId = item.questionItem?.question?.questionId + if (questionId && item.title) { + map.set(questionId, item.title) + } + } + return map +} + +/** + * Renders the full form document: its structure (title, description, questions) + * followed by each response's question/answer pairs when responses are included. + */ +function renderFormDocument(form: FormStructure, responses: FormResponse[]): string { + const parts: string[] = [] + + const title = form.info?.title || form.info?.documentTitle + if (title) parts.push(`# ${title}`) + if (form.info?.description?.trim()) parts.push(form.info.description.trim()) + + const questionTitles = buildQuestionTitleMap(form) + + const questionLines: string[] = [] + for (const item of form.items ?? []) { + if (!item.title?.trim()) continue + const required = item.questionItem?.question?.required ? ' (required)' : '' + questionLines.push(`- ${item.title.trim()}${required}`) + if (item.description?.trim()) questionLines.push(` ${item.description.trim()}`) + } + if (questionLines.length > 0) { + parts.push('## Questions') + parts.push(questionLines.join('\n')) + } + + if (responses.length > 0) { + parts.push(`## Responses (${responses.length})`) + responses.forEach((response, index) => { + const responseLines: string[] = [] + const submitted = response.lastSubmittedTime || response.createTime + const header = submitted + ? `### Response ${index + 1} — ${submitted}` + : `### Response ${index + 1}` + responseLines.push(header) + if (response.respondentEmail) { + responseLines.push(`Respondent: ${response.respondentEmail}`) + } + for (const [questionId, answer] of Object.entries(response.answers ?? {})) { + const label = questionTitles.get(questionId) || questionId + const value = extractAnswerText(answer) + if (value) responseLines.push(`${label}: ${value}`) + } + parts.push(responseLines.join('\n')) + }) + } + + return parts.join('\n\n').trim() +} + +/** + * Builds the Drive `q` query that selects form files, optionally scoped to a + * folder. Single quotes and backslashes in the folder ID are escaped to prevent + * query injection. + */ +function buildDriveQuery(folderId?: string): string { + const parts = ['trashed = false', `mimeType = '${FORM_MIME_TYPE}'`] + if (folderId?.trim()) { + const escaped = folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'") + parts.push(`'${escaped}' in parents`) + } + return parts.join(' and ') +} + +export const googleFormsConnector: ConnectorConfig = { + id: 'google_forms', + name: 'Google Forms', + description: 'Sync Google Forms questions and responses into your knowledge base', + version: '1.0.0', + icon: GoogleFormsIcon, + + auth: { + mode: 'oauth', + provider: 'google-forms', + requiredScopes: [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/forms.body', + 'https://www.googleapis.com/auth/forms.responses.readonly', + ], + }, + + configFields: [ + { + id: 'folderId', + title: 'Folder ID', + type: 'short-input', + placeholder: 'e.g. 1aBcDeFgHiJkLmNoPqRsTuVwXyZ (optional)', + required: false, + description: 'Only sync forms inside this Drive folder. Leave blank to sync all forms.', + }, + { + id: 'contentScope', + title: 'Content', + type: 'dropdown', + required: false, + options: [ + { label: 'Questions & responses', id: 'both' }, + { label: 'Questions only', id: 'structure' }, + ], + description: 'Whether to index submitted responses alongside each form’s questions.', + }, + { + id: 'maxForms', + title: 'Max Forms', + type: 'short-input', + required: false, + placeholder: 'e.g. 100 (default: unlimited)', + }, + { + id: 'maxResponsesPerForm', + title: 'Max Responses Per Form', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: `e.g. 100 (default: ${MAX_RESPONSES_PER_FORM})`, + description: 'Cap on responses indexed per form. Applies only when indexing responses.', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const maxForms = parsePositiveInt(sourceConfig.maxForms) + const contentScope = resolveContentScope(sourceConfig.contentScope) + const responseCap = resolveResponseCap(sourceConfig) + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + + if (maxForms > 0 && previouslyFetched >= maxForms) { + return { documents: [], hasMore: false } + } + + const folderId = sourceConfig.folderId as string | undefined + const queryParams = new URLSearchParams({ + q: buildDriveQuery(folderId), + pageSize: String(DRIVE_PAGE_SIZE), + orderBy: 'modifiedTime desc', + fields: 'nextPageToken,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners)', + supportsAllDrives: 'true', + includeItemsFromAllDrives: 'true', + }) + if (cursor) queryParams.set('pageToken', cursor) + + const url = `${DRIVE_API_BASE}/files?${queryParams.toString()}` + + logger.info('Listing Google Forms', { + folderId: folderId?.trim() || 'all', + contentScope, + cursor: cursor ?? 'initial', + }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Google Forms', { status: response.status, error: errorText }) + throw new Error(`Failed to list Google Forms: ${response.status}`) + } + + const data = await response.json() + let files = (data.files || []) as DriveFormFile[] + + /** + * Drive sets `incompleteSearch` when it could not search every corpus (it + * arises with the `allDrives` scope enabled by `includeItemsFromAllDrives`). + * A partial listing drops still-existing forms, so reconciliation must be + * suppressed to avoid hard-deleting valid documents. + */ + const incompleteSearch = data.incompleteSearch === true + + let slicedSome = false + if (maxForms > 0) { + const remaining = maxForms - previouslyFetched + if (files.length > remaining) { + slicedSome = true + files = files.slice(0, remaining) + } + } + + /** + * Build stubs with metadata-based change indicators. Each form needs its + * revisionId (structure changes) and, when responses are indexed, the latest + * response time (new submissions) so the sync engine can detect changes + * without downloading full content. Forms are processed with bounded + * concurrency; a transient per-form failure is skipped rather than aborting + * the whole page, but it is recorded so the listing is marked incomplete. + */ + let skippedOnError = false + const stubs = await mapWithConcurrency(files, LIST_CONCURRENCY, async (file) => { + try { + const form = await fetchFormStructure(accessToken, file.id) + if (!form) return null + const latest = + contentScope === 'both' ? await fetchLatestResponseTime(accessToken, file.id) : undefined + return formToStub({ + file, + formTitle: form.info?.title || form.info?.documentTitle, + revisionId: form.revisionId, + latestResponseTime: latest, + contentScope, + responseCap, + }) + } catch (error) { + skippedOnError = true + logger.warn(`Skipping form during listing: ${file.name} (${file.id})`, { + error: toError(error).message, + }) + return null + } + }) + + const documents = stubs.filter((s): s is ExternalDocument => s !== null) + + const totalFetched = previouslyFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxForms > 0 && totalFetched >= maxForms + + const nextPageToken = data.nextPageToken as string | undefined + + /** + * Mark the listing as incomplete so the sync engine skips deletion + * reconciliation. Three cases drop still-existing forms from the listing: + * - `slicedSome`: this page held more forms than the `maxForms` cap allowed, + * so forms beyond the slice were truncated. This is independent of + * `hitLimit`, which counts successfully fetched stubs and can fall below + * the cap when 404s or errors null out items even though real forms were + * sliced off. + * - `hitLimit` with a next page: the cap was reached while more pages of + * forms remain in the source. + * - `skippedOnError`: a transient error dropped a still-present form. + * - `incompleteSearch`: Drive could not search every corpus, so the page + * itself is partial and may omit still-existing forms. + * Deleting any of those would wipe valid documents from the knowledge base. + * When the cap merely coincides with source exhaustion (no slice, no next + * page), reconciliation stays enabled so deleted forms are cleaned up. + */ + if ( + syncContext && + (slicedSome || (hitLimit && Boolean(nextPageToken)) || skippedOnError || incompleteSearch) + ) { + syncContext.listingCapped = true + } + + return { + documents, + nextCursor: hitLimit ? undefined : nextPageToken, + hasMore: hitLimit ? false : Boolean(nextPageToken), + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + const contentScope = resolveContentScope(sourceConfig.contentScope) + const fields = 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,trashed' + const metadataUrl = `${DRIVE_API_BASE}/files/${encodeURIComponent(externalId)}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true` + + const metadataResponse = await fetchWithRetry(metadataUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!metadataResponse.ok) { + if (metadataResponse.status === 404) return null + throw new Error(`Failed to get form metadata: ${metadataResponse.status}`) + } + + const file = (await metadataResponse.json()) as DriveFormFile + + if (file.trashed) return null + if (file.mimeType !== FORM_MIME_TYPE) return null + + try { + const form = await fetchFormStructure(accessToken, file.id) + if (!form) return null + + const responseCap = resolveResponseCap(sourceConfig) + const fetched = + contentScope === 'both' + ? await fetchFormResponses(accessToken, file.id) + : { responses: [], latestSubmittedTime: undefined } + const responses = fetched.responses + const cappedResponses = + responses.length > responseCap ? responses.slice(0, responseCap) : responses + + const content = renderFormDocument(form, cappedResponses) + if (!content.trim()) return null + + const stub = formToStub({ + file, + formTitle: form.info?.title || form.info?.documentTitle, + revisionId: form.revisionId, + latestResponseTime: fetched.latestSubmittedTime, + contentScope, + responseCap, + }) + return { ...stub, content, contentDeferred: false } + } catch (error) { + logger.warn(`Failed to fetch content for form: ${file.name} (${file.id})`, { + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const folderId = sourceConfig.folderId as string | undefined + const maxForms = sourceConfig.maxForms as string | undefined + const maxResponsesPerForm = sourceConfig.maxResponsesPerForm as string | undefined + + if (maxForms && (Number.isNaN(Number(maxForms)) || Number(maxForms) <= 0)) { + return { valid: false, error: 'Max forms must be a positive number' } + } + + if ( + maxResponsesPerForm && + (Number.isNaN(Number(maxResponsesPerForm)) || Number(maxResponsesPerForm) <= 0) + ) { + return { valid: false, error: 'Max responses per form must be a positive number' } + } + + try { + if (folderId?.trim()) { + const url = `${DRIVE_API_BASE}/files/${encodeURIComponent(folderId.trim())}?fields=id,name,mimeType&supportsAllDrives=true` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { valid: false, error: 'Folder not found. Check the folder ID and permissions.' } + } + return { valid: false, error: `Failed to access folder: ${response.status}` } + } + + const folder = await response.json() + if (folder.mimeType !== FOLDER_MIME_TYPE) { + return { valid: false, error: 'The provided ID is not a folder' } + } + } else { + const url = `${DRIVE_API_BASE}/files?pageSize=1&q=${encodeURIComponent(`mimeType = '${FORM_MIME_TYPE}'`)}&fields=files(id)&supportsAllDrives=true&includeItemsFromAllDrives=true` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + return { valid: false, error: `Failed to access Google Forms: ${response.status}` } + } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + tagDefinitions: [ + { id: 'formTitle', displayName: 'Form Title', fieldType: 'text' }, + { id: 'owners', displayName: 'Owner', fieldType: 'text' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + { id: 'lastResponse', displayName: 'Last Response', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.formTitle === 'string' && metadata.formTitle.trim()) { + result.formTitle = metadata.formTitle.trim() + } + + const owners = joinTagArray(metadata.owners) + if (owners) result.owners = owners + + const lastModified = parseTagDate(metadata.modifiedTime) + if (lastModified) result.lastModified = lastModified + + const lastResponse = parseTagDate(metadata.latestResponseTime) + if (lastResponse) result.lastResponse = lastResponse + + return result + }, +} diff --git a/apps/sim/connectors/google-forms/index.ts b/apps/sim/connectors/google-forms/index.ts new file mode 100644 index 00000000000..4a11928e0c8 --- /dev/null +++ b/apps/sim/connectors/google-forms/index.ts @@ -0,0 +1 @@ +export { googleFormsConnector } from '@/connectors/google-forms/google-forms' diff --git a/apps/sim/connectors/grain/grain.ts b/apps/sim/connectors/grain/grain.ts index be05cca037a..ea4243c3213 100644 --- a/apps/sim/connectors/grain/grain.ts +++ b/apps/sim/connectors/grain/grain.ts @@ -471,10 +471,11 @@ export const grainConnector: ConnectorConfig = { try { if (!externalId) return null - const recording = await fetchRecording(accessToken, externalId) + const [recording, segments] = await Promise.all([ + fetchRecording(accessToken, externalId), + fetchTranscript(accessToken, externalId), + ]) if (!recording) return null - - const segments = await fetchTranscript(accessToken, externalId) if (!segments) return null const hasTranscript = segments.some((segment) => segment.text?.trim()) diff --git a/apps/sim/connectors/jsm/index.ts b/apps/sim/connectors/jsm/index.ts new file mode 100644 index 00000000000..ba6fe961592 --- /dev/null +++ b/apps/sim/connectors/jsm/index.ts @@ -0,0 +1 @@ +export { jsmConnector } from '@/connectors/jsm/jsm' diff --git a/apps/sim/connectors/jsm/jsm.ts b/apps/sim/connectors/jsm/jsm.ts new file mode 100644 index 00000000000..9b61d7c9b19 --- /dev/null +++ b/apps/sim/connectors/jsm/jsm.ts @@ -0,0 +1,687 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { JiraServiceManagementIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate } from '@/connectors/utils' +import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils' +import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +const logger = createLogger('JsmConnector') + +const PAGE_SIZE = 50 + +/** + * Allowed `requestStatus` filter values for `GET /rest/servicedeskapi/request`. + * When omitted, the JSM API defaults to `ALL_REQUESTS`. + */ +const VALID_REQUEST_STATUS = ['OPEN_REQUESTS', 'CLOSED_REQUESTS', 'ALL_REQUESTS'] as const +type JsmRequestStatus = (typeof VALID_REQUEST_STATUS)[number] + +/** + * Allowed `requestOwnership` filter values for `GET /rest/servicedeskapi/request`. + * + * This param scopes results to the OAuth user's relationship to each request. When + * omitted, the JSM API defaults to `OWNED_REQUESTS` — i.e. only requests the + * authenticated user reported. For a knowledge-base sync the user almost always + * wants every request in the service desk, so the connector defaults this to + * `ALL_REQUESTS` (which the JSM API treats as "owned + participated") rather than + * relying on the API's narrower default. + */ +const VALID_REQUEST_OWNERSHIP = ['OWNED_REQUESTS', 'PARTICIPATED_REQUESTS', 'ALL_REQUESTS'] as const +type JsmRequestOwnership = (typeof VALID_REQUEST_OWNERSHIP)[number] + +/** + * Which comments to include in synced documents. + */ +const VALID_COMMENT_SCOPE = ['none', 'public', 'all'] as const +type JsmCommentScope = (typeof VALID_COMMENT_SCOPE)[number] + +/** + * A JSM date object as returned by the Service Desk REST API. The same shape is + * used for `createdDate`, `currentStatus.statusDate`, and comment `created`. + */ +interface JsmDate { + iso8601?: string + friendly?: string + epochMillis?: number +} + +/** + * Subset of a JSM customer request returned by `GET /request` and + * `GET /request/{issueIdOrKey}`. Only the fields the connector reads are typed. + */ +interface JsmRequest { + issueId?: string + issueKey?: string + requestTypeId?: string + serviceDeskId?: string + createdDate?: JsmDate + currentStatus?: { + status?: string + statusCategory?: string + statusDate?: JsmDate + } + reporter?: { + displayName?: string + emailAddress?: string + } + requestFieldValues?: Array<{ + fieldId?: string + label?: string + value?: unknown + renderedValue?: unknown + }> + _links?: { + web?: string + } +} + +/** + * A single comment on a JSM request. The JSM API returns the comment `body` as a + * plain string containing Jira wiki markup (not an ADF document), so no rich-text + * extraction is required. + */ +interface JsmComment { + id?: string + body?: string + public?: boolean + author?: { + displayName?: string + } + created?: JsmDate +} + +/** + * Paginated envelope shared by every JSM Service Desk list endpoint. + */ +interface JsmPage { + values?: T[] + size?: number + isLastPage?: boolean +} + +/** + * Reads the resolved sync options off the raw `sourceConfig`, normalizing + * enum-like fields to their valid set and clamping the numeric cap. Centralized + * so `listDocuments`, `getDocument`, and `validateConfig` agree on defaults. + */ +function resolveOptions(sourceConfig: Record): { + requestStatus: JsmRequestStatus + requestOwnership: JsmRequestOwnership + requestTypeId: string + searchTerm: string + commentScope: JsmCommentScope + maxRequests: number +} { + const requestStatus = VALID_REQUEST_STATUS.includes( + sourceConfig.requestStatus as JsmRequestStatus + ) + ? (sourceConfig.requestStatus as JsmRequestStatus) + : 'ALL_REQUESTS' + + const requestOwnership = VALID_REQUEST_OWNERSHIP.includes( + sourceConfig.requestOwnership as JsmRequestOwnership + ) + ? (sourceConfig.requestOwnership as JsmRequestOwnership) + : 'ALL_REQUESTS' + + const commentScope = VALID_COMMENT_SCOPE.includes(sourceConfig.comments as JsmCommentScope) + ? (sourceConfig.comments as JsmCommentScope) + : 'public' + + const requestTypeId = + typeof sourceConfig.requestTypeId === 'string' ? sourceConfig.requestTypeId.trim() : '' + const searchTerm = + typeof sourceConfig.searchTerm === 'string' ? sourceConfig.searchTerm.trim() : '' + + const parsedMax = sourceConfig.maxRequests ? Number(sourceConfig.maxRequests) : 0 + const maxRequests = Number.isFinite(parsedMax) && parsedMax > 0 ? Math.floor(parsedMax) : 0 + + return { requestStatus, requestOwnership, requestTypeId, searchTerm, commentScope, maxRequests } +} + +/** + * Extracts a plain-text value for a given request field id (e.g. `summary`, + * `description`) from a request's `requestFieldValues`. The JSM API returns + * `value` either as a plain string (wiki markup) or, for some rich-text fields, + * as an ADF document — both are handled. + */ +function getFieldText(request: JsmRequest, fieldId: string): string { + const field = request.requestFieldValues?.find((f) => f.fieldId === fieldId) + if (!field) return '' + const { value } = field + if (typeof value === 'string') return value + if (value && typeof value === 'object') { + const adf = extractAdfText(value) + if (adf) return adf + } + return '' +} + +/** + * Resolves the best available "change indicator" timestamp for a request. + * + * The JSM list endpoint does NOT return an updated/last-modified field — only + * `createdDate` and `currentStatus.statusDate` are present. We use + * `statusDate` (the time the request last changed status) when available, and + * fall back to `createdDate`. This is the change signal encoded into the + * contentHash. Note: edits that do not change status (e.g. a new comment) are + * not reflected here, so such changes may not trigger a re-sync. + */ +function getChangeIndicator(request: JsmRequest): string { + const statusDate = request.currentStatus?.statusDate + if (statusDate?.epochMillis != null) return String(statusDate.epochMillis) + if (statusDate?.iso8601) return statusDate.iso8601 + const created = request.createdDate + if (created?.epochMillis != null) return String(created.epochMillis) + if (created?.iso8601) return created.iso8601 + return '' +} + +/** + * Builds a stub ExternalDocument from a request returned by the list endpoint. + * Content is deferred — description and comments require a per-request API call + * fetched lazily in `getDocument`. The contentHash is metadata-only so it is + * identical whether produced here or in `getDocument`. + */ +function requestToStub(request: JsmRequest, domain: string): ExternalDocument { + const issueId = String(request.issueId ?? '') + const issueKey = request.issueKey ?? issueId + const summary = getFieldText(request, 'summary') || 'Untitled' + const status = request.currentStatus?.status + + const bareDomain = domain + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '') + + return { + externalId: issueId, + title: `${issueKey}: ${summary}`, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: request._links?.web || `https://${bareDomain}/browse/${issueKey}`, + contentHash: `jsm:${issueId}:${getChangeIndicator(request)}`, + metadata: { + issueKey, + requestTypeId: request.requestTypeId, + serviceDeskId: request.serviceDeskId, + status, + reporter: request.reporter?.displayName, + created: request.createdDate?.iso8601, + /** + * The list endpoint has no true "last updated" field; `statusDate` is the + * closest available signal (time of last status change). Mapped to the + * `updated` tag and documented as such. + */ + statusDate: request.currentStatus?.statusDate?.iso8601, + }, + } +} + +/** + * Renders a readable plain-text document from a fully-fetched request and its + * comments. Includes summary, description, reporter, status, and comment thread. + */ +function buildContent(request: JsmRequest, comments: JsmComment[]): string { + const parts: string[] = [] + + const summary = getFieldText(request, 'summary') + if (summary) parts.push(summary) + + const description = getFieldText(request, 'description') + if (description) parts.push(description) + + const status = request.currentStatus?.status + if (status) parts.push(`Status: ${status}`) + + const reporter = request.reporter?.displayName + if (reporter) parts.push(`Reporter: ${reporter}`) + + if (comments.length > 0) { + parts.push('Comments:') + for (const comment of comments) { + const body = (comment.body ?? '').trim() + if (!body) continue + const author = comment.author?.displayName + parts.push(author ? `${author}: ${body}` : body) + } + } + + return parts.join('\n\n').trim() +} + +/** + * Resolves and caches the Jira cloud ID for a domain across a sync run. + */ +async function resolveCloudId( + domain: string, + accessToken: string, + syncContext?: Record +): Promise { + const cached = syncContext?.cloudId as string | undefined + if (cached) return cached + const cloudId = await getJiraCloudId(domain, accessToken) + if (syncContext) syncContext.cloudId = cloudId + return cloudId +} + +/** + * Fetches comments for a request, following offset pagination until the API + * signals `isLastPage`. When `publicOnly` is true the `public=true` filter is + * applied so internal/agent-only comments are excluded. + */ +async function fetchComments( + baseUrl: string, + accessToken: string, + issueIdOrKey: string, + publicOnly: boolean +): Promise { + const comments: JsmComment[] = [] + let start = 0 + + while (true) { + const params = new URLSearchParams({ + start: String(start), + limit: String(PAGE_SIZE), + }) + /** + * The JSM comment endpoint exposes `public` and `internal` as independent + * inclusion filters that both default to `true`. Requesting public-only + * therefore requires explicitly disabling `internal` — passing `public=true` + * alone would still return agent-only/internal comments. + */ + if (publicOnly) { + params.append('public', 'true') + params.append('internal', 'false') + } + const url = `${baseUrl}/request/${encodeURIComponent(issueIdOrKey)}/comment?${params.toString()}` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + logger.warn('Failed to fetch JSM comments', { + issueIdOrKey, + status: response.status, + }) + break + } + + const data = (await response.json()) as JsmPage + const values = data.values ?? [] + comments.push(...values) + + if (data.isLastPage || values.length === 0) break + start += values.length + } + + return comments +} + +export const jsmConnector: ConnectorConfig = { + id: 'jsm', + name: 'Jira Service Management', + description: 'Sync service desk requests from Jira Service Management into your knowledge base', + version: '1.0.0', + icon: JiraServiceManagementIcon, + + auth: { + mode: 'oauth', + provider: 'jira', + requiredScopes: [ + 'read:servicedesk:jira-service-management', + 'read:request:jira-service-management', + 'read:request.comment:jira-service-management', + 'read:request.status:jira-service-management', + /** + * Requests embed a `reporter` user object whose `displayName` is surfaced + * in document content and the Reporter tag. Atlassian only populates + * embedded user data when the user-read scope is granted, so request it + * here. Present in the `jira` OAuth provider config as `read:jira-user`. + */ + 'read:jira-user', + 'offline_access', + ], + }, + + configFields: [ + { + id: 'domain', + title: 'Jira Domain', + type: 'short-input', + placeholder: 'yoursite.atlassian.net', + required: true, + }, + { + id: 'serviceDeskSelector', + title: 'Service Desk', + type: 'selector', + selectorKey: 'jsm.serviceDesks', + canonicalParamId: 'serviceDeskId', + mode: 'basic', + dependsOn: ['domain'], + placeholder: 'Select a service desk', + required: true, + }, + { + id: 'serviceDeskId', + title: 'Service Desk ID', + type: 'short-input', + canonicalParamId: 'serviceDeskId', + mode: 'advanced', + placeholder: 'e.g. 1, 2', + required: true, + }, + { + id: 'requestTypeSelector', + title: 'Request Type', + type: 'selector', + selectorKey: 'jsm.requestTypes', + canonicalParamId: 'requestTypeId', + mode: 'basic', + dependsOn: ['domain', 'serviceDeskSelector'], + placeholder: 'All request types', + required: false, + }, + { + id: 'requestTypeId', + title: 'Request Type ID', + type: 'short-input', + canonicalParamId: 'requestTypeId', + mode: 'advanced', + placeholder: 'e.g. 10 (leave blank for all)', + required: false, + }, + { + id: 'requestStatus', + title: 'Request Status', + type: 'dropdown', + required: false, + options: [ + { label: 'All requests', id: 'ALL_REQUESTS' }, + { label: 'Open requests', id: 'OPEN_REQUESTS' }, + { label: 'Closed requests', id: 'CLOSED_REQUESTS' }, + ], + }, + { + id: 'requestOwnership', + title: 'Request Ownership', + type: 'dropdown', + required: false, + description: + 'Which requests the connected account can see. "Owned + participated" is the broadest scope a customer token can sync.', + options: [ + { label: 'Owned + participated', id: 'ALL_REQUESTS' }, + { label: 'Owned only', id: 'OWNED_REQUESTS' }, + { label: 'Participated only', id: 'PARTICIPATED_REQUESTS' }, + ], + }, + { + id: 'comments', + title: 'Include Comments', + type: 'dropdown', + required: false, + description: 'Comments require an extra API call per request during sync.', + options: [ + { label: 'Public comments only', id: 'public' }, + { label: 'All comments (incl. internal)', id: 'all' }, + { label: 'No comments', id: 'none' }, + ], + }, + { + id: 'searchTerm', + title: 'Search Filter', + type: 'short-input', + required: false, + placeholder: 'e.g. password reset (optional)', + }, + { + id: 'maxRequests', + title: 'Max Requests', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const domain = sourceConfig.domain as string + const serviceDeskId = sourceConfig.serviceDeskId as string + + if (!domain || !serviceDeskId) { + throw new Error('Domain and service desk ID are required') + } + + const { requestStatus, requestOwnership, requestTypeId, searchTerm, maxRequests } = + resolveOptions(sourceConfig) + + const cloudId = await resolveCloudId(domain, accessToken, syncContext) + const baseUrl = getJsmApiBaseUrl(cloudId) + + /** + * `start|collected` is encoded in the cursor so the maxRequests cap holds + * across pages even if syncContext is not threaded through by the caller. + */ + let start = 0 + let collectedSoFar = (syncContext?.collectedCount as number | undefined) ?? 0 + if (cursor) { + const sep = cursor.indexOf('|') + if (sep > 0) { + const parsedStart = Number(cursor.slice(0, sep)) + const parsedCount = Number(cursor.slice(sep + 1)) + if (Number.isFinite(parsedStart) && parsedStart >= 0) start = parsedStart + if (Number.isFinite(parsedCount) && parsedCount >= 0) collectedSoFar = parsedCount + } else { + const parsedStart = Number(cursor) + if (Number.isFinite(parsedStart) && parsedStart >= 0) start = parsedStart + } + } + + const remaining = maxRequests > 0 ? Math.max(0, maxRequests - collectedSoFar) : PAGE_SIZE + if (maxRequests > 0 && remaining === 0) { + return { documents: [], hasMore: false } + } + + const params = new URLSearchParams({ + serviceDeskId, + requestStatus, + start: String(start), + limit: String(Math.min(PAGE_SIZE, remaining)), + }) + params.append('requestOwnership', requestOwnership) + if (requestTypeId) params.append('requestTypeId', requestTypeId) + if (searchTerm) params.append('searchTerm', searchTerm) + + const url = `${baseUrl}/request?${params.toString()}` + + logger.info('Listing JSM requests', { + serviceDeskId, + requestStatus, + requestOwnership, + hasCursor: Boolean(cursor), + }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list JSM requests', { status: response.status, error: errorText }) + throw new Error(`Failed to list JSM requests: ${response.status}`) + } + + const data = (await response.json()) as JsmPage + let requests = data.values ?? [] + + let slicedSome = false + if (maxRequests > 0 && requests.length > remaining) { + slicedSome = true + requests = requests.slice(0, remaining) + } + + const documents = requests.map((request) => requestToStub(request, domain)) + + const newCollected = collectedSoFar + requests.length + if (syncContext) syncContext.collectedCount = newCollected + + const reachedCap = maxRequests > 0 && newCollected >= maxRequests + + /** + * When `maxRequests` truncates the listing before the source is exhausted, + * flag the run as capped so the sync engine skips deletion reconciliation — + * otherwise unseen requests beyond the cap would be deleted on every sync. + * `slicedSome` covers truncation on the final page: requests dropped from + * this page still exist even when `isLastPage` is true. (The requested + * `limit` never exceeds the remaining budget, so a slice should be + * impossible — this is defense in depth against the API over-returning.) + */ + if (((reachedCap && !data.isLastPage) || slicedSome) && syncContext) { + syncContext.listingCapped = true + } + + const hasMore = !data.isLastPage && requests.length > 0 && !reachedCap + const nextStart = start + requests.length + + return { + documents, + nextCursor: hasMore ? `${nextStart}|${newCollected}` : undefined, + hasMore, + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string, + syncContext?: Record + ): Promise => { + const domain = sourceConfig.domain as string + const { commentScope } = resolveOptions(sourceConfig) + const cloudId = await resolveCloudId(domain, accessToken, syncContext) + const baseUrl = getJsmApiBaseUrl(cloudId) + + const requestUrl = `${baseUrl}/request/${encodeURIComponent(externalId)}?expand=status` + const response = await fetchWithRetry(requestUrl, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + if (response.status === 404) return null + if (response.status === 401 || response.status === 403) { + logger.warn('Access denied fetching JSM request', { externalId, status: response.status }) + return null + } + throw new Error(`Failed to get JSM request: ${response.status}`) + } + + const request = (await response.json()) as JsmRequest + + const comments = + commentScope === 'none' + ? [] + : await fetchComments(baseUrl, accessToken, externalId, commentScope === 'public') + + const stub = requestToStub(request, domain) + const content = buildContent(request, comments) + + return { + ...stub, + content, + contentDeferred: false, + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const domain = sourceConfig.domain as string + const serviceDeskId = sourceConfig.serviceDeskId as string + + if (!domain || !serviceDeskId) { + return { valid: false, error: 'Domain and service desk ID are required' } + } + + if (sourceConfig.maxRequests) { + const max = Number(sourceConfig.maxRequests) + if (Number.isNaN(max) || max <= 0) { + return { valid: false, error: 'Max requests must be a positive number' } + } + } + + try { + const cloudId = await getJiraCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) + const baseUrl = getJsmApiBaseUrl(cloudId) + const url = `${baseUrl}/servicedesk/${encodeURIComponent(serviceDeskId)}` + + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: getJsmHeaders(accessToken), + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { valid: false, error: `Service desk "${serviceDeskId}" not found` } + } + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: 'Access denied. Check the connected account has access to this service desk.', + } + } + const errorText = await response.text() + return { valid: false, error: `Failed to validate: ${response.status} - ${errorText}` } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: toError(error).message || 'Failed to validate configuration' } + } + }, + + tagDefinitions: [ + { id: 'status', displayName: 'Status', fieldType: 'text' }, + { id: 'requestTypeId', displayName: 'Request Type', fieldType: 'text' }, + { id: 'reporter', displayName: 'Reporter', fieldType: 'text' }, + { id: 'created', displayName: 'Created', fieldType: 'date' }, + { id: 'updated', displayName: 'Last Status Change', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.status === 'string') result.status = metadata.status + if (typeof metadata.requestTypeId === 'string') result.requestTypeId = metadata.requestTypeId + if (typeof metadata.reporter === 'string') result.reporter = metadata.reporter + + const created = parseTagDate(metadata.created) + if (created) result.created = created + + /** + * The list endpoint exposes no true last-updated field; `statusDate` (time + * of last status change) is the closest available signal and surfaces under + * the "Last Status Change" tag. + */ + const statusDate = parseTagDate(metadata.statusDate) + if (statusDate) result.updated = statusDate + + return result + }, +} diff --git a/apps/sim/connectors/mapTags.test.ts b/apps/sim/connectors/mapTags.test.ts index 62701d04698..956f796cbd1 100644 --- a/apps/sim/connectors/mapTags.test.ts +++ b/apps/sim/connectors/mapTags.test.ts @@ -11,6 +11,18 @@ vi.mock('@/components/icons', () => ({ NotionIcon: () => null, GoogleDriveIcon: () => null, AirtableIcon: () => null, + SentryIcon: () => null, + TypeformIcon: () => null, + YouTubeIcon: () => null, + JiraServiceManagementIcon: () => null, + S3Icon: () => null, + GoogleFormsIcon: () => null, + AzureDevOpsIcon: () => null, + xIcon: () => null, + GranolaIcon: () => null, + GreenhouseIcon: () => null, + FathomIcon: () => null, + RootlyIcon: () => null, })) vi.mock('@/lib/knowledge/documents/utils', () => ({ fetchWithRetry: vi.fn(), @@ -18,14 +30,37 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({ })) vi.mock('@/tools/jira/utils', () => ({ extractAdfText: vi.fn(), getJiraCloudId: vi.fn() })) vi.mock('@/tools/confluence/utils', () => ({ getConfluenceCloudId: vi.fn() })) +vi.mock('@/tools/jsm/utils', () => ({ + getJsmApiBaseUrl: vi.fn(), + getJsmFormsApiBaseUrl: vi.fn(), + getJsmHeaders: vi.fn(), +})) +vi.mock('@/tools/s3/utils', () => ({ + encodeS3PathComponent: vi.fn(), + getSignatureKey: vi.fn(), + parseS3Uri: vi.fn(), + generatePresignedUrl: vi.fn(), +})) import { airtableConnector } from '@/connectors/airtable/airtable' +import { azureDevopsConnector } from '@/connectors/azure-devops/azure-devops' import { confluenceConnector } from '@/connectors/confluence/confluence' +import { fathomConnector } from '@/connectors/fathom/fathom' import { githubConnector } from '@/connectors/github/github' import { googleDriveConnector } from '@/connectors/google-drive/google-drive' +import { googleFormsConnector } from '@/connectors/google-forms/google-forms' +import { granolaConnector } from '@/connectors/granola/granola' +import { greenhouseConnector } from '@/connectors/greenhouse/greenhouse' import { jiraConnector } from '@/connectors/jira/jira' +import { jsmConnector } from '@/connectors/jsm/jsm' import { linearConnector } from '@/connectors/linear/linear' import { notionConnector } from '@/connectors/notion/notion' +import { rootlyConnector } from '@/connectors/rootly/rootly' +import { s3Connector } from '@/connectors/s3/s3' +import { sentryConnector } from '@/connectors/sentry/sentry' +import { typeformConnector } from '@/connectors/typeform/typeform' +import { xConnector } from '@/connectors/x/x' +import { youtubeConnector } from '@/connectors/youtube/youtube' const ISO_DATE = '2025-06-15T10:30:00.000Z' @@ -388,3 +423,714 @@ describe('Airtable mapTags', () => { expect(result).toEqual({ createdTime: new Date(ISO_DATE) }) }) }) + +describe('Sentry mapTags', () => { + const mapTags = sentryConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + level: 'error', + status: 'unresolved', + count: 1234, + firstSeen: '2025-01-01T00:00:00.000Z', + lastSeen: ISO_DATE, + }) + + expect(result).toEqual({ + level: 'error', + status: 'unresolved', + count: 1234, + firstSeen: new Date('2025-01-01T00:00:00.000Z'), + lastSeen: new Date(ISO_DATE), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + level: 123, + status: null, + count: 'not-a-number', + firstSeen: 'bad-date', + lastSeen: 99999, + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips blank string fields', () => { + const result = mapTags({ level: ' ', status: '' }) + expect(result).toEqual({}) + }) + + it.concurrent('converts string count to number', () => { + const result = mapTags({ count: '42' }) + expect(result).toEqual({ count: 42 }) + }) + + it.concurrent('maps count of zero', () => { + const result = mapTags({ count: 0 }) + expect(result).toEqual({ count: 0 }) + }) +}) + +describe('Typeform mapTags', () => { + const mapTags = typeformConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + formTitle: 'Customer Survey', + platform: 'web', + submittedAt: ISO_DATE, + }) + + expect(result).toEqual({ + formTitle: 'Customer Survey', + platform: 'web', + submittedAt: new Date(ISO_DATE), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + formTitle: 123, + platform: null, + submittedAt: 99999, + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips submittedAt when date is invalid', () => { + const result = mapTags({ submittedAt: 'not-a-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips empty string fields', () => { + const result = mapTags({ formTitle: '', platform: '' }) + expect(result).toEqual({}) + }) +}) + +describe('YouTube mapTags', () => { + const mapTags = youtubeConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + channelTitle: 'Tech Channel', + publishedAt: ISO_DATE, + duration: 'PT10M30S', + tags: ['tutorial', 'coding'], + }) + + expect(result).toEqual({ + channelTitle: 'Tech Channel', + publishedAt: new Date(ISO_DATE), + duration: 'PT10M30S', + tags: 'tutorial, coding', + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + channelTitle: 123, + publishedAt: 99999, + duration: null, + tags: 'not-an-array', + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips publishedAt when date is invalid', () => { + const result = mapTags({ publishedAt: 'bad-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips blank string fields', () => { + const result = mapTags({ channelTitle: ' ', duration: '' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips tags when array is empty', () => { + const result = mapTags({ tags: [] }) + expect(result).toEqual({}) + }) +}) + +describe('JSM mapTags', () => { + const mapTags = jsmConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + status: 'Waiting for support', + requestTypeId: 'rt-123', + reporter: 'Carol', + created: '2025-01-01T00:00:00.000Z', + statusDate: ISO_DATE, + }) + + expect(result).toEqual({ + status: 'Waiting for support', + requestTypeId: 'rt-123', + reporter: 'Carol', + created: new Date('2025-01-01T00:00:00.000Z'), + updated: new Date(ISO_DATE), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + status: 123, + requestTypeId: null, + reporter: true, + created: 'bad-date', + statusDate: 99999, + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips created when date is invalid', () => { + const result = mapTags({ created: 'not-a-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('maps statusDate to updated key', () => { + const result = mapTags({ statusDate: ISO_DATE }) + expect(result).toEqual({ updated: new Date(ISO_DATE) }) + expect(result).not.toHaveProperty('statusDate') + }) +}) + +describe('S3 mapTags', () => { + const mapTags = s3Connector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + prefix: 'documents/reports/', + fileSize: 2048, + lastModified: ISO_DATE, + }) + + expect(result).toEqual({ + prefix: 'documents/reports/', + fileSize: 2048, + lastModified: new Date(ISO_DATE), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + prefix: 123, + fileSize: 'not-a-number', + lastModified: 99999, + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips prefix when empty string', () => { + const result = mapTags({ prefix: '' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips lastModified when date is invalid', () => { + const result = mapTags({ lastModified: 'bad-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('converts string fileSize to number', () => { + const result = mapTags({ fileSize: '512' }) + expect(result).toEqual({ fileSize: 512 }) + }) + + it.concurrent('maps fileSize of zero', () => { + const result = mapTags({ fileSize: 0 }) + expect(result).toEqual({ fileSize: 0 }) + }) +}) + +describe('Google Forms mapTags', () => { + const mapTags = googleFormsConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + formTitle: 'Feedback Form', + owners: ['Alice', 'Bob'], + modifiedTime: ISO_DATE, + latestResponseTime: '2025-01-01T00:00:00.000Z', + }) + + expect(result).toEqual({ + formTitle: 'Feedback Form', + owners: 'Alice, Bob', + lastModified: new Date(ISO_DATE), + lastResponse: new Date('2025-01-01T00:00:00.000Z'), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + formTitle: 123, + owners: 'not-an-array', + modifiedTime: 99999, + latestResponseTime: false, + }) + expect(result).toEqual({}) + }) + + it.concurrent('trims formTitle in output', () => { + const result = mapTags({ formTitle: ' Feedback Form ' }) + expect(result).toEqual({ formTitle: 'Feedback Form' }) + }) + + it.concurrent('skips formTitle when blank', () => { + const result = mapTags({ formTitle: ' ' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips owners when array is empty', () => { + const result = mapTags({ owners: [] }) + expect(result).toEqual({}) + }) + + it.concurrent('maps modifiedTime to lastModified key', () => { + const result = mapTags({ modifiedTime: ISO_DATE }) + expect(result).toEqual({ lastModified: new Date(ISO_DATE) }) + expect(result).not.toHaveProperty('modifiedTime') + }) + + it.concurrent('maps latestResponseTime to lastResponse key', () => { + const result = mapTags({ latestResponseTime: ISO_DATE }) + expect(result).toEqual({ lastResponse: new Date(ISO_DATE) }) + expect(result).not.toHaveProperty('latestResponseTime') + }) +}) + +describe('Azure DevOps mapTags', () => { + const mapTags = azureDevopsConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + kind: 'workItem', + wikiName: 'Engineering Wiki', + workItemType: 'Bug', + state: 'Active', + areaPath: 'Project\\Team', + tags: ['frontend', 'urgent'], + repository: 'owner/repo', + path: 'src/index.ts', + changedDate: ISO_DATE, + }) + + expect(result).toEqual({ + kind: 'workItem', + wikiName: 'Engineering Wiki', + workItemType: 'Bug', + state: 'Active', + areaPath: 'Project\\Team', + tags: 'frontend, urgent', + repository: 'owner/repo', + path: 'src/index.ts', + changedDate: new Date(ISO_DATE), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + kind: 123, + wikiName: null, + workItemType: true, + state: [], + areaPath: 99999, + tags: 'not-an-array', + repository: false, + path: 42, + changedDate: 'bad-date', + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips empty string fields except kind', () => { + const result = mapTags({ + kind: '', + wikiName: '', + workItemType: '', + state: '', + areaPath: '', + repository: '', + path: '', + }) + expect(result).toEqual({ kind: '' }) + }) + + it.concurrent('skips tags when array is empty', () => { + const result = mapTags({ tags: [] }) + expect(result).toEqual({}) + }) + + it.concurrent('skips changedDate when date is invalid', () => { + const result = mapTags({ changedDate: 'garbage' }) + expect(result).toEqual({}) + }) +}) + +describe('X mapTags', () => { + const mapTags = xConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + author: 'jack', + createdAt: ISO_DATE, + likeCount: 1500, + retweetCount: 300, + }) + + expect(result).toEqual({ + author: 'jack', + createdAt: new Date(ISO_DATE), + likeCount: 1500, + retweetCount: 300, + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + author: 123, + createdAt: 99999, + likeCount: 'not-a-number', + retweetCount: 'nope', + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips createdAt when date is invalid', () => { + const result = mapTags({ createdAt: 'not-a-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('converts string counts to numbers', () => { + const result = mapTags({ likeCount: '42', retweetCount: '7' }) + expect(result).toEqual({ likeCount: 42, retweetCount: 7 }) + }) + + it.concurrent('maps counts of zero', () => { + const result = mapTags({ likeCount: 0, retweetCount: 0 }) + expect(result).toEqual({ likeCount: 0, retweetCount: 0 }) + }) +}) + +describe('Granola mapTags', () => { + const mapTags = granolaConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + title: 'Weekly Sync', + owner: 'Alice', + attendees: ['Alice', 'Bob'], + folders: ['Team', 'Projects'], + meeting: 'Q3 Planning', + noteDate: ISO_DATE, + meetingDate: '2025-01-01T00:00:00.000Z', + }) + + expect(result).toEqual({ + title: 'Weekly Sync', + owner: 'Alice', + attendees: 'Alice, Bob', + folders: 'Team, Projects', + meeting: 'Q3 Planning', + noteDate: new Date(ISO_DATE), + meetingDate: new Date('2025-01-01T00:00:00.000Z'), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + title: 123, + owner: null, + attendees: 'not-an-array', + folders: 'not-an-array', + meeting: true, + noteDate: 99999, + meetingDate: false, + }) + expect(result).toEqual({}) + }) + + it.concurrent('trims text fields in output', () => { + const result = mapTags({ title: ' Weekly Sync ', owner: ' Alice ', meeting: ' Q3 ' }) + expect(result).toEqual({ title: 'Weekly Sync', owner: 'Alice', meeting: 'Q3' }) + }) + + it.concurrent('skips blank text fields', () => { + const result = mapTags({ title: ' ', owner: '', meeting: ' ' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips array fields when empty', () => { + const result = mapTags({ attendees: [], folders: [] }) + expect(result).toEqual({}) + }) + + it.concurrent('skips noteDate when date is invalid', () => { + const result = mapTags({ noteDate: 'not-a-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips meetingDate when date is invalid', () => { + const result = mapTags({ meetingDate: 'garbage' }) + expect(result).toEqual({}) + }) +}) + +describe('Greenhouse mapTags', () => { + const mapTags = greenhouseConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + candidateName: 'Jane Doe', + company: 'Acme', + title: 'Engineer', + recruiter: 'Alice', + coordinator: 'Bob', + source: 'LinkedIn', + applicationCount: 3, + updatedAt: ISO_DATE, + lastActivity: '2025-01-01T00:00:00.000Z', + }) + + expect(result).toEqual({ + candidateName: 'Jane Doe', + company: 'Acme', + title: 'Engineer', + recruiter: 'Alice', + coordinator: 'Bob', + source: 'LinkedIn', + applicationCount: 3, + updatedAt: new Date(ISO_DATE), + lastActivity: new Date('2025-01-01T00:00:00.000Z'), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + candidateName: 123, + company: null, + title: true, + recruiter: [], + coordinator: false, + source: 99999, + applicationCount: 'not-a-number', + updatedAt: 12345, + lastActivity: 'bad-date', + }) + expect(result).toEqual({}) + }) + + it.concurrent('trims text fields in output', () => { + const result = mapTags({ candidateName: ' Jane Doe ', company: ' Acme ' }) + expect(result).toEqual({ candidateName: 'Jane Doe', company: 'Acme' }) + }) + + it.concurrent('skips blank text fields', () => { + const result = mapTags({ candidateName: ' ', company: '', source: ' ' }) + expect(result).toEqual({}) + }) + + it.concurrent('maps applicationCount of zero', () => { + const result = mapTags({ applicationCount: 0 }) + expect(result).toEqual({ applicationCount: 0 }) + }) + + it.concurrent('skips applicationCount when string', () => { + const result = mapTags({ applicationCount: '3' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips updatedAt when date is invalid', () => { + const result = mapTags({ updatedAt: 'not-a-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips lastActivity when date is invalid', () => { + const result = mapTags({ lastActivity: 'garbage' }) + expect(result).toEqual({}) + }) +}) + +describe('Fathom mapTags', () => { + const mapTags = fathomConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + title: 'Sales Call', + recordedByEmail: 'john@example.com', + recordedByName: 'John Smith', + team: 'Sales', + meetingType: 'external', + transcriptLanguage: 'en', + durationSeconds: 1800, + meetingDate: ISO_DATE, + }) + + expect(result).toEqual({ + title: 'Sales Call', + recordedByEmail: 'john@example.com', + recordedByName: 'John Smith', + team: 'Sales', + meetingType: 'external', + transcriptLanguage: 'en', + durationSeconds: 1800, + meetingDate: new Date(ISO_DATE), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + title: 123, + recordedByEmail: null, + recordedByName: true, + team: [], + meetingType: false, + transcriptLanguage: 99999, + durationSeconds: 'not-a-number', + meetingDate: 12345, + }) + expect(result).toEqual({}) + }) + + it.concurrent('skips blank string fields', () => { + const result = mapTags({ title: ' ', team: '', transcriptLanguage: ' ' }) + expect(result).toEqual({}) + }) + + it.concurrent('converts string durationSeconds to number', () => { + const result = mapTags({ durationSeconds: '900' }) + expect(result).toEqual({ durationSeconds: 900 }) + }) + + it.concurrent('maps durationSeconds of zero', () => { + const result = mapTags({ durationSeconds: 0 }) + expect(result).toEqual({ durationSeconds: 0 }) + }) + + it.concurrent('skips meetingDate when date is invalid', () => { + const result = mapTags({ meetingDate: 'not-a-date' }) + expect(result).toEqual({}) + }) +}) + +describe('Rootly mapTags', () => { + const mapTags = rootlyConnector.mapTags! + + it.concurrent('maps all fields when present', () => { + const result = mapTags({ + status: 'resolved', + severityName: 'SEV1', + kind: 'incident', + services: ['api', 'web'], + teams: ['platform'], + environments: ['production'], + labels: ['platform:osx'], + incidentDate: ISO_DATE, + resolvedDate: '2025-01-01T00:00:00.000Z', + }) + + expect(result).toEqual({ + status: 'resolved', + severity: 'SEV1', + kind: 'incident', + services: 'api, web', + teams: 'platform', + environments: 'production', + labels: 'platform:osx', + incidentDate: new Date(ISO_DATE), + resolvedDate: new Date('2025-01-01T00:00:00.000Z'), + }) + }) + + it.concurrent('returns empty object for empty metadata', () => { + expect(mapTags({})).toEqual({}) + }) + + it.concurrent('skips fields with wrong types', () => { + const result = mapTags({ + status: 123, + severityName: null, + severityLevel: true, + kind: [], + services: 'not-an-array', + teams: 'not-an-array', + environments: 'not-an-array', + labels: 'not-an-array', + incidentDate: 99999, + resolvedDate: false, + }) + expect(result).toEqual({}) + }) + + it.concurrent('falls back to severityLevel when severityName is absent', () => { + const result = mapTags({ severityLevel: 'sev0' }) + expect(result).toEqual({ severity: 'sev0' }) + }) + + it.concurrent('prefers severityName over severityLevel', () => { + const result = mapTags({ severityName: 'Critical', severityLevel: 'sev0' }) + expect(result).toEqual({ severity: 'Critical' }) + }) + + it.concurrent('skips array fields when empty', () => { + const result = mapTags({ services: [], teams: [], environments: [], labels: [] }) + expect(result).toEqual({}) + }) + + it.concurrent('skips incidentDate when date is invalid', () => { + const result = mapTags({ incidentDate: 'not-a-date' }) + expect(result).toEqual({}) + }) + + it.concurrent('skips resolvedDate when date is invalid', () => { + const result = mapTags({ resolvedDate: 'garbage' }) + expect(result).toEqual({}) + }) +}) diff --git a/apps/sim/connectors/registry.ts b/apps/sim/connectors/registry.ts index a0d468417a9..9224ac77c61 100644 --- a/apps/sim/connectors/registry.ts +++ b/apps/sim/connectors/registry.ts @@ -1,6 +1,7 @@ import { airtableConnector } from '@/connectors/airtable' import { asanaConnector } from '@/connectors/asana' import { ashbyConnector } from '@/connectors/ashby' +import { azureDevopsConnector } from '@/connectors/azure-devops' import { confluenceConnector } from '@/connectors/confluence' import { discordConnector } from '@/connectors/discord' import { docusignConnector } from '@/connectors/docusign' @@ -15,6 +16,7 @@ import { gongConnector } from '@/connectors/gong' import { googleCalendarConnector } from '@/connectors/google-calendar' import { googleDocsConnector } from '@/connectors/google-docs' import { googleDriveConnector } from '@/connectors/google-drive' +import { googleFormsConnector } from '@/connectors/google-forms' import { googleSheetsConnector } from '@/connectors/google-sheets' import { grainConnector } from '@/connectors/grain' import { granolaConnector } from '@/connectors/granola' @@ -23,6 +25,7 @@ import { hubspotConnector } from '@/connectors/hubspot' import { incidentioConnector } from '@/connectors/incidentio' import { intercomConnector } from '@/connectors/intercom' import { jiraConnector } from '@/connectors/jira' +import { jsmConnector } from '@/connectors/jsm' import { linearConnector } from '@/connectors/linear' import { microsoftTeamsConnector } from '@/connectors/microsoft-teams' import { mondayConnector } from '@/connectors/monday' @@ -32,13 +35,18 @@ import { onedriveConnector } from '@/connectors/onedrive' import { outlookConnector } from '@/connectors/outlook' import { redditConnector } from '@/connectors/reddit' import { rootlyConnector } from '@/connectors/rootly' +import { s3Connector } from '@/connectors/s3' import { salesforceConnector } from '@/connectors/salesforce' +import { sentryConnector } from '@/connectors/sentry' import { servicenowConnector } from '@/connectors/servicenow' import { sharepointConnector } from '@/connectors/sharepoint' import { slackConnector } from '@/connectors/slack' +import { typeformConnector } from '@/connectors/typeform' import type { ConnectorRegistry } from '@/connectors/types' import { webflowConnector } from '@/connectors/webflow' import { wordpressConnector } from '@/connectors/wordpress' +import { xConnector } from '@/connectors/x' +import { youtubeConnector } from '@/connectors/youtube' import { zendeskConnector } from '@/connectors/zendesk' import { zoomConnector } from '@/connectors/zoom' @@ -46,6 +54,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { airtable: airtableConnector, asana: asanaConnector, ashby: ashbyConnector, + azure_devops: azureDevopsConnector, confluence: confluenceConnector, discord: discordConnector, docusign: docusignConnector, @@ -60,6 +69,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { google_calendar: googleCalendarConnector, google_docs: googleDocsConnector, google_drive: googleDriveConnector, + google_forms: googleFormsConnector, google_sheets: googleSheetsConnector, grain: grainConnector, granola: granolaConnector, @@ -68,6 +78,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { incidentio: incidentioConnector, intercom: intercomConnector, jira: jiraConnector, + jsm: jsmConnector, linear: linearConnector, microsoft_teams: microsoftTeamsConnector, monday: mondayConnector, @@ -77,12 +88,17 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { outlook: outlookConnector, reddit: redditConnector, rootly: rootlyConnector, + s3: s3Connector, salesforce: salesforceConnector, + sentry: sentryConnector, servicenow: servicenowConnector, sharepoint: sharepointConnector, slack: slackConnector, + typeform: typeformConnector, webflow: webflowConnector, wordpress: wordpressConnector, + x: xConnector, + youtube: youtubeConnector, zendesk: zendeskConnector, zoom: zoomConnector, } diff --git a/apps/sim/connectors/s3/index.ts b/apps/sim/connectors/s3/index.ts new file mode 100644 index 00000000000..c61a9d64c58 --- /dev/null +++ b/apps/sim/connectors/s3/index.ts @@ -0,0 +1 @@ +export { s3Connector } from '@/connectors/s3/s3' diff --git a/apps/sim/connectors/s3/s3.ts b/apps/sim/connectors/s3/s3.ts new file mode 100644 index 00000000000..2b2ac1843aa --- /dev/null +++ b/apps/sim/connectors/s3/s3.ts @@ -0,0 +1,730 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { S3Icon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate, readBodyWithLimit } from '@/connectors/utils' +import { encodeS3PathComponent, getSignatureKey } from '@/tools/s3/utils' + +const logger = createLogger('S3Connector') + +/** Maximum object size to sync. Larger objects are skipped during listing. */ +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB + +/** Number of objects requested per ListObjectsV2 page (S3 caps at 1000). */ +const LIST_MAX_KEYS = 1000 + +/** + * Default set of file extensions considered safely text-extractable. Objects + * with any other extension (or no extension) are skipped, since their content + * cannot be reliably decoded to plain text. Users can override this list via + * the `extensions` config field. + */ +const DEFAULT_EXTENSIONS = new Set([ + 'txt', + 'md', + 'markdown', + 'csv', + 'tsv', + 'json', + 'jsonl', + 'ndjson', + 'html', + 'htm', + 'xml', + 'yaml', + 'yml', + 'log', + 'rtf', +]) + +/** + * A single object entry parsed out of a ListObjectsV2 XML response. + */ +interface S3ObjectEntry { + key: string + etag: string + lastModified: string + size: number +} + +/** + * A parsed custom S3-compatible endpoint (Cloudflare R2, MinIO, etc.). + * + * `host` is the bare hostname, `hostHeader` is the value used both as the wire + * `Host` header and in the SigV4 canonical headers — it includes the port when + * a non-default port is configured (e.g. `localhost:9000`). When the endpoint + * uses the scheme's default port (443 for https, 80 for http) the port is + * omitted from `hostHeader`, matching what the HTTP client sends on the wire. + */ +interface S3Endpoint { + scheme: 'http' | 'https' + host: string + hostHeader: string +} + +/** + * AWS credentials and target resource resolved from sourceConfig + access token. + * + * When `endpoint` is present the connector targets an S3-compatible store using + * path-style addressing (`{endpoint}/{bucket}/{key}`). When absent it targets + * AWS S3 using virtual-hosted-style addressing + * (`{bucket}.s3.{region}.amazonaws.com`), preserving the original behavior. + */ +interface S3Context { + accessKeyId: string + secretAccessKey: string + region: string + bucket: string + endpoint?: S3Endpoint +} + +/** + * Parses the comma-separated `extensions` config override into a normalized set + * (lowercased, no leading dot). Returns the built-in default set when the + * override is empty or contains no usable entries. + */ +function resolveExtensions(raw: unknown): Set { + if (typeof raw !== 'string') return DEFAULT_EXTENSIONS + const exts = raw + .split(',') + .map((e) => e.trim().toLowerCase().replace(/^\./, '')) + .filter(Boolean) + return exts.length > 0 ? new Set(exts) : DEFAULT_EXTENSIONS +} + +/** + * Extracts the lowercased file extension from an object key, or '' if none. + */ +function getExtension(key: string): string { + const lastSegment = key.split('/').pop() ?? '' + const dotIndex = lastSegment.lastIndexOf('.') + if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) return '' + return lastSegment.slice(dotIndex + 1).toLowerCase() +} + +/** + * Returns true when the object key ends in one of the allowed text extensions. + */ +function isSupportedKey(key: string, allowedExtensions: Set): boolean { + return allowedExtensions.has(getExtension(key)) +} + +/** + * Returns true when the host is a loopback address for which plain `http://` + * is tolerated (local MinIO development). Any other host must use `https://` so + * that credentials are never transmitted over cleartext. + */ +function isLoopbackHost(host: string): boolean { + const bare = host.replace(/^\[|\]$/g, '') + return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1' +} + +/** + * Parses and validates a custom S3-compatible endpoint string. + * + * Accepts a full origin such as `https://accountid.r2.cloudflarestorage.com` or + * `http://localhost:9000`. Trailing slashes are stripped. Throws when the value + * is not a valid URL, carries a path/query/fragment beyond `/` (which would + * corrupt the path-style canonical URI), or uses plain `http://` against a + * non-loopback host. + * + * The returned `hostHeader` includes the port only when it differs from the + * scheme default, matching the `Host` header the HTTP client emits — this keeps + * the SigV4 canonical Host byte-identical to the wire Host. + */ +function parseEndpoint(raw: string): S3Endpoint { + let url: URL + try { + url = new URL(raw) + } catch { + throw new Error('Endpoint must be a valid URL, e.g. https://accountid.r2.cloudflarestorage.com') + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Endpoint must use http:// or https://') + } + const scheme = url.protocol === 'https:' ? 'https' : 'http' + + if (url.username || url.password) { + throw new Error('Endpoint must not contain credentials') + } + if (url.search || url.hash) { + throw new Error('Endpoint must not contain a query string or fragment') + } + const path = url.pathname.replace(/\/+$/, '') + if (path !== '') { + throw new Error('Endpoint must not contain a path — provide only the host, e.g. https://host') + } + + const host = url.hostname + if (!host) throw new Error('Endpoint is missing a host') + if (scheme === 'http' && !isLoopbackHost(host)) { + throw new Error( + 'Plain http:// endpoints are only allowed for localhost — use https:// otherwise' + ) + } + + const defaultPort = scheme === 'https' ? '443' : '80' + const port = url.port && url.port !== defaultPort ? url.port : '' + const hostHeader = port ? `${host}:${port}` : host + + return { scheme, host, hostHeader } +} + +/** + * Resolves AWS credentials and the target bucket from the connector's + * sourceConfig and the encrypted secret (delivered as accessToken). When an + * `endpoint` is configured it is parsed/validated into an {@link S3Endpoint} so + * the connector targets an S3-compatible store via path-style addressing. + */ +function resolveContext(accessToken: string, sourceConfig: Record): S3Context { + const accessKeyId = ((sourceConfig.accessKeyId as string) ?? '').trim() + const region = ((sourceConfig.region as string) ?? '').trim() + const bucket = ((sourceConfig.bucket as string) ?? '').trim() + const secretAccessKey = (accessToken ?? '').trim() + const rawEndpoint = ((sourceConfig.endpoint as string) ?? '').trim() + + if (!accessKeyId) throw new Error('Missing AWS Access Key ID') + if (!secretAccessKey) throw new Error('Missing AWS Secret Access Key') + if (!region) throw new Error('Missing AWS region') + if (!bucket) throw new Error('Missing S3 bucket name') + + const endpoint = rawEndpoint ? parseEndpoint(rawEndpoint) : undefined + + return { accessKeyId, secretAccessKey, region, bucket, endpoint } +} + +/** + * Returns the SigV4 canonical Host header for the request. For AWS this is the + * virtual-hosted-style host; for a custom endpoint it is the endpoint host + * (with port when non-default). + */ +function resolveHost(ctx: S3Context): string { + return ctx.endpoint ? ctx.endpoint.hostHeader : `${ctx.bucket}.s3.${ctx.region}.amazonaws.com` +} + +/** + * Returns the request scheme: always `https` for AWS, or the endpoint scheme + * (which may be `http` for local MinIO) for a custom endpoint. + */ +function resolveScheme(ctx: S3Context): string { + return ctx.endpoint ? ctx.endpoint.scheme : 'https' +} + +/** + * Builds the canonical URI for an object key. + * + * AWS (virtual-hosted-style): `/{key}` — the bucket lives in the host. + * Custom endpoint (path-style): `/{bucket}/{key}` — the bucket is the first + * path segment. Both the bucket and key are percent-encoded per AWS UriEncode + * rules while preserving `/` separators via {@link encodeS3PathComponent}. + */ +function buildObjectPath(ctx: S3Context, key: string): string { + const encodedKey = encodeS3PathComponent(key) + return ctx.endpoint ? `/${encodeS3PathComponent(ctx.bucket)}/${encodedKey}` : `/${encodedKey}` +} + +/** + * Builds the canonical URI for a bucket-level (ListObjectsV2) request. + * + * AWS (virtual-hosted-style): `/`. + * Custom endpoint (path-style): `/{bucket}/`. + */ +function buildBucketPath(ctx: S3Context): string { + return ctx.endpoint ? `/${encodeS3PathComponent(ctx.bucket)}/` : '/' +} + +/** + * Builds the full request URL from the canonical path and an optional canonical + * query string. The path passed here is the same canonical, percent-encoded + * string used to compute the SigV4 signature, so the signed URI and the wire + * URI are byte-identical. + */ +function buildUrl(ctx: S3Context, encodedPath: string, canonicalQueryString: string): string { + const base = `${resolveScheme(ctx)}://${resolveHost(ctx)}${encodedPath}` + return canonicalQueryString ? `${base}?${canonicalQueryString}` : base +} + +/** + * Builds SigV4 request headers for an S3 REST call. + * + * `canonicalQueryString` must be the already-sorted, percent-encoded query + * string (empty for GetObject) — the caller builds the request URL from this + * exact same string so the signed query and the wire query are byte-identical + * (the classic continuation-token signing mismatch cannot occur here). + * `encodedPath` is the canonical URI path starting with '/' (virtual-hosted + * `/{key}` for AWS, path-style `/{bucket}/{key}` for custom endpoints). The + * canonical Host header is resolved via {@link resolveHost} and includes the + * port for non-default custom-endpoint ports, exactly matching the wire Host. + * Reuses {@link getSignatureKey} from the s3 tool utilities. + * + * The signed headers embed `x-amz-date` and are reused verbatim across + * `fetchWithRetry` attempts. S3 allows a 15-minute clock-skew window; the + * retry helper's worst-case total backoff (~31s default, ~10s in validate) is + * far inside that window, so a stale timestamp never triggers + * RequestTimeTooSkewed. + */ +function buildSignedHeaders( + ctx: S3Context, + method: 'GET', + encodedPath: string, + canonicalQueryString: string +): Record { + const date = new Date() + const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '') + const dateStamp = amzDate.slice(0, 8) + + const host = resolveHost(ctx) + const payloadHash = crypto.createHash('sha256').update('').digest('hex') + + const canonicalHeaders = + `host:${host}\n` + `x-amz-content-sha256:${payloadHash}\n` + `x-amz-date:${amzDate}\n` + const signedHeaders = 'host;x-amz-content-sha256;x-amz-date' + + const canonicalRequest = `${method}\n${encodedPath}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}` + + const algorithm = 'AWS4-HMAC-SHA256' + const credentialScope = `${dateStamp}/${ctx.region}/s3/aws4_request` + const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto + .createHash('sha256') + .update(canonicalRequest) + .digest('hex')}` + + const signingKey = getSignatureKey(ctx.secretAccessKey, dateStamp, ctx.region, 's3') + const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex') + + const authorizationHeader = `${algorithm} Credential=${ctx.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` + + return { + Host: host, + 'X-Amz-Content-Sha256': payloadHash, + 'X-Amz-Date': amzDate, + Authorization: authorizationHeader, + } +} + +/** + * Percent-encodes a query parameter name or value per AWS SigV4 canonical rules + * (every byte except the unreserved set `A-Za-z0-9-_.~` is encoded). + * `encodeURIComponent` leaves `!'()*` unencoded, so those are encoded here. + */ +function encodeQueryValue(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +/** + * Builds the canonical (sorted, percent-encoded) query string for a + * ListObjectsV2 request. Keys are sorted lexicographically after encoding and + * each name/value pair is encoded individually. + */ +function buildListQueryString(params: Record): string { + return Object.keys(params) + .sort() + .map((key) => `${encodeQueryValue(key)}=${encodeQueryValue(params[key])}`) + .join('&') +} + +/** + * Decodes XML entities found in S3 response text values. `&` is decoded + * last so sequences like `&lt;` resolve to `<` rather than `<`. + */ +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + +/** + * Normalizes an ETag from either a ListObjectsV2 XML `` element or a + * GetObject response header into a stable bare token used in the content hash. + * + * Strips surrounding double quotes and a leading weak-validator prefix (`W/`). + * AWS S3 always returns strong, quoted ETags (including the multipart `-N` + * suffix) identically from List and Get, but S3-compatible stores (MinIO, R2) + * are not contractually bound to that and could emit a weak ETag on one path + * and a strong one on the other. Normalizing both ends keeps the + * `s3:{key}:{etag}` hash invariant between the listing stub and the hydrated + * document so unchanged objects are not re-uploaded every sync. + */ +function normalizeEtag(raw: string): string { + return raw.replace(/^W\//, '').replace(/"/g, '') +} + +/** + * Decodes a URL-encoded object key returned when `encoding-type=url` is set. + * Falls back to the raw value if decoding fails (malformed percent sequence). + */ +function decodeObjectKey(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** + * Extracts the text content of the first matching XML tag within a fragment. + */ +function extractTag(fragment: string, tag: string): string | undefined { + const match = fragment.match(new RegExp(`<${tag}>([\\s\\S]*?)`)) + return match ? decodeXmlEntities(match[1]) : undefined +} + +/** + * Parses a ListObjectsV2 XML response into object entries plus pagination state. + * + * The request is always made with `encoding-type=url`, so the per-`Key` values + * are percent-encoded in the XML (safe for the regex parser even when keys + * contain XML-hostile bytes such as `&`, `<`, or ASCII control characters). + * Each `Key` is XML-entity-decoded then URL-decoded back to its true value. + * `NextContinuationToken` is opaque and is not affected by `encoding-type`, so + * it is used verbatim. + */ +function parseListResponse(xml: string): { + objects: S3ObjectEntry[] + isTruncated: boolean + nextContinuationToken?: string +} { + const objects: S3ObjectEntry[] = [] + + for (const match of xml.matchAll(/([\s\S]*?)<\/Contents>/g)) { + const block = match[1] + const rawKey = extractTag(block, 'Key') + if (!rawKey) continue + const key = decodeObjectKey(rawKey) + + const etag = normalizeEtag(extractTag(block, 'ETag') ?? '') + const lastModified = extractTag(block, 'LastModified') ?? '' + const size = Number(extractTag(block, 'Size') ?? '0') + + objects.push({ key, etag, lastModified, size: Number.isNaN(size) ? 0 : size }) + } + + const isTruncated = extractTag(xml, 'IsTruncated') === 'true' + const nextContinuationToken = extractTag(xml, 'NextContinuationToken') + + return { objects, isTruncated, nextContinuationToken } +} + +/** + * Builds a metadata stub for an S3 object. The content hash combines the key + * and ETag — S3's ETag changes whenever object content changes, making it an + * ideal change indicator. Used by both listDocuments and getDocument to + * guarantee identical hashes. + */ +function objectToStub(ctx: S3Context, entry: S3ObjectEntry): ExternalDocument { + const title = entry.key.split('/').pop() || entry.key + const prefix = entry.key.includes('/') ? entry.key.slice(0, entry.key.lastIndexOf('/')) : '' + + return { + externalId: entry.key, + title, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: buildUrl(ctx, buildObjectPath(ctx, entry.key), ''), + contentHash: `s3:${entry.key}:${entry.etag}`, + metadata: { + key: entry.key, + prefix, + etag: entry.etag, + lastModified: entry.lastModified, + fileSize: entry.size, + }, + } +} + +/** + * Performs a single ListObjectsV2 page request and returns the parsed result. + */ +async function listObjectsPage( + ctx: S3Context, + prefix: string, + continuationToken: string | undefined, + retryOptions?: Parameters[2], + maxKeys: number = LIST_MAX_KEYS +): Promise<{ objects: S3ObjectEntry[]; isTruncated: boolean; nextContinuationToken?: string }> { + const queryParams: Record = { + 'list-type': '2', + 'encoding-type': 'url', + 'max-keys': String(maxKeys), + } + if (prefix) queryParams.prefix = prefix + if (continuationToken) queryParams['continuation-token'] = continuationToken + + const canonicalQueryString = buildListQueryString(queryParams) + const bucketPath = buildBucketPath(ctx) + const headers = buildSignedHeaders(ctx, 'GET', bucketPath, canonicalQueryString) + + const url = buildUrl(ctx, bucketPath, canonicalQueryString) + + const response = await fetchWithRetry(url, { method: 'GET', headers }, retryOptions) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`S3 ListObjectsV2 failed: ${response.status} ${errorText}`) + } + + const xml = await response.text() + return parseListResponse(xml) +} + +export const s3Connector: ConnectorConfig = { + id: 's3', + name: 'Amazon S3', + description: + 'Sync text-based objects from Amazon S3 or any S3-compatible store (Cloudflare R2, MinIO) into your knowledge base', + version: '1.1.0', + icon: S3Icon, + + auth: { + mode: 'apiKey', + label: 'Secret Access Key', + placeholder: 'Enter your AWS Secret Access Key', + }, + + configFields: [ + { + id: 'accessKeyId', + title: 'Access Key ID', + type: 'short-input', + placeholder: 'e.g. AKIAIOSFODNN7EXAMPLE', + required: true, + }, + { + id: 'region', + title: 'Region', + type: 'short-input', + placeholder: 'e.g. us-east-1 (use auto for Cloudflare R2)', + required: true, + description: + 'AWS region for the bucket. For Cloudflare R2 use "auto"; for MinIO use the region the server is configured with (commonly us-east-1).', + }, + { + id: 'bucket', + title: 'Bucket', + type: 'short-input', + placeholder: 'e.g. my-bucket', + required: true, + }, + { + id: 'endpoint', + title: 'Custom Endpoint', + type: 'short-input', + placeholder: 'https://accountid.r2.cloudflarestorage.com (optional — leave empty for AWS S3)', + required: false, + description: + 'S3-compatible endpoint for Cloudflare R2, MinIO, etc. Leave empty for AWS S3. Uses path-style addressing. Plain http:// is only allowed for localhost.', + }, + { + id: 'prefix', + title: 'Prefix', + type: 'short-input', + placeholder: 'e.g. docs/ (optional)', + required: false, + description: 'Only sync objects whose key starts with this prefix', + }, + { + id: 'extensions', + title: 'File Extensions', + type: 'short-input', + placeholder: 'e.g. txt, md, csv (optional)', + required: false, + description: + 'Comma-separated list of file extensions to sync. Leave blank to use the built-in text formats.', + }, + { + id: 'maxObjects', + title: 'Max Objects', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + description: 'Stop syncing after this many objects', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const ctx = resolveContext(accessToken, sourceConfig) + const prefix = ((sourceConfig.prefix as string) ?? '').trim() + const allowedExtensions = resolveExtensions(sourceConfig.extensions) + + const maxObjects = sourceConfig.maxObjects ? Number(sourceConfig.maxObjects) : 0 + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + + if (maxObjects > 0 && previouslyFetched >= maxObjects) { + return { documents: [], hasMore: false } + } + + logger.info('Listing S3 objects', { bucket: ctx.bucket, prefix, cursor: cursor ?? 'initial' }) + + const { objects, isTruncated, nextContinuationToken } = await listObjectsPage( + ctx, + prefix, + cursor + ) + + let documents = objects + .filter((entry) => isSupportedKey(entry.key, allowedExtensions)) + .filter((entry) => entry.size > 0 && entry.size <= MAX_FILE_SIZE) + .map((entry) => objectToStub(ctx, entry)) + + let slicedSome = false + if (maxObjects > 0) { + const remaining = maxObjects - previouslyFetched + if (documents.length > remaining) { + slicedSome = true + documents = documents.slice(0, remaining) + } + } + + const totalFetched = previouslyFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxObjects > 0 && totalFetched >= maxObjects + const moreAvailable = slicedSome || (isTruncated && Boolean(nextContinuationToken)) + if (hitLimit && moreAvailable && syncContext) syncContext.listingCapped = true + + return { + documents, + nextCursor: hitLimit ? undefined : isTruncated ? nextContinuationToken : undefined, + hasMore: hitLimit ? false : isTruncated && Boolean(nextContinuationToken), + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + const ctx = resolveContext(accessToken, sourceConfig) + const key = externalId + + try { + const encodedPath = buildObjectPath(ctx, key) + const headers = buildSignedHeaders(ctx, 'GET', encodedPath, '') + const url = buildUrl(ctx, encodedPath, '') + + const response = await fetchWithRetry(url, { method: 'GET', headers }) + + if (response.status === 404) return null + if (!response.ok) { + const errorText = await response.text() + throw new Error(`S3 GetObject failed: ${response.status} ${errorText}`) + } + + const etag = normalizeEtag(response.headers.get('etag') ?? '') + const lastModified = response.headers.get('last-modified') ?? '' + const declaredLength = Number(response.headers.get('content-length') ?? '') + + if (declaredLength > MAX_FILE_SIZE) { + logger.warn('Skipping oversized S3 object', { key, size: declaredLength }) + return null + } + + const body = await readBodyWithLimit(response, MAX_FILE_SIZE) + if (body === null) { + logger.warn('Skipping oversized S3 object (size cap exceeded while streaming)', { key }) + return null + } + const content = body.toString('utf-8') + if (!content.trim()) return null + + const entry: S3ObjectEntry = { + key, + etag, + lastModified, + size: + Number.isNaN(declaredLength) || declaredLength <= 0 ? body.byteLength : declaredLength, + } + const stub = objectToStub(ctx, entry) + return { ...stub, content, contentDeferred: false } + } catch (error) { + logger.warn('Failed to get S3 object', { key, error: toError(error).message }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + let ctx: S3Context + try { + ctx = resolveContext(accessToken, sourceConfig) + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Invalid configuration') } + } + + const maxObjects = sourceConfig.maxObjects as string | undefined + if (maxObjects && (Number.isNaN(Number(maxObjects)) || Number(maxObjects) <= 0)) { + return { valid: false, error: 'Max objects must be a positive number' } + } + + const prefix = ((sourceConfig.prefix as string) ?? '').trim() + + try { + await listObjectsPage(ctx, prefix, undefined, VALIDATE_RETRY_OPTIONS, 1) + return { valid: true } + } catch (error) { + const message = getErrorMessage(error, 'Failed to validate configuration') + const lower = message.toLowerCase() + if ( + lower.includes('permanentredirect') || + lower.includes('authorizationheadermalformed') || + lower.includes(' 301 ') + ) { + return { + valid: false, + error: + 'Wrong region for this bucket. Update the region to match where the bucket lives (or use "auto" for Cloudflare R2).', + } + } + if (lower.includes('403') || lower.includes('accessdenied') || lower.includes('signature')) { + return { + valid: false, + error: 'Access denied. Check the access key, secret key, and bucket permissions.', + } + } + if (lower.includes('404') || lower.includes('nosuchbucket')) { + return { valid: false, error: 'Bucket not found. Check the bucket name and region.' } + } + return { valid: false, error: message } + } + }, + + tagDefinitions: [ + { id: 'prefix', displayName: 'Folder', fieldType: 'text' }, + { id: 'fileSize', displayName: 'Size (bytes)', fieldType: 'number' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.prefix === 'string' && metadata.prefix.length > 0) { + result.prefix = metadata.prefix + } + + if (metadata.fileSize != null) { + const num = Number(metadata.fileSize) + if (!Number.isNaN(num)) result.fileSize = num + } + + const lastModified = parseTagDate(metadata.lastModified) + if (lastModified) result.lastModified = lastModified + + return result + }, +} diff --git a/apps/sim/connectors/sentry/index.ts b/apps/sim/connectors/sentry/index.ts new file mode 100644 index 00000000000..caeb2e10bec --- /dev/null +++ b/apps/sim/connectors/sentry/index.ts @@ -0,0 +1 @@ +export { sentryConnector } from '@/connectors/sentry/sentry' diff --git a/apps/sim/connectors/sentry/sentry.ts b/apps/sim/connectors/sentry/sentry.ts new file mode 100644 index 00000000000..55838ecda5b --- /dev/null +++ b/apps/sim/connectors/sentry/sentry.ts @@ -0,0 +1,736 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { SentryIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate } from '@/connectors/utils' + +const logger = createLogger('SentryConnector') + +const DEFAULT_HOST = 'sentry.io' +const ISSUES_PER_PAGE = 100 + +/** + * Default issue search query. + * + * Reconciliation semantics: the sync engine hard-deletes any previously-synced + * document whose `externalId` is absent from a full (non-capped) listing pass. + * With the default `is:unresolved` query this means an issue that is resolved, + * ignored/muted, or aged out of the query window will fall out of the listing + * and be removed from the knowledge base on the next full sync. That is the + * intended semantic — the KB tracks the *currently matching* issue set, not a + * permanent archive. Users who want resolved issues retained should widen the + * query (e.g. drop `is:unresolved`). When `maxIssues` caps the listing, the + * engine sets `listingCapped` and skips deletion, so capped runs never remove + * unseen issues. + */ +const DEFAULT_QUERY = 'is:unresolved' + +/** + * Allowed `statsPeriod` values for the project issues list endpoint. Sentry's + * project issues endpoint only honors `24h` (default) or `14d` for its timeline + * stats; an empty value disables the stats window. Other periods (e.g. `90d`) + * are accepted by the organization issues endpoint but not this one, so they are + * rejected during validation to avoid a silently-ignored filter. + */ +const ALLOWED_STATS_PERIODS = new Set(['24h', '14d']) + +/** + * Metadata block on a Sentry issue, carrying the human-readable error type/value. + */ +interface SentryIssueMetadata { + type?: string + value?: string + function?: string + title?: string +} + +/** + * A single issue (error group) returned by the issues list/detail endpoints. + */ +interface SentryIssue { + id: string + shortId?: string + title?: string + culprit?: string | null + permalink?: string + logger?: string | null + level?: string + status?: string + platform?: string | null + type?: string | null + metadata?: SentryIssueMetadata + /** Sentry returns the event count as a string (e.g. "12"), not a number. */ + count?: string + userCount?: number + firstSeen?: string + lastSeen?: string +} + +/** + * One entry inside a Sentry event. Entries carry the structured payload (exception, + * breadcrumbs, request, message) keyed by `type`, with the shape under `data` varying + * per entry type. + */ +interface SentryEventEntry { + type?: string + data?: unknown +} + +/** + * A key/value tag pair attached to a Sentry event. + */ +interface SentryEventTag { + key?: string + value?: string +} + +/** + * The latest event for an issue, used to enrich the synced document with the concrete + * message, exception detail, and tags from the most recent occurrence. + */ +interface SentryEvent { + id?: string + eventID?: string + message?: string + title?: string + culprit?: string | null + platform?: string | null + dateCreated?: string + metadata?: SentryIssueMetadata + entries?: SentryEventEntry[] + tags?: SentryEventTag[] +} + +/** + * The shape of an exception entry's `data` payload: a list of exception values, each + * with a type, message, and an optional rendered stack frame list. + */ +interface SentryExceptionData { + values?: { + type?: string + value?: string + module?: string + stacktrace?: { + frames?: { + filename?: string + function?: string + lineNo?: number + module?: string + }[] + } + }[] +} + +/** + * Resolved connector source configuration after normalization. + */ +interface SentrySourceConfig { + /** Bare host (no protocol, no trailing slash), e.g. `sentry.io` or a self-hosted host. */ + host: string + /** REST API base, e.g. `https://sentry.io/api/0`. */ + apiBase: string + organization: string + project: string + query: string + statsPeriod: string + environment: string + maxIssues: number +} + +/** + * Normalizes the host config value: trims whitespace, strips any protocol prefix, + * trailing slashes, and a pasted `/api` or `/api/0` suffix (the connector appends + * `/api/0` itself), and falls back to sentry.io when empty. Genuine path prefixes + * (e.g. `company.com/sentry` for subpath self-hosted installs) are preserved. + */ +function normalizeHost(rawHost: unknown): string { + const host = typeof rawHost === 'string' ? rawHost.trim() : '' + if (!host) return DEFAULT_HOST + return host + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '') + .replace(/\/api(\/0)?$/i, '') + .replace(/\/+$/, '') + .trim() +} + +/** + * Reads and normalizes the connector source configuration once per call. + */ +function readSourceConfig(sourceConfig: Record): SentrySourceConfig { + const host = normalizeHost(sourceConfig.baseUrl) + const organization = + typeof sourceConfig.organization === 'string' ? sourceConfig.organization.trim() : '' + const project = typeof sourceConfig.project === 'string' ? sourceConfig.project.trim() : '' + const query = + typeof sourceConfig.query === 'string' && sourceConfig.query.trim() + ? sourceConfig.query.trim() + : DEFAULT_QUERY + const statsPeriod = + typeof sourceConfig.statsPeriod === 'string' ? sourceConfig.statsPeriod.trim() : '' + const environment = + typeof sourceConfig.environment === 'string' ? sourceConfig.environment.trim() : '' + const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 + + return { + host, + apiBase: `https://${host}/api/0`, + organization, + project, + query, + statsPeriod, + environment, + maxIssues, + } +} + +/** + * Builds the standard JSON request headers carrying the Sentry auth token. + */ +function authHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + } +} + +/** + * Reads the `cursor` of the `rel="next"` link from a Sentry `Link` header. + * + * Sentry paginates via the `Link` header: each link is annotated with `rel`, + * `results`, and `cursor` attributes, e.g. + * `; rel="next"; results="true"; cursor="0:100:0"`. + * A further page exists only when the `next` link reports `results="true"`; when it + * reports `results="false"` (or the header is absent) the cursor points at an empty + * page and pagination must stop. The cursor is read from the `cursor="…"` attribute, + * which is the canonical token Sentry expects echoed back on the next request. + */ +function parseNextCursor(linkHeader: string | null): string | undefined { + if (!linkHeader) return undefined + + for (const part of linkHeader.split(',')) { + if (!/rel="next"/.test(part)) continue + if (!/results="true"/.test(part)) return undefined + const cursorMatch = part.match(/cursor="([^"]*)"/) + if (cursorMatch) return cursorMatch[1] + return undefined + } + + return undefined +} + +/** + * Builds the metadata-based content hash for an issue. + * + * The hash combines the issue id, its status, and `lastSeen`. `lastSeen` advances every + * time a new event lands on the group, which is exactly when the latest-event content can + * change — so it captures content freshness without hashing the downloaded body. `status` + * is included so resolve/ignore transitions also re-sync. `count` is deliberately omitted: + * it changes on every single occurrence and would churn the document on each event even + * when `lastSeen` already moved, providing no extra signal over `lastSeen`. + * + * The hash is derived purely from issue metadata present on both the list stub and the + * getDocument detail fetch, so both paths produce an identical hash for the same issue + * snapshot. If a fresh event lands between listing and hydration, `lastSeen` advances and + * getDocument computes a newer hash; the sync engine stores that newer hash, which the next + * list pass reproduces — so the document converges without churn. + */ +function buildContentHash(issue: SentryIssue): string { + return `sentry:${issue.id}:${issue.status ?? ''}:${issue.lastSeen ?? ''}` +} + +/** + * Builds the document title, preferring the issue title and falling back to the + * metadata type/value or short id. + */ +function buildTitle(issue: SentryIssue): string { + const title = issue.title?.trim() + if (title) return title + + const metaType = issue.metadata?.type?.trim() + const metaValue = issue.metadata?.value?.trim() + if (metaType && metaValue) return `${metaType}: ${metaValue}` + return metaType || issue.shortId || `Issue ${issue.id}` +} + +/** + * Collects the source-specific metadata fed to mapTags. Shared between the list stub and + * getDocument so tag values stay consistent regardless of which path produced the doc. + */ +function buildMetadata(issue: SentryIssue): Record { + return { + level: issue.level, + status: issue.status, + firstSeen: issue.firstSeen, + lastSeen: issue.lastSeen, + count: issue.count != null ? Number(issue.count) : undefined, + } +} + +/** + * Creates a lightweight document stub from a list entry. No per-issue API calls — the + * latest-event content is deferred to getDocument and only fetched for new/changed issues. + */ +function issueToStub(issue: SentryIssue): ExternalDocument { + return { + externalId: issue.id, + title: buildTitle(issue), + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: issue.permalink || undefined, + contentHash: buildContentHash(issue), + metadata: buildMetadata(issue), + } +} + +/** + * Renders the exception entry of a latest event into readable lines: each exception's + * type/value plus a compact, top-down stack frame list. + */ +function formatException(data: SentryExceptionData): string[] { + const lines: string[] = [] + + for (const value of data.values ?? []) { + const header = [value.type, value.value].filter(Boolean).join(': ') + if (header) lines.push(header) + + const frames = value.stacktrace?.frames ?? [] + for (const frame of frames.slice().reverse()) { + const location = [frame.module || frame.filename, frame.function].filter(Boolean).join(' in ') + const lineNo = frame.lineNo != null ? `:${frame.lineNo}` : '' + if (location) lines.push(` at ${location}${lineNo}`) + } + } + + return lines +} + +/** + * Formats an issue and its latest event into a single plain-text document covering the + * title, culprit, counts, the latest event's message/exception, and event tags. + */ +function formatIssueContent(issue: SentryIssue, event: SentryEvent | null): string { + const parts: string[] = [] + + parts.push(`Issue: ${buildTitle(issue)}`) + if (issue.shortId) parts.push(`Short ID: ${issue.shortId}`) + if (issue.culprit) parts.push(`Culprit: ${issue.culprit}`) + if (issue.level) parts.push(`Level: ${issue.level}`) + if (issue.status) parts.push(`Status: ${issue.status}`) + if (issue.platform) parts.push(`Platform: ${issue.platform}`) + if (issue.count) parts.push(`Events: ${issue.count}`) + if (issue.userCount != null) parts.push(`Users affected: ${issue.userCount}`) + if (issue.firstSeen) parts.push(`First seen: ${issue.firstSeen}`) + if (issue.lastSeen) parts.push(`Last seen: ${issue.lastSeen}`) + + if (event) { + const message = event.message?.trim() || event.title?.trim() + if (message) { + parts.push('') + parts.push('--- Latest Event ---') + if (event.dateCreated) parts.push(`Occurred: ${event.dateCreated}`) + parts.push(message) + } + + const exceptionEntry = event.entries?.find((entry) => entry.type === 'exception') + if (exceptionEntry?.data) { + const exceptionLines = formatException(exceptionEntry.data as SentryExceptionData) + if (exceptionLines.length > 0) { + parts.push('') + parts.push('--- Exception ---') + parts.push(...exceptionLines) + } + } + + const tagLines = (event.tags ?? []) + .map((tag) => (tag.key && tag.value ? `${tag.key}: ${tag.value}` : undefined)) + .filter((line): line is string => Boolean(line)) + if (tagLines.length > 0) { + parts.push('') + parts.push('--- Tags ---') + parts.push(...tagLines) + } + } + + return parts.join('\n').trim() +} + +/** + * Fetches the latest event for an issue. Returns null when the issue has no events or the + * request fails, so the document still syncs with its list-level summary. + * + * Uses the organization-scoped event endpoint + * `/api/0/organizations/{org}/issues/{id}/events/latest/`, which is the documented path + * and works for both sentry.io and self-hosted installs. + */ +async function fetchLatestEvent( + apiBase: string, + organization: string, + accessToken: string, + issueId: string +): Promise { + const url = `${apiBase}/organizations/${encodeURIComponent(organization)}/issues/${encodeURIComponent(issueId)}/events/latest/` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: authHeaders(accessToken), + }) + + if (!response.ok) { + if (response.status !== 404) { + logger.warn('Failed to fetch latest Sentry event', { issueId, status: response.status }) + } + return null + } + + return (await response.json()) as SentryEvent +} + +export const sentryConnector: ConnectorConfig = { + id: 'sentry', + name: 'Sentry', + description: 'Sync issues and errors from Sentry into your knowledge base', + version: '1.0.0', + icon: SentryIcon, + + auth: { + mode: 'apiKey', + label: 'Auth Token', + placeholder: 'Enter your Sentry auth token', + }, + + configFields: [ + { + id: 'baseUrl', + title: 'Sentry URL', + type: 'short-input', + placeholder: 'sentry.io', + required: false, + mode: 'advanced', + description: + 'Host of your Sentry install. Leave blank for sentry.io. Set this for self-hosted Sentry (e.g. sentry.mycompany.com).', + }, + { + id: 'organization', + title: 'Organization Slug', + type: 'short-input', + placeholder: 'e.g. my-org', + required: true, + description: 'The slug of your Sentry organization.', + }, + { + id: 'project', + title: 'Project Slug', + type: 'short-input', + placeholder: 'e.g. my-project', + required: true, + description: 'The slug of the project whose issues should be synced.', + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: `e.g. ${DEFAULT_QUERY}`, + required: false, + description: + 'Sentry search query to filter issues (e.g. "is:unresolved level:error environment:production"). Defaults to "is:unresolved".', + }, + { + id: 'environment', + title: 'Environment', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. production', + description: 'Only sync issues seen in this environment. Leave blank for all environments.', + }, + { + id: 'statsPeriod', + title: 'Stats Period', + type: 'dropdown', + required: false, + mode: 'advanced', + options: [ + { label: 'Sentry default (24h)', id: '' }, + { label: 'Last 24 hours', id: '24h' }, + { label: 'Last 14 days', id: '14d' }, + ], + description: 'Time window for the issue stats Sentry computes on the project issues list.', + }, + { + id: 'maxIssues', + title: 'Max Issues', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + description: 'Cap the number of issues synced. Leave empty to sync all matching issues.', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const { apiBase, organization, project, query, statsPeriod, environment, maxIssues } = + readSourceConfig(sourceConfig) + + if (!organization || !project) { + throw new Error('Organization and project slugs are required') + } + + /* + * Uses the project issues list endpoint + * `/api/0/projects/{org}/{project}/issues/`. This endpoint is deprecated in favor of + * `/api/0/organizations/{org}/issues/?project=`, but the organization endpoint + * filters by numeric project ID rather than slug — a UX regression for a connector + * keyed on the human-readable project slug. The project endpoint remains functional + * and slug-addressable, so it is retained deliberately for the listing path. Issue + * detail and latest-event fetches use the organization-scoped paths. + */ + const url = new URL( + `${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/issues/` + ) + url.searchParams.set('query', query) + url.searchParams.set('limit', String(ISSUES_PER_PAGE)) + if (statsPeriod) url.searchParams.set('statsPeriod', statsPeriod) + if (environment) url.searchParams.set('environment', environment) + if (cursor) url.searchParams.set('cursor', cursor) + + logger.info('Listing Sentry issues', { + organization, + project, + cursor: cursor ?? 'initial', + maxIssues, + }) + + const response = await fetchWithRetry(url.toString(), { + method: 'GET', + headers: authHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Sentry issues', { + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to list Sentry issues: ${response.status}`) + } + + const issues = ((await response.json()) as SentryIssue[]).filter((issue) => Boolean(issue.id)) + + const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 + let documents = issues.map(issueToStub) + let slicedSome = false + if (maxIssues > 0) { + const remaining = Math.max(0, maxIssues - prevFetched) + if (documents.length > remaining) { + slicedSome = true + documents = documents.slice(0, remaining) + } + } + + const totalFetched = prevFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxIssues > 0 && totalFetched >= maxIssues + + const nextCursor = parseNextCursor(response.headers.get('Link')) + if (hitLimit && (slicedSome || Boolean(nextCursor)) && syncContext) { + syncContext.listingCapped = true + } + const hasMore = !hitLimit && Boolean(nextCursor) + + return { + documents, + nextCursor: hasMore ? nextCursor : undefined, + hasMore, + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + try { + if (!externalId) return null + + const { apiBase, organization } = readSourceConfig(sourceConfig) + if (!organization) return null + + const url = `${apiBase}/organizations/${encodeURIComponent(organization)}/issues/${encodeURIComponent(externalId)}/` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: authHeaders(accessToken), + }) + + if (!response.ok) { + if (response.status === 404 || response.status === 410) return null + throw new Error(`Failed to fetch Sentry issue: ${response.status}`) + } + + const issue = (await response.json()) as SentryIssue + if (!issue?.id) return null + + const event = await fetchLatestEvent(apiBase, organization, accessToken, issue.id) + const content = formatIssueContent(issue, event) + if (!content.trim()) return null + + return { + externalId: issue.id, + title: buildTitle(issue), + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: issue.permalink || undefined, + contentHash: buildContentHash(issue), + metadata: buildMetadata(issue), + } + } catch (error) { + logger.warn('Failed to get Sentry issue', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const { apiBase, organization, project, statsPeriod, maxIssues, host } = + readSourceConfig(sourceConfig) + + if (!organization) { + return { valid: false, error: 'Organization slug is required' } + } + if (!project) { + return { valid: false, error: 'Project slug is required' } + } + + if (statsPeriod && !ALLOWED_STATS_PERIODS.has(statsPeriod)) { + return { valid: false, error: 'Stats period must be 24h or 14d' } + } + + const rawMax = sourceConfig.maxIssues as string | undefined + if (rawMax && (Number.isNaN(maxIssues) || maxIssues < 0)) { + return { valid: false, error: 'Max issues must be a non-negative number' } + } + + try { + /* + * Probe the project detail endpoint first. This exercises the `project:read` + * scope and the project-scoped path style, and gives a precise "not found" + * message when the org or project slug is wrong. + */ + const projectResponse = await fetchWithRetry( + `${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/`, + { + method: 'GET', + headers: authHeaders(accessToken), + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!projectResponse.ok) { + if (projectResponse.status === 401 || projectResponse.status === 403) { + return { valid: false, error: 'Invalid auth token or insufficient permissions' } + } + if (projectResponse.status === 404) { + return { + valid: false, + error: `Organization or project not found on ${host}`, + } + } + const errorText = await projectResponse.text().catch(() => '') + return { + valid: false, + error: `Sentry access failed: ${projectResponse.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, + } + } + + /* + * Probe the issues-list endpoint with a single-result page. The project + * detail probe above only proves `project:read`, but every sync operation — + * `listDocuments` and the org-scoped `getDocument`/latest-event hydration — + * needs `event:read`. A token scoped to `project:read` only would pass the + * first probe yet fail at hydration time, so this second probe forces a + * misconfigured token to fail fast at save time. It is slug-addressable and + * cheap (one issue, no stats window). + */ + const issuesProbeUrl = new URL( + `${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/issues/` + ) + issuesProbeUrl.searchParams.set('query', DEFAULT_QUERY) + issuesProbeUrl.searchParams.set('limit', '1') + + const issuesResponse = await fetchWithRetry( + issuesProbeUrl.toString(), + { + method: 'GET', + headers: authHeaders(accessToken), + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!issuesResponse.ok) { + if (issuesResponse.status === 401 || issuesResponse.status === 403) { + return { + valid: false, + error: + 'Auth token cannot read issues. The token needs the "event:read" scope (in addition to "project:read").', + } + } + const errorText = await issuesResponse.text().catch(() => '') + return { + valid: false, + error: `Sentry issue access failed: ${issuesResponse.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, + } + } + + return { valid: true } + } catch (error) { + const message = getErrorMessage(error, 'Failed to validate configuration') + return { valid: false, error: message } + } + }, + + tagDefinitions: [ + { id: 'level', displayName: 'Level', fieldType: 'text' }, + { id: 'status', displayName: 'Status', fieldType: 'text' }, + { id: 'count', displayName: 'Event Count', fieldType: 'number' }, + { id: 'firstSeen', displayName: 'First Seen', fieldType: 'date' }, + { id: 'lastSeen', displayName: 'Last Seen', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.level === 'string' && metadata.level.trim()) { + result.level = metadata.level + } + + if (typeof metadata.status === 'string' && metadata.status.trim()) { + result.status = metadata.status + } + + if (metadata.count != null) { + const num = Number(metadata.count) + if (!Number.isNaN(num)) result.count = num + } + + const firstSeen = parseTagDate(metadata.firstSeen) + if (firstSeen) result.firstSeen = firstSeen + + const lastSeen = parseTagDate(metadata.lastSeen) + if (lastSeen) result.lastSeen = lastSeen + + return result + }, +} diff --git a/apps/sim/connectors/typeform/index.ts b/apps/sim/connectors/typeform/index.ts new file mode 100644 index 00000000000..031ca7f4f62 --- /dev/null +++ b/apps/sim/connectors/typeform/index.ts @@ -0,0 +1 @@ +export { typeformConnector } from '@/connectors/typeform/typeform' diff --git a/apps/sim/connectors/typeform/typeform.ts b/apps/sim/connectors/typeform/typeform.ts new file mode 100644 index 00000000000..d4fd3d2c02b --- /dev/null +++ b/apps/sim/connectors/typeform/typeform.ts @@ -0,0 +1,605 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { TypeformIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate } from '@/connectors/utils' + +const logger = createLogger('TypeformConnector') + +const TYPEFORM_API_BASE = 'https://api.typeform.com' +/** Typeform allows page_size up to 1000; 100 keeps per-batch memory bounded. */ +const RESPONSES_PER_PAGE = 100 + +/** + * Allowed `response_type` filter values per the Responses API. `completed` is the + * API default; `all` is a connector-local sentinel that omits the filter so every + * response type (`started`, `partial`, `completed`) is returned. + */ +type ResponseTypeChoice = 'completed' | 'partial' | 'all' + +/** + * A single field definition from the Typeform form structure. + */ +interface TypeformField { + id: string + ref?: string + title?: string + type?: string +} + +/** + * The relevant subset of a Typeform form definition. + */ +interface TypeformFormDefinition { + id: string + title?: string + fields?: TypeformField[] + _links?: { display?: string } +} + +/** + * A single answer within a Typeform response. Only the value-bearing keys for + * each answer `type` are declared explicitly; the remainder are optional. + */ +interface TypeformAnswer { + field?: { id?: string; type?: string; ref?: string } + type?: string + text?: string + email?: string + url?: string + phone_number?: string + file_url?: string + number?: number + boolean?: boolean + date?: string + choice?: { label?: string; other?: string } + choices?: { labels?: string[]; other?: string } + payment?: { amount?: string; last4?: string; name?: string; success?: boolean } +} + +/** + * A single Typeform response item. + * + * `token` is the cursor field consumed by the `before`/`after` query params, while + * `response_id` is the identifier consumed by `included_response_ids`. They are + * distinct values, so both are tracked: the externalId is keyed off `response_id` + * (used by getDocument), the pagination cursor off `token`. + */ +interface TypeformResponseItem { + response_id?: string + token: string + landing_id?: string + landed_at?: string + submitted_at?: string + metadata?: { + platform?: string + browser?: string + referer?: string + } + answers?: TypeformAnswer[] | null + hidden?: Record | null +} + +/** + * Reads the `response_type` choice from sourceConfig, defaulting to `completed`. + */ +function getResponseTypeChoice(sourceConfig: Record): ResponseTypeChoice { + const value = + typeof sourceConfig.responseType === 'string' ? sourceConfig.responseType.trim() : '' + if (value === 'partial' || value === 'all') return value + return 'completed' +} + +/** + * Appends the `response_type` filter to a query string for a given choice. `all` + * omits the parameter so every type is returned; `partial` requests both partial + * and completed so partially-answered submissions are included alongside finished + * ones. + */ +function appendResponseType(params: URLSearchParams, choice: ResponseTypeChoice): void { + if (choice === 'completed') params.append('response_type', 'completed') + else if (choice === 'partial') params.append('response_type', 'partial,completed') +} + +/** + * Renders a single answer's value into a human-readable string. + */ +function renderAnswerValue(answer: TypeformAnswer): string { + switch (answer.type) { + case 'text': + return answer.text ?? '' + case 'email': + return answer.email ?? '' + case 'url': + return answer.url ?? '' + case 'phone_number': + return answer.phone_number ?? '' + case 'file_url': + return answer.file_url ?? '' + case 'number': + return answer.number != null ? String(answer.number) : '' + case 'boolean': + return answer.boolean != null ? (answer.boolean ? 'Yes' : 'No') : '' + case 'date': + return answer.date ?? '' + case 'choice': { + const parts = [answer.choice?.label, answer.choice?.other].filter(Boolean) + return parts.join(', ') + } + case 'choices': { + const labels = Array.isArray(answer.choices?.labels) ? (answer.choices?.labels ?? []) : [] + const parts = [...labels] + if (answer.choices?.other) parts.push(answer.choices.other) + return parts.join(', ') + } + case 'payment': + return answer.payment?.amount != null ? String(answer.payment.amount) : '' + default: + return '' + } +} + +/** + * Builds a map of field id to its human-readable question title from a form definition. + */ +function buildFieldTitleMap(form: TypeformFormDefinition): Map { + const map = new Map() + for (const field of form.fields ?? []) { + if (field.id) map.set(field.id, field.title || field.id) + } + return map +} + +/** + * Renders a Typeform response as readable "Question: Answer" plain text. + */ +function renderResponseContent( + form: TypeformFormDefinition, + response: TypeformResponseItem, + fieldTitles: Map +): string { + const parts: string[] = [] + + if (form.title) parts.push(`Form: ${form.title}`) + if (response.submitted_at) parts.push(`Submitted: ${response.submitted_at}`) + parts.push('') + + const answers = Array.isArray(response.answers) ? response.answers : [] + for (const answer of answers) { + const fieldId = answer.field?.id + const question = (fieldId && fieldTitles.get(fieldId)) || fieldId || 'Answer' + const value = renderAnswerValue(answer) + parts.push(`${question}: ${value}`) + } + + if (response.hidden && Object.keys(response.hidden).length > 0) { + parts.push('') + parts.push('--- Hidden Fields ---') + for (const [key, val] of Object.entries(response.hidden)) { + parts.push(`${key}: ${String(val)}`) + } + } + + return parts.join('\n') +} + +/** + * Derives the stable external identifier for a response. Prefers `response_id` + * (the identifier `included_response_ids` filters on, so getDocument can fetch the + * exact response) and falls back to `token` when `response_id` is absent. + */ +function getResponseExternalId(response: TypeformResponseItem): string { + return response.response_id || response.token +} + +/** + * Produces the metadata-based content hash for a response. Responses are immutable + * once submitted, so `submitted_at` is a stable change key. For not-yet-submitted + * (started/partial) responses, `landed_at` is used as the fallback indicator. + */ +function getResponseContentHash(response: TypeformResponseItem): string { + const indicator = response.submitted_at || response.landed_at || '' + return `typeform:${getResponseExternalId(response)}:${indicator}` +} + +/** + * Builds a full ExternalDocument from a rendered response. + */ +function responseToDocument( + form: TypeformFormDefinition, + response: TypeformResponseItem, + fieldTitles: Map +): ExternalDocument { + const externalId = getResponseExternalId(response) + const submittedAt = response.submitted_at + const displayUrl = form._links?.display + + return { + externalId, + title: `${form.title || 'Typeform'} — ${submittedAt || response.landed_at || externalId}`, + content: renderResponseContent(form, response, fieldTitles), + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: displayUrl || undefined, + contentHash: getResponseContentHash(response), + metadata: { + formId: form.id, + formTitle: form.title, + submittedAt, + landedAt: response.landed_at, + platform: response.metadata?.platform, + }, + } +} + +/** + * Fetches a form definition, caching it in syncContext keyed by form id so a + * single sync run fetches each form's structure only once. + */ +async function getFormDefinition( + accessToken: string, + formId: string, + syncContext?: Record, + retryOptions?: Parameters[2] +): Promise { + const cacheKey = `form:${formId}` + const cached = syncContext?.[cacheKey] as TypeformFormDefinition | undefined + if (cached) return cached + + const response = await fetchWithRetry( + `${TYPEFORM_API_BASE}/forms/${encodeURIComponent(formId)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + retryOptions + ) + + if (!response.ok) { + throw new Error(`Failed to fetch Typeform form ${formId}: ${response.status}`) + } + + const form = (await response.json()) as TypeformFormDefinition + if (syncContext) syncContext[cacheKey] = form + return form +} + +export const typeformConnector: ConnectorConfig = { + id: 'typeform', + name: 'Typeform', + description: 'Sync form responses from Typeform into your knowledge base', + version: '1.0.0', + icon: TypeformIcon, + + auth: { + mode: 'apiKey', + label: 'Personal Access Token', + placeholder: 'Enter your Typeform personal access token', + }, + + /** + * Incremental sync narrows the listing to responses submitted after the last + * sync via the `since` filter (inclusive, matched against `submitted_at` for + * completed responses). Responses are immutable, so reconciliation by content + * hash skips anything already indexed. + */ + supportsIncrementalSync: true, + + configFields: [ + { + id: 'formId', + title: 'Form ID', + type: 'short-input', + placeholder: 'e.g. abc123XYZ', + required: true, + description: 'The Typeform form whose responses you want to sync', + }, + { + id: 'responseType', + title: 'Responses', + type: 'dropdown', + required: false, + options: [ + { label: 'Completed only', id: 'completed' }, + { label: 'Partial & completed', id: 'partial' }, + { label: 'All (including started)', id: 'all' }, + ], + description: 'Which responses to sync by completion status. Defaults to completed only.', + }, + { + id: 'since', + title: 'Submitted After', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. 2024-01-01T00:00:00Z', + description: 'Only sync responses submitted on or after this date (ISO 8601, UTC).', + }, + { + id: 'until', + title: 'Submitted Before', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. 2024-12-31T23:59:59Z', + description: 'Only sync responses submitted on or before this date (ISO 8601, UTC).', + }, + { + id: 'query', + title: 'Search Filter', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. acme', + description: + 'Only sync responses containing this text in any answer, hidden field, or variable.', + }, + { + id: 'maxResponses', + title: 'Max Responses', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record, + lastSyncAt?: Date + ): Promise => { + const formId = (sourceConfig.formId as string)?.trim() + if (!formId) { + throw new Error('Form ID is required') + } + const maxResponses = sourceConfig.maxResponses ? Number(sourceConfig.maxResponses) : 0 + + const form = await getFormDefinition(accessToken, formId, syncContext) + const fieldTitles = buildFieldTitleMap(form) + + const queryParams = new URLSearchParams() + queryParams.append('page_size', String(RESPONSES_PER_PAGE)) + appendResponseType(queryParams, getResponseTypeChoice(sourceConfig)) + + const since = typeof sourceConfig.since === 'string' ? sourceConfig.since.trim() : '' + const until = typeof sourceConfig.until === 'string' ? sourceConfig.until.trim() : '' + const search = typeof sourceConfig.query === 'string' ? sourceConfig.query.trim() : '' + if (until) queryParams.append('until', until) + if (search) queryParams.append('query', search) + + /** + * `since` from the user config wins; otherwise incremental sync derives it + * from lastSyncAt. `since` narrows the set by submission date while `before` + * (token paging) walks it newest-to-oldest; the two compose — only `sort` is + * mutually exclusive with `before`/`after`, which this connector never sets. + */ + if (since) queryParams.append('since', since) + else if (lastSyncAt) queryParams.append('since', lastSyncAt.toISOString()) + + if (cursor) { + queryParams.append('before', cursor) + } + + const url = `${TYPEFORM_API_BASE}/forms/${encodeURIComponent(formId)}/responses?${queryParams.toString()}` + + logger.info('Listing Typeform responses', { + formId, + before: cursor, + incremental: Boolean(lastSyncAt), + }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Typeform responses', { + formId, + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to list Typeform responses: ${response.status}`) + } + + const data = (await response.json()) as { items?: TypeformResponseItem[] } + const items = Array.isArray(data.items) ? data.items.filter((item) => item?.token) : [] + + const prevTotal = (syncContext?.totalDocsFetched as number) ?? 0 + + /** + * Trim the page to the remaining `maxResponses` budget so the cap is honored + * exactly rather than overshooting by up to a full page. The `before` cursor + * is still derived from the untrimmed page below, but it is unused once the + * cap is hit because `hasMore` becomes false. + */ + let cappedItems = items + let slicedSome = false + if (maxResponses > 0) { + const remaining = Math.max(0, maxResponses - prevTotal) + if (items.length > remaining) { + slicedSome = true + cappedItems = items.slice(0, remaining) + } + } + + const documents: ExternalDocument[] = cappedItems.map((item) => + responseToDocument(form, item, fieldTitles) + ) + + const totalFetched = prevTotal + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxResponses > 0 && totalFetched >= maxResponses + + /** + * The `before` cursor is the response `token` (not `response_id`). Each full + * page advances to the oldest token seen so the next request pages strictly + * older responses. A short page or a missing token ends pagination, which also + * guards against an infinite loop if the API ever repeats a cursor. + */ + const lastItem = items[items.length - 1] + const nextCursor = lastItem?.token + const sourceHasMore = items.length === RESPONSES_PER_PAGE && Boolean(nextCursor) + + /** + * Signal a truncated listing so the engine skips deletion reconciliation — + * but only when the cap actually dropped responses (this page was sliced, or + * the source had more pages). If the cap merely coincides with source + * exhaustion, reconciliation stays enabled so deleted responses are cleaned up. + */ + if (hitLimit && (slicedSome || sourceHasMore) && syncContext) { + syncContext.listingCapped = true + } + + const hasMore = !hitLimit && sourceHasMore + + return { + documents, + nextCursor: hasMore ? nextCursor : undefined, + hasMore, + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string, + syncContext?: Record + ): Promise => { + const formId = (sourceConfig.formId as string)?.trim() + if (!formId || !externalId) return null + + try { + const form = await getFormDefinition(accessToken, formId, syncContext) + const fieldTitles = buildFieldTitleMap(form) + + /** + * `included_response_ids` filters by `response_id`, matching the externalId + * minted in listDocuments. The configured response_type is forwarded so a + * partial response stays fetchable (the endpoint defaults to completed-only, + * which would otherwise exclude it). + */ + const params = new URLSearchParams() + params.append('included_response_ids', externalId) + appendResponseType(params, getResponseTypeChoice(sourceConfig)) + + const url = `${TYPEFORM_API_BASE}/forms/${encodeURIComponent(formId)}/responses?${params.toString()}` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error(`Failed to fetch Typeform response ${externalId}: ${response.status}`) + } + + const data = (await response.json()) as { items?: TypeformResponseItem[] } + const item = Array.isArray(data.items) + ? data.items.find((candidate) => getResponseExternalId(candidate) === externalId) + : undefined + if (!item) return null + + return responseToDocument(form, item, fieldTitles) + } catch (error) { + logger.warn('Failed to get Typeform response', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const formId = (sourceConfig.formId as string)?.trim() + if (!formId) { + return { valid: false, error: 'Form ID is required' } + } + + const maxResponses = sourceConfig.maxResponses as string | undefined + if (maxResponses && (Number.isNaN(Number(maxResponses)) || Number(maxResponses) <= 0)) { + return { valid: false, error: 'Max responses must be a positive number' } + } + + const since = typeof sourceConfig.since === 'string' ? sourceConfig.since.trim() : '' + if (since && Number.isNaN(new Date(since).getTime())) { + return { valid: false, error: '"Submitted After" must be a valid ISO 8601 date' } + } + + const until = typeof sourceConfig.until === 'string' ? sourceConfig.until.trim() : '' + if (until && Number.isNaN(new Date(until).getTime())) { + return { valid: false, error: '"Submitted Before" must be a valid ISO 8601 date' } + } + + if (since && until && new Date(since).getTime() > new Date(until).getTime()) { + return { valid: false, error: '"Submitted After" must not be later than "Submitted Before"' } + } + + try { + const response = await fetchWithRetry( + `${TYPEFORM_API_BASE}/forms/${encodeURIComponent(formId)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: 'Invalid or unauthorized Typeform personal access token' } + } + if (response.status === 404) { + return { valid: false, error: `Form not found: ${formId}` } + } + if (!response.ok) { + return { valid: false, error: `Failed to validate Typeform form: ${response.status}` } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + tagDefinitions: [ + { id: 'formTitle', displayName: 'Form Title', fieldType: 'text' }, + { id: 'platform', displayName: 'Platform', fieldType: 'text' }, + { id: 'submittedAt', displayName: 'Submitted At', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.formTitle === 'string' && metadata.formTitle) { + result.formTitle = metadata.formTitle + } + + if (typeof metadata.platform === 'string' && metadata.platform) { + result.platform = metadata.platform + } + + const submittedAt = parseTagDate(metadata.submittedAt) + if (submittedAt) result.submittedAt = submittedAt + + return result + }, +} diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 391d3a590f8..cc78c3e680d 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -78,3 +78,36 @@ export function parseMultiValue(value: unknown): string[] { } return [] } + +/** + * Reads a response body into a Buffer while enforcing a hard byte cap. The + * declared `content-length` header cannot be trusted as the sole guard — + * chunked transfer encoding may omit it entirely — so bytes are accumulated + * from the stream and reading aborts as soon as the cap is exceeded, ensuring + * an oversized (or hostile) body is never fully buffered into memory. + * Returns null when the cap is exceeded. + */ +export async function readBodyWithLimit( + response: Response, + maxBytes: number +): Promise { + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()) + return buffer.byteLength > maxBytes ? null : buffer + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => {}) + return null + } + chunks.push(value) + } + return Buffer.concat(chunks) +} diff --git a/apps/sim/connectors/x/index.ts b/apps/sim/connectors/x/index.ts new file mode 100644 index 00000000000..3760c8b2985 --- /dev/null +++ b/apps/sim/connectors/x/index.ts @@ -0,0 +1 @@ +export { xConnector } from '@/connectors/x/x' diff --git a/apps/sim/connectors/x/x.ts b/apps/sim/connectors/x/x.ts new file mode 100644 index 00000000000..0b52253f12e --- /dev/null +++ b/apps/sim/connectors/x/x.ts @@ -0,0 +1,628 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { xIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseMultiValue, parseTagDate } from '@/connectors/utils' + +const logger = createLogger('XConnector') + +const X_API_BASE = 'https://api.x.com/2' +const DEFAULT_MAX_POSTS = 200 +/** Max page size accepted by the timeline, mentions, bookmarks, and likes endpoints. */ +const POSTS_PER_PAGE = 100 +/** + * Minimum `max_results` accepted by the user-tweets, mentions, and liked-tweets + * endpoints. The bookmarks endpoint is the sole exception and accepts a minimum of 1. + */ +const MIN_PAGE_SIZE = 5 +/** + * `edit_history_tweet_ids` is requested explicitly (it is not a default field) so the + * content hash can key on edit-history length and detect edits. + */ +const TWEET_FIELDS = 'created_at,public_metrics,text,edit_history_tweet_ids' + +/** + * Sync mode determines which timeline the connector reads. + * - `me`: the authenticated user's own posts (GET /2/users/:id/tweets) + * - `user`: another account's posts by username (GET /2/users/:id/tweets) + * - `mentions`: posts mentioning the authenticated user (GET /2/users/:id/mentions) + * - `bookmarks`: the authenticated user's bookmarks (GET /2/users/:id/bookmarks) + * - `likes`: posts the authenticated user has liked (GET /2/users/:id/liked_tweets) + */ +type SyncMode = 'me' | 'user' | 'mentions' | 'bookmarks' | 'likes' + +/** Modes whose endpoint supports the `exclude=retweets,replies` parameter. */ +const EXCLUDE_CAPABLE_MODES: ReadonlySet = new Set(['me', 'user']) +/** Modes whose endpoint supports the `start_time` / `end_time` parameters. */ +const DATE_RANGE_CAPABLE_MODES: ReadonlySet = new Set([ + 'me', + 'user', + 'mentions', +]) + +interface XPublicMetrics { + retweet_count?: number + reply_count?: number + like_count?: number + quote_count?: number +} + +interface XTweet { + id: string + text: string + created_at?: string + author_id?: string + public_metrics?: XPublicMetrics + edit_history_tweet_ids?: string[] +} + +interface XUser { + id: string + name?: string + username?: string +} + +interface XListResponse { + data?: XTweet[] + includes?: { users?: XUser[] } + meta?: { next_token?: string; result_count?: number } + errors?: Array<{ detail?: string; title?: string }> +} + +interface XSingleResponse { + data?: XTweet + includes?: { users?: XUser[] } + errors?: Array<{ detail?: string; title?: string }> +} + +/** + * Resolves the configured sync mode, defaulting to the authenticated user's + * own posts. + */ +function resolveSyncMode(sourceConfig: Record): SyncMode { + const mode = sourceConfig.syncMode + if (mode === 'user' || mode === 'mentions' || mode === 'bookmarks' || mode === 'likes') { + return mode + } + return 'me' +} + +/** + * Reads a boolean toggle from a dropdown config field that stores 'true' / 'false' + * strings. Falls back to `defaultValue` when unset or unrecognized. + */ +function readBooleanOption(value: unknown, defaultValue: boolean): boolean { + if (value === 'true' || value === true) return true + if (value === 'false' || value === false) return false + return defaultValue +} + +/** + * Parses the configured usernames into a normalized, deduplicated handle list. + * + * Handles are lowercased and stripped of a leading `@` before deduplication so + * that `jack`, `@jack`, and `Jack` collapse to a single entry — avoiding a + * duplicate user-id lookup and a redundant `userIndex` slot in the packed + * pagination cursor. Both `validateConfig` and `listDocuments` call this so the + * cursor's `userIndex` stays aligned to the same array across pages. + */ +function parseUsernames(value: unknown): string[] { + const seen = new Set() + const out: string[] = [] + for (const raw of parseMultiValue(value)) { + const handle = raw.replace(/^@/, '').toLowerCase() + if (!handle || seen.has(handle)) continue + seen.add(handle) + out.push(handle) + } + return out +} + +/** + * Reads and trims a string config field, returning undefined when blank. + */ +function readTrimmed(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** + * Performs an authenticated GET against the X API v2 and returns the parsed JSON. + */ +async function xApiGet( + path: string, + accessToken: string, + params?: Record, + retryOptions?: Parameters[2] +): Promise { + const queryParams = params ? `?${new URLSearchParams(params).toString()}` : '' + const url = `${X_API_BASE}${path}${queryParams}` + + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + retryOptions + ) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`X API HTTP error: ${response.status} ${response.statusText} ${body}`.trim()) + } + + return response.json() +} + +/** + * Resolves the authenticated user's numeric ID via GET /2/users/me. + */ +async function resolveMyUserId( + accessToken: string, + retryOptions?: Parameters[2] +): Promise { + const data = (await xApiGet('/users/me', accessToken, undefined, retryOptions)) as { + data?: { id?: string } + } + const id = data.data?.id + if (!id) throw new Error('Failed to resolve authenticated user ID') + return id +} + +/** + * Resolves a public username to its numeric user ID via + * GET /2/users/by/username/:username. + */ +async function resolveUsernameId( + accessToken: string, + username: string, + retryOptions?: Parameters[2] +): Promise { + const handle = username.trim().replace(/^@/, '') + const data = (await xApiGet( + `/users/by/username/${encodeURIComponent(handle)}`, + accessToken, + undefined, + retryOptions + )) as { data?: { id?: string }; errors?: Array<{ detail?: string }> } + const id = data.data?.id + if (!id) { + throw new Error(data.errors?.[0]?.detail || `User @${handle} not found`) + } + return id +} + +/** + * Builds a deterministic, metadata-based content hash for a tweet. + * + * Tweets are immutable outside the brief post-publish edit window; an edit + * appends a new ID to `edit_history_tweet_ids`. We therefore key the hash on + * the edit-history length when present (so edits are detected as changes), and + * fall back to `created_at` when the field is absent. + */ +function tweetContentHash(tweet: XTweet): string { + const historyLength = Array.isArray(tweet.edit_history_tweet_ids) + ? tweet.edit_history_tweet_ids.length + : undefined + const changeIndicator = historyLength ?? tweet.created_at ?? '' + return `x:${tweet.id}:${changeIndicator}` +} + +/** + * Builds the canonical source URL for a tweet. When the author's username is + * unknown, falls back to the username-agnostic permalink which X redirects. + */ +function tweetSourceUrl(tweetId: string, username?: string): string { + if (username) return `https://x.com/${username}/status/${tweetId}` + return `https://x.com/i/web/status/${tweetId}` +} + +/** + * Derives a short title from the tweet text (first line, truncated). + */ +function tweetTitle(text: string): string { + const firstLine = text.split('\n')[0].trim() + if (!firstLine) return 'Tweet' + return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine +} + +/** + * Converts a tweet (and its resolved author) into an ExternalDocument with + * inline content — the list API returns full text, so no deferral is needed. + * + * The author is the actual tweet author resolved from the `author_id` expansion, + * not the credential owner — important for bookmarks and likes, where most posts + * belong to other accounts. + */ +function tweetToDocument(tweet: XTweet, author?: XUser): ExternalDocument { + const metrics = tweet.public_metrics ?? {} + return { + externalId: tweet.id, + title: tweetTitle(tweet.text), + content: tweet.text, + mimeType: 'text/plain', + sourceUrl: tweetSourceUrl(tweet.id, author?.username), + contentHash: tweetContentHash(tweet), + metadata: { + author: author?.username ?? author?.name ?? undefined, + authorName: author?.name ?? undefined, + createdAt: tweet.created_at ?? undefined, + likeCount: metrics.like_count ?? 0, + retweetCount: metrics.retweet_count ?? 0, + replyCount: metrics.reply_count ?? 0, + quoteCount: metrics.quote_count ?? 0, + }, + } +} + +/** + * Maps tweets from a list response to documents, joining each tweet to its + * author via the `includes.users` expansion (matched on `author_id`). + */ +function mapTweets(response: XListResponse): ExternalDocument[] { + const usersById = new Map() + for (const user of response.includes?.users ?? []) { + usersById.set(user.id, user) + } + const tweets = response.data ?? [] + return tweets.map((tweet) => tweetToDocument(tweet, usersById.get(tweet.author_id ?? ''))) +} + +/** + * Returns the API path for a given mode and resolved user ID. + */ +function listPathForMode(mode: SyncMode, userId: string): string { + switch (mode) { + case 'bookmarks': + return `/users/${userId}/bookmarks` + case 'likes': + return `/users/${userId}/liked_tweets` + case 'mentions': + return `/users/${userId}/mentions` + default: + return `/users/${userId}/tweets` + } +} + +/** + * Builds the query string for the active listing endpoint. `pageSize` is the + * per-request `max_results`, already clamped to the endpoint's valid range and + * to any remaining cap. `exclude` and date-range params are only attached for + * the modes whose endpoint supports them. + */ +function buildListParams( + sourceConfig: Record, + mode: SyncMode, + pageSize: number, + cursor?: string +): Record { + const params: Record = { + max_results: String(pageSize), + 'tweet.fields': TWEET_FIELDS, + expansions: 'author_id', + 'user.fields': 'name,username', + } + + if (EXCLUDE_CAPABLE_MODES.has(mode)) { + const includeReplies = readBooleanOption(sourceConfig.includeReplies, false) + const includeRetweets = readBooleanOption(sourceConfig.includeRetweets, false) + const exclude: string[] = [] + if (!includeRetweets) exclude.push('retweets') + if (!includeReplies) exclude.push('replies') + if (exclude.length > 0) params.exclude = exclude.join(',') + } + + if (DATE_RANGE_CAPABLE_MODES.has(mode)) { + const startTime = readTrimmed(sourceConfig.startTime) + const endTime = readTrimmed(sourceConfig.endTime) + if (startTime) params.start_time = startTime + if (endTime) params.end_time = endTime + } + + if (cursor) params.pagination_token = cursor + return params +} + +/** + * Clamps the requested page size to the endpoint's valid range and to the number + * of posts still needed under the cap. The user-tweets, mentions, and liked-tweets + * endpoints require `max_results` ≥ 5; only bookmarks accepts ≥ 1. We always request + * at least the endpoint minimum (over-fetch on the final page is trimmed afterward). + */ +function resolvePageSize(mode: SyncMode, remaining: number): number { + const floor = mode === 'bookmarks' ? 1 : MIN_PAGE_SIZE + if (remaining <= 0) return POSTS_PER_PAGE + return Math.max(floor, Math.min(POSTS_PER_PAGE, remaining)) +} + +export const xConnector: ConnectorConfig = { + id: 'x', + name: 'X', + description: 'Sync posts from X (formerly Twitter) into your knowledge base', + version: '1.0.0', + icon: xIcon, + + auth: { + mode: 'oauth', + provider: 'x', + requiredScopes: ['tweet.read', 'users.read', 'bookmark.read', 'like.read', 'offline.access'], + }, + + configFields: [ + { + id: 'syncMode', + title: 'Sync Mode', + type: 'dropdown', + required: false, + description: 'Which posts to sync into the knowledge base', + options: [ + { label: 'My posts', id: 'me' }, + { label: 'Another user', id: 'user' }, + { label: 'My mentions', id: 'mentions' }, + { label: 'My bookmarks', id: 'bookmarks' }, + { label: 'My likes', id: 'likes' }, + ], + }, + { + id: 'username', + title: 'Username(s)', + type: 'short-input', + required: false, + multi: true, + placeholder: 'e.g. jack, xdevelopers (required for "Another user")', + description: + 'One or more X usernames to sync posts from (comma-separated). Only used when Sync Mode is "Another user".', + }, + { + id: 'includeReplies', + title: 'Include Replies', + type: 'dropdown', + required: false, + options: [ + { label: 'Exclude replies', id: 'false' }, + { label: 'Include replies', id: 'true' }, + ], + description: 'Whether to include reply posts. Applies to "My posts" and "Another user".', + }, + { + id: 'includeRetweets', + title: 'Include Retweets', + type: 'dropdown', + required: false, + options: [ + { label: 'Exclude retweets', id: 'false' }, + { label: 'Include retweets', id: 'true' }, + ], + description: 'Whether to include retweets. Applies to "My posts" and "Another user".', + }, + { + id: 'startTime', + title: 'Start Time', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. 2024-01-01T00:00:00Z', + description: + 'Oldest post time (ISO 8601 UTC). Applies to posts and mentions; ignored for bookmarks and likes.', + }, + { + id: 'endTime', + title: 'End Time', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. 2024-12-31T23:59:59Z', + description: + 'Newest post time (ISO 8601 UTC). Applies to posts and mentions; ignored for bookmarks and likes.', + }, + { + id: 'maxPosts', + title: 'Max Posts', + type: 'short-input', + required: false, + placeholder: `e.g. 100 (default: ${DEFAULT_MAX_POSTS})`, + description: + 'Maximum number of posts to sync (across all configured users). Posts beyond this limit are not deleted from the knowledge base; X also only exposes a limited recent window (≈3,200 timeline posts, ≈800 bookmarks), so posts that age out of that window are removed on the next sync.', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const mode = resolveSyncMode(sourceConfig) + const maxPosts = sourceConfig.maxPosts ? Number(sourceConfig.maxPosts) : DEFAULT_MAX_POSTS + + const collectedSoFar = (syncContext?.collected as number) ?? 0 + if (maxPosts > 0 && collectedSoFar >= maxPosts) { + return { documents: [], hasMore: false } + } + + // For the multi-username "user" mode, walk one username per cursor cycle. The + // cursor packs the username index and that user's pagination token; the shared + // cap is enforced across all users via syncContext.collected. + const usernames = mode === 'user' ? parseUsernames(sourceConfig.username) : [] + if (mode === 'user' && usernames.length === 0) { + throw new Error('Username is required when Sync Mode is "Another user"') + } + + let userIndex = 0 + let pageToken = cursor + if (mode === 'user' && cursor) { + const sep = cursor.indexOf(':') + if (sep >= 0) { + userIndex = Number(cursor.slice(0, sep)) || 0 + const token = cursor.slice(sep + 1) + pageToken = token.length > 0 ? token : undefined + } + } + + // Resolve the target user ID. For `user` mode it depends on the current index + // (resolved per page, cheap); for self-modes it is cached on syncContext. + let userId: string + if (mode === 'user') { + userId = await resolveUsernameId(accessToken, usernames[userIndex]) + } else { + userId = (syncContext?.userId as string | undefined) ?? (await resolveMyUserId(accessToken)) + if (syncContext) syncContext.userId = userId + } + + const remaining = maxPosts > 0 ? maxPosts - collectedSoFar : 0 + const pageSize = resolvePageSize(mode, remaining) + const path = listPathForMode(mode, userId) + const params = buildListParams(sourceConfig, mode, pageSize, pageToken) + + logger.info('Syncing X posts', { mode, userId, userIndex, maxPosts }) + + const response = (await xApiGet(path, accessToken, params)) as XListResponse + if (response.errors?.length && !response.data) { + throw new Error(response.errors[0]?.detail || response.errors[0]?.title || 'X API error') + } + + let documents = mapTweets(response) + + if (maxPosts > 0 && collectedSoFar + documents.length > maxPosts) { + documents = documents.slice(0, maxPosts - collectedSoFar) + } + const newCollected = collectedSoFar + documents.length + if (syncContext) syncContext.collected = newCollected + + const capReached = maxPosts > 0 && newCollected >= maxPosts + const nextToken = response.meta?.next_token + + // Advance pagination: continue the current user's pages, else move to the next + // username (user mode), else stop. + if (capReached) { + // We stopped before exhausting the source, so the listing is incomplete: + // older previously-synced posts may still exist beyond the `maxPosts` cap. + // Flag the sync as capped so the engine skips deletion reconciliation and + // does not soft-delete posts that simply fell outside this run's window. + // A forced full sync bypasses this guard and reconciles normally. + if (syncContext) syncContext.listingCapped = true + return { documents, hasMore: false } + } + + if (mode === 'user') { + if (nextToken) { + return { documents, nextCursor: `${userIndex}:${nextToken}`, hasMore: true } + } + const nextUserIndex = userIndex + 1 + if (nextUserIndex < usernames.length) { + return { documents, nextCursor: `${nextUserIndex}:`, hasMore: true } + } + return { documents, hasMore: false } + } + + return { + documents, + nextCursor: nextToken ?? undefined, + hasMore: Boolean(nextToken), + } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + try { + const response = (await xApiGet(`/tweets/${encodeURIComponent(externalId)}`, accessToken, { + 'tweet.fields': TWEET_FIELDS, + expansions: 'author_id', + 'user.fields': 'name,username', + })) as XSingleResponse + + const tweet = response.data + if (!tweet) return null + + const author = response.includes?.users?.find((u) => u.id === tweet.author_id) + return tweetToDocument(tweet, author) + } catch (error) { + logger.warn('Failed to get X tweet document', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const mode = resolveSyncMode(sourceConfig) + const usernames = mode === 'user' ? parseUsernames(sourceConfig.username) : [] + const maxPosts = sourceConfig.maxPosts as string | undefined + + if (mode === 'user' && usernames.length === 0) { + return { valid: false, error: 'Username is required when Sync Mode is "Another user"' } + } + + if (maxPosts && (Number.isNaN(Number(maxPosts)) || Number(maxPosts) <= 0)) { + return { valid: false, error: 'Max posts must be a positive number' } + } + + const startTime = readTrimmed(sourceConfig.startTime) + if (startTime && Number.isNaN(new Date(startTime).getTime())) { + return { valid: false, error: 'Start Time must be a valid ISO 8601 timestamp' } + } + const endTime = readTrimmed(sourceConfig.endTime) + if (endTime && Number.isNaN(new Date(endTime).getTime())) { + return { valid: false, error: 'End Time must be a valid ISO 8601 timestamp' } + } + + try { + await resolveMyUserId(accessToken, VALIDATE_RETRY_OPTIONS) + + if (mode === 'user') { + for (const username of usernames) { + await resolveUsernameId(accessToken, username, VALIDATE_RETRY_OPTIONS) + } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + tagDefinitions: [ + { id: 'author', displayName: 'Author', fieldType: 'text' }, + { id: 'createdAt', displayName: 'Created Date', fieldType: 'date' }, + { id: 'likeCount', displayName: 'Like Count', fieldType: 'number' }, + { id: 'retweetCount', displayName: 'Retweet Count', fieldType: 'number' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.author === 'string') { + result.author = metadata.author + } + + const createdAt = parseTagDate(metadata.createdAt) + if (createdAt) { + result.createdAt = createdAt + } + + if (metadata.likeCount != null) { + const num = Number(metadata.likeCount) + if (!Number.isNaN(num)) result.likeCount = num + } + + if (metadata.retweetCount != null) { + const num = Number(metadata.retweetCount) + if (!Number.isNaN(num)) result.retweetCount = num + } + + return result + }, +} diff --git a/apps/sim/connectors/youtube/index.ts b/apps/sim/connectors/youtube/index.ts new file mode 100644 index 00000000000..a7d08fd17b3 --- /dev/null +++ b/apps/sim/connectors/youtube/index.ts @@ -0,0 +1 @@ +export { youtubeConnector } from '@/connectors/youtube/youtube' diff --git a/apps/sim/connectors/youtube/youtube.ts b/apps/sim/connectors/youtube/youtube.ts new file mode 100644 index 00000000000..a682ddeea23 --- /dev/null +++ b/apps/sim/connectors/youtube/youtube.ts @@ -0,0 +1,650 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { YouTubeIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { joinTagArray, parseTagDate } from '@/connectors/utils' + +const logger = createLogger('YouTubeConnector') + +const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3' + +/** Max videos fetched per `playlistItems.list` page (YouTube hard limit is 50). */ +const PAGE_SIZE = 50 + +/** Videos shorter than this (seconds) are treated as Shorts when the exclude filter is on. */ +const SHORTS_MAX_DURATION_SECONDS = 60 + +/** + * Minimal `playlistItems.list` item shape we consume. + * `contentDetails.videoId` is the stable video identifier; `snippet.resourceId.videoId` + * is used as a fallback for older API responses. + * + * `snippet.publishedAt` is the time the item was ADDED to the playlist, whereas + * `contentDetails.videoPublishedAt` is the time the VIDEO was published to YouTube. + * These differ for hand-curated playlists, so only `videoPublishedAt` is used for the + * change-detection hash (it matches `videos.list` `snippet.publishedAt`). + */ +interface PlaylistItem { + contentDetails?: { videoId?: string; videoPublishedAt?: string } + snippet?: { + title?: string + publishedAt?: string + channelTitle?: string + videoOwnerChannelTitle?: string + resourceId?: { videoId?: string } + } +} + +/** + * Minimal `videos.list` item shape we consume in `getDocument`. + */ +interface VideoItem { + id?: string + snippet?: { + title?: string + description?: string + publishedAt?: string + channelTitle?: string + tags?: string[] + categoryId?: string + } + contentDetails?: { duration?: string } + status?: { privacyStatus?: string } +} + +/** + * Resolves the API key from the access token the sync engine provides. + * In `apiKey` mode the engine decrypts the stored key and passes it as `accessToken`. + */ +function getApiKey(accessToken: string): string { + return accessToken.trim() +} + +/** + * Builds the change-detection hash for a video. + * + * The hash is keyed on the video's own publish time (`videos.list` `snippet.publishedAt` + * / playlistItem `contentDetails.videoPublishedAt`), which is identical on both the + * listing stub and the hydrated document — guaranteeing the stub/getDocument hash + * invariant. The playlist-item "added at" time (`snippet.publishedAt`) is deliberately + * NOT used, since `getDocument` (via `videos.list`) cannot reproduce it. + * + * YouTube exposes no field that reliably changes when a video's title/description is + * edited, so edits to already-synced videos are not detected — only new videos are + * picked up. This is a known limitation of the API-key data surface. + */ +function buildContentHash(videoId: string, videoPublishedAt: string): string { + return `youtube:${videoId}:${videoPublishedAt}` +} + +/** + * Parses an ISO 8601 duration (e.g. `PT1M30S`, `PT2H`, `P1DT2H`) into total seconds. + * Returns null when the value is missing or unparseable. + */ +function parseIso8601Duration(value: string | undefined): number | null { + if (!value) return null + const match = value.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/) + if (!match) return null + const [, days, hours, minutes, seconds] = match + if (!days && !hours && !minutes && !seconds) return null + return ( + Number(days ?? 0) * 86400 + + Number(hours ?? 0) * 3600 + + Number(minutes ?? 0) * 60 + + Number(seconds ?? 0) + ) +} + +/** + * Resolves a channel reference to its "uploads" playlist ID via `channels.list`. + * + * Accepts a `UC…` channel ID, an `@handle` (resolved with `forHandle`), or a legacy + * username (resolved with `forUsername`). Returns null when the channel is missing or + * has no uploads playlist. + */ +async function resolveUploadsPlaylistId( + apiKey: string, + channelRef: string, + retryOptions?: Parameters[2] +): Promise { + const ref = channelRef.trim() + if (!ref) return null + + const params = new URLSearchParams({ part: 'contentDetails', key: apiKey }) + if (ref.startsWith('@')) { + params.set('forHandle', ref) + } else if (/^UC[\w-]{20,}$/.test(ref)) { + params.set('id', ref) + } else { + params.set('forUsername', ref) + } + + const url = `${YOUTUBE_API_BASE}/channels?${params.toString()}` + + const response = await fetchWithRetry( + url, + { method: 'GET', headers: { Accept: 'application/json' } }, + retryOptions + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to resolve channel uploads playlist', { + channelRef: ref, + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to resolve channel: ${response.status}`) + } + + const data = await response.json() + const items = (data.items ?? []) as Array<{ + contentDetails?: { relatedPlaylists?: { uploads?: string } } + }> + return items[0]?.contentDetails?.relatedPlaylists?.uploads ?? null +} + +/** + * Resolves the effective playlist ID to sync from sourceConfig, and whether the source + * is a channel's reverse-chronological uploads playlist (which enables early-stop for + * the `publishedAfter` filter). A `playlistId` takes precedence over a `channelId`. + */ +async function resolvePlaylistId( + apiKey: string, + sourceConfig: Record, + retryOptions?: Parameters[2] +): Promise<{ playlistId: string | null; isUploadsPlaylist: boolean }> { + const playlistId = (sourceConfig.playlistId as string | undefined)?.trim() + if (playlistId) return { playlistId, isUploadsPlaylist: false } + + const channelId = (sourceConfig.channelId as string | undefined)?.trim() + if (channelId) { + const resolved = await resolveUploadsPlaylistId(apiKey, channelId, retryOptions) + return { playlistId: resolved, isUploadsPlaylist: resolved != null } + } + + return { playlistId: null, isUploadsPlaylist: false } +} + +/** + * Extracts the video ID from a playlist item, preferring the stable + * `contentDetails.videoId` over the legacy `snippet.resourceId.videoId`. + */ +function getVideoId(item: PlaylistItem): string { + return item.contentDetails?.videoId ?? item.snippet?.resourceId?.videoId ?? '' +} + +/** + * Reads the optional `publishedAfter` cutoff from sourceConfig as a timestamp (ms), + * or null when unset/invalid. + */ +function getPublishedAfter(sourceConfig: Record): number | null { + const raw = (sourceConfig.publishedAfter as string | undefined)?.trim() + if (!raw) return null + const ms = new Date(raw).getTime() + return Number.isNaN(ms) ? null : ms +} + +/** + * Builds a metadata-only stub from a playlist item. + * + * Duration/tags/category are not available on `playlistItems.list` — they are populated + * during hydration in `getDocument` via `videos.list`. The content hash uses the video's + * publish time only, so it is identical between this stub and the hydrated document. + */ +function itemToStub(item: PlaylistItem): ExternalDocument | null { + const videoId = getVideoId(item) + if (!videoId) return null + + const snippet = item.snippet ?? {} + const videoPublishedAt = item.contentDetails?.videoPublishedAt ?? '' + const channelTitle = snippet.videoOwnerChannelTitle ?? snippet.channelTitle ?? '' + + return { + externalId: videoId, + title: snippet.title || 'Untitled', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: `https://www.youtube.com/watch?v=${videoId}`, + contentHash: buildContentHash(videoId, videoPublishedAt), + metadata: { + channelTitle, + publishedAt: videoPublishedAt, + }, + } +} + +/** + * Batch-fetches full video resources for the given IDs via `videos.list`. + * + * `videos.list` accepts up to 50 comma-separated IDs and costs a flat 1 quota unit per + * call regardless of ID count, so a single call covers a full `playlistItems.list` page. + * Videos that are private, deleted, or region-blocked are simply absent from the response. + */ +async function fetchVideosByIds( + apiKey: string, + videoIds: string[], + retryOptions?: Parameters[2] +): Promise> { + const result = new Map() + if (videoIds.length === 0) return result + + const url = `${YOUTUBE_API_BASE}/videos?part=snippet,contentDetails,status&id=${encodeURIComponent( + videoIds.join(',') + )}&key=${encodeURIComponent(apiKey)}` + + const response = await fetchWithRetry( + url, + { method: 'GET', headers: { Accept: 'application/json' } }, + retryOptions + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to batch-fetch YouTube videos', { + count: videoIds.length, + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to batch-fetch YouTube videos: ${response.status}`) + } + + const data = await response.json() + const items = (data.items ?? []) as VideoItem[] + for (const item of items) { + if (item.id) result.set(item.id, item) + } + return result +} + +/** + * Builds the full document for a video, combining title and description as plain-text + * content. Returns null for unlisted/private/deleted videos, and (when configured) for + * Shorts shorter than 60 seconds. + * + * Captions/transcripts are intentionally not fetched: `captions.download` requires OAuth + * as the video owner, which the API-key auth surface cannot provide. Content is therefore + * the video title plus description only. + */ +function videoToDocument(video: VideoItem, excludeShorts: boolean): ExternalDocument | null { + const videoId = video.id + if (!videoId) return null + + const privacyStatus = video.status?.privacyStatus + if (privacyStatus && privacyStatus !== 'public' && privacyStatus !== 'unlisted') { + return null + } + + if (excludeShorts) { + const seconds = parseIso8601Duration(video.contentDetails?.duration) + if (seconds != null && seconds > 0 && seconds < SHORTS_MAX_DURATION_SECONDS) { + return null + } + } + + const snippet = video.snippet ?? {} + const title = snippet.title || 'Untitled' + const description = snippet.description ?? '' + const publishedAt = snippet.publishedAt ?? '' + const content = description.trim() ? `${title}\n\n${description}` : title + const tags = Array.isArray(snippet.tags) ? snippet.tags : [] + + return { + externalId: videoId, + title, + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: `https://www.youtube.com/watch?v=${videoId}`, + contentHash: buildContentHash(videoId, publishedAt), + metadata: { + channelTitle: snippet.channelTitle ?? '', + publishedAt, + duration: video.contentDetails?.duration ?? '', + categoryId: snippet.categoryId ?? '', + tags, + }, + } +} + +export const youtubeConnector: ConnectorConfig = { + id: 'youtube', + name: 'YouTube', + description: 'Sync videos from a YouTube channel or playlist into your knowledge base', + version: '1.0.0', + icon: YouTubeIcon, + + auth: { + mode: 'apiKey', + label: 'YouTube Data API Key', + placeholder: 'Enter your YouTube Data API v3 key', + }, + + configFields: [ + { + id: 'channelId', + title: 'Channel', + type: 'short-input', + placeholder: 'e.g. @mkbhd or UCXXXXXXXXXXXXXXXXXXXXXX', + required: false, + description: + 'Channel handle (@name), channel ID (starts with "UC"), or legacy username. Syncs the channel\'s uploaded videos.', + }, + { + id: 'playlistId', + title: 'Playlist ID', + type: 'short-input', + placeholder: 'e.g. PLXXXXXXXXXXXXXXXX', + required: false, + description: 'Playlist ID. Takes precedence over Channel when both are set.', + }, + { + id: 'publishedAfter', + title: 'Published After', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'e.g. 2024-01-01', + description: + 'Only sync videos published on or after this date (ISO 8601, e.g. 2024-01-01). Applies to the video publish date.', + }, + { + id: 'excludeShorts', + title: 'Exclude Shorts', + type: 'dropdown', + required: false, + mode: 'advanced', + options: [ + { label: 'Include Shorts', id: 'false' }, + { label: 'Exclude Shorts (< 60s)', id: 'true' }, + ], + description: 'Skip videos shorter than 60 seconds (Shorts).', + }, + { + id: 'maxVideos', + title: 'Max Videos', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const apiKey = getApiKey(accessToken) + + const maxVideos = sourceConfig.maxVideos ? Number(sourceConfig.maxVideos) : 0 + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + + if (maxVideos > 0 && previouslyFetched >= maxVideos) { + return { documents: [], hasMore: false } + } + + const cachedPlaylistId = syncContext?.resolvedPlaylistId as string | undefined + let playlistId: string | null = cachedPlaylistId ?? null + let isUploadsPlaylist = (syncContext?.isUploadsPlaylist as boolean | undefined) ?? false + + if (!playlistId) { + const resolved = await resolvePlaylistId(apiKey, sourceConfig) + playlistId = resolved.playlistId + isUploadsPlaylist = resolved.isUploadsPlaylist + if (syncContext) { + if (playlistId) syncContext.resolvedPlaylistId = playlistId + syncContext.isUploadsPlaylist = isUploadsPlaylist + } + } + + if (!playlistId) { + throw new Error('No playlistId or channelId configured, or channel has no uploads playlist') + } + + const publishedAfter = getPublishedAfter(sourceConfig) + + const remaining = maxVideos > 0 ? maxVideos - previouslyFetched : 0 + const effectivePageSize = maxVideos > 0 ? Math.min(PAGE_SIZE, remaining) : PAGE_SIZE + + const queryParams = new URLSearchParams({ + part: 'snippet,contentDetails', + playlistId, + maxResults: String(effectivePageSize), + key: apiKey, + }) + if (cursor) queryParams.set('pageToken', cursor) + + const url = `${YOUTUBE_API_BASE}/playlistItems?${queryParams.toString()}` + + logger.info('Listing YouTube playlist items', { playlistId, cursor: cursor ?? 'initial' }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list YouTube playlist items', { + playlistId, + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to list YouTube playlist items: ${response.status}`) + } + + const data = await response.json() + const items = (data.items ?? []) as PlaylistItem[] + const excludeShorts = String(sourceConfig.excludeShorts ?? '') === 'true' + + const keptItems: PlaylistItem[] = [] + let stopEarly = false + + for (const item of items) { + if (!getVideoId(item)) continue + + if (publishedAfter != null) { + const videoPublishedAt = item.contentDetails?.videoPublishedAt + const ms = videoPublishedAt ? new Date(videoPublishedAt).getTime() : Number.NaN + if (!Number.isNaN(ms) && ms < publishedAfter) { + // Uploads playlists are reverse-chronological by publish date, so once we + // cross the cutoff no later item can qualify — stop paginating. For arbitrary + // playlists we only filter per-item (order is not guaranteed). + if (isUploadsPlaylist) { + stopEarly = true + break + } + continue + } + } + + keptItems.push(item) + } + + let documents: ExternalDocument[] = [] + + if (excludeShorts && keptItems.length > 0) { + // When excluding Shorts we must know each video's duration, which is not exposed on + // `playlistItems.list`. Resolve it here with a single batched `videos.list` call + // (1 quota unit per page) and emit FULLY-HYDRATED documents. This is deliberate: + // emitting deferred stubs for Shorts would make every excluded Short re-list as a + // brand-new doc on every sync (it is never persisted), re-hydrating to null forever. + // Filtering at listing time bounds the cost to one batched call per page per sync. + const videoMap = await fetchVideosByIds(apiKey, keptItems.map(getVideoId)) + for (const item of keptItems) { + const video = videoMap.get(getVideoId(item)) + // Absent from `videos.list` => private/deleted/region-blocked. Drop it instead of + // emitting a stub that would re-hydrate to null on every sync. + if (!video) continue + const doc = videoToDocument(video, true) + if (doc) documents.push(doc) + } + } else { + for (const item of keptItems) { + const stub = itemToStub(item) + if (stub) documents.push(stub) + } + } + + const totalFetched = previouslyFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + + const hitMax = maxVideos > 0 && totalFetched >= maxVideos + if (hitMax && maxVideos > 0) { + const overflow = totalFetched - maxVideos + if (overflow > 0) documents = documents.slice(0, documents.length - overflow) + if (syncContext) syncContext.totalDocsFetched = maxVideos + } + + const nextPageToken = data.nextPageToken as string | undefined + + // When the `maxVideos` cap stops the listing before the source is exhausted, mark the + // listing as capped so the sync engine does not delete still-present-but-unlisted + // videos from the knowledge base. `stopEarly` (publishedAfter cutoff) is NOT a cap — + // every remaining video is older than the cutoff and intentionally out of scope, so + // those should reconcile (delete) normally. + if (hitMax && Boolean(nextPageToken) && syncContext) { + syncContext.listingCapped = true + } + + const hasMore = !hitMax && !stopEarly && Boolean(nextPageToken) + + return { + documents, + nextCursor: hasMore ? nextPageToken : undefined, + hasMore, + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + const apiKey = getApiKey(accessToken) + const excludeShorts = String(sourceConfig.excludeShorts ?? '') === 'true' + + const url = `${YOUTUBE_API_BASE}/videos?part=snippet,contentDetails,status&id=${encodeURIComponent(externalId)}&key=${encodeURIComponent(apiKey)}` + + try { + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + if (response.status === 403 || response.status === 404) return null + throw new Error(`Failed to get YouTube video: ${response.status}`) + } + + const data = await response.json() + const items = (data.items ?? []) as VideoItem[] + const video = items[0] + + // An empty items array means the video is deleted, private, or region-blocked. + if (!video) return null + + return videoToDocument(video, excludeShorts) + } catch (error) { + logger.warn(`Failed to fetch YouTube video ${externalId}`, { error: toError(error).message }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const apiKey = getApiKey(accessToken) + if (!apiKey) { + return { valid: false, error: 'A YouTube Data API key is required' } + } + + const channelId = (sourceConfig.channelId as string | undefined)?.trim() + const playlistId = (sourceConfig.playlistId as string | undefined)?.trim() + if (!channelId && !playlistId) { + return { valid: false, error: 'Provide a channel or a playlistId' } + } + + const maxVideos = sourceConfig.maxVideos as string | undefined + if (maxVideos && (Number.isNaN(Number(maxVideos)) || Number(maxVideos) <= 0)) { + return { valid: false, error: 'Max videos must be a positive number' } + } + + const publishedAfterRaw = (sourceConfig.publishedAfter as string | undefined)?.trim() + if (publishedAfterRaw && Number.isNaN(new Date(publishedAfterRaw).getTime())) { + return { valid: false, error: 'Published After must be a valid date (e.g. 2024-01-01)' } + } + + try { + const resolvedPlaylistId = playlistId + ? playlistId + : await resolveUploadsPlaylistId(apiKey, channelId as string, VALIDATE_RETRY_OPTIONS) + + if (!resolvedPlaylistId) { + return { valid: false, error: 'Channel not found or has no uploaded videos' } + } + + const url = `${YOUTUBE_API_BASE}/playlistItems?part=id&maxResults=1&playlistId=${encodeURIComponent(resolvedPlaylistId)}&key=${encodeURIComponent(apiKey)}` + const response = await fetchWithRetry( + url, + { method: 'GET', headers: { Accept: 'application/json' } }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 403) { + return { + valid: false, + error: + 'API key rejected. Check that the key is valid, has no HTTP referrer/IP restrictions (server-side use requires an unrestricted or IP-allowed key), and that your daily quota is not exhausted.', + } + } + if (response.status === 404) { + return { valid: false, error: 'Playlist not found. Check the playlist or channel ID.' } + } + return { valid: false, error: `Failed to access YouTube: ${response.status}` } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + tagDefinitions: [ + { id: 'channelTitle', displayName: 'Channel', fieldType: 'text' }, + { id: 'publishedAt', displayName: 'Published Date', fieldType: 'date' }, + { id: 'duration', displayName: 'Duration', fieldType: 'text' }, + { id: 'tags', displayName: 'Tags', fieldType: 'text' }, + ], + + /** + * Maps document metadata to tag slots. `duration` and `tags` are only present after + * hydration in `getDocument`; on the listing stub they are absent and simply skipped + * by the guards below. The sync engine only runs `mapTags` on add/update (after + * hydration), so durations/tags are populated when tags are actually written. + */ + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.channelTitle === 'string' && metadata.channelTitle.trim()) { + result.channelTitle = metadata.channelTitle + } + + const publishedAt = parseTagDate(metadata.publishedAt) + if (publishedAt) result.publishedAt = publishedAt + + if (typeof metadata.duration === 'string' && metadata.duration.trim()) { + result.duration = metadata.duration + } + + const tags = joinTagArray(metadata.tags) + if (tags) result.tags = tags + + return result + }, +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 13ab83fda01..485444fc143 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -29,6 +29,7 @@ import { batchUpdateTableRowsContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, + cancelTableImportContract, cancelTableRunsContract, createTableContract, createTableRowContract, @@ -39,6 +40,8 @@ import { deleteWorkflowGroupContract, getTableContract, type InsertTableRowBodyInput, + importIntoTableAsyncContract, + importTableAsyncContract, listActiveDispatchesContract, listTableRowsContract, listTablesContract, @@ -78,6 +81,7 @@ import { isExecInFlight, optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' +import { runUploadStrategy } from '@/lib/uploads/client/direct-upload' const logger = createLogger('TableQueries') @@ -178,7 +182,15 @@ function invalidateTableSchema(queryClient: ReturnType, t /** * Fetch all tables for a workspace. */ -export function useTablesList(workspaceId?: string, scope: TableQueryScope = 'active') { +export function useTablesList( + workspaceId?: string, + scope: TableQueryScope = 'active', + options?: { + /** Poll cadence, or a predicate over the current list that returns a cadence (or `false`). */ + refetchInterval?: number | false | ((tables: TableDefinition[] | undefined) => number | false) + } +) { + const refetchInterval = options?.refetchInterval return useQuery({ queryKey: tableKeys.list(workspaceId, scope), queryFn: async ({ signal }) => { @@ -193,6 +205,10 @@ export function useTablesList(workspaceId?: string, scope: TableQueryScope = 'ac enabled: Boolean(workspaceId), staleTime: 30 * 1000, placeholderData: keepPreviousData, + refetchInterval: + typeof refetchInterval === 'function' + ? (query) => refetchInterval(query.state.data) + : (refetchInterval ?? false), }) } @@ -496,7 +512,13 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) ) => { return requestJson(createTableRowContract, { params: { tableId }, - body: { workspaceId, data: variables.data as RowData, position: variables.position }, + body: { + workspaceId, + data: variables.data as RowData, + position: variables.position, + afterRowId: variables.afterRowId, + beforeRowId: variables.beforeRowId, + }, }) }, onSuccess: (response) => { @@ -576,35 +598,47 @@ function reconcileCreatedRow( if (!old) return old if (old.pages.some((p) => p.rows.some((r) => r.id === row.id))) return old - const pages = old.pages.map((page) => - page.rows.some((r) => r.position >= row.position) - ? { - ...page, - rows: page.rows.map((r) => - r.position >= row.position ? { ...r, position: r.position + 1 } : r - ), - } - : page - ) + // Use key-ordering only when the new row AND every cached row have an + // `orderKey` — then no neighbor bump is needed and order is exact. If any + // cached row is un-keyed (mid-backfill), fall back to the legacy `position` + // path so un-keyed rows aren't yanked to the front by an empty-string sort. + const byKey = + row.orderKey != null && old.pages.every((p) => p.rows.every((r) => r.orderKey != null)) + const sortRows = (rows: TableRow[]) => + byKey + ? [...rows].sort((a, b) => (a.orderKey as string).localeCompare(b.orderKey as string)) + : [...rows].sort((a, b) => a.position - b.position) + const fitsAfter = (last: TableRow | undefined) => + last === undefined || + (byKey + ? (last.orderKey as string) >= (row.orderKey as string) + : last.position >= row.position) + + const pages = byKey + ? old.pages + : old.pages.map((page) => + page.rows.some((r) => r.position >= row.position) + ? { + ...page, + rows: page.rows.map((r) => + r.position >= row.position ? { ...r, position: r.position + 1 } : r + ), + } + : page + ) let inserted = false const nextPages = pages.map((page) => { if (inserted) return page - const last = page.rows[page.rows.length - 1] - const fits = last === undefined || last.position >= row.position - if (!fits) return page + if (!fitsAfter(page.rows[page.rows.length - 1])) return page inserted = true - const merged = [...page.rows, row].sort((a, b) => a.position - b.position) - return { ...page, rows: merged } + return { ...page, rows: sortRows([...page.rows, row]) } }) if (!inserted && nextPages.length > 0) { const lastIdx = nextPages.length - 1 const lastPage = nextPages[lastIdx] - nextPages[lastIdx] = { - ...lastPage, - rows: [...lastPage.rows, row].sort((a, b) => a.position - b.position), - } + nextPages[lastIdx] = { ...lastPage, rows: sortRows([...lastPage.rows, row]) } } const firstPage = nextPages[0] @@ -639,6 +673,7 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon workspaceId, rows: variables.rows as RowData[], positions: variables.positions, + orderKeys: variables.orderKeys, }, }) }, @@ -1087,9 +1122,11 @@ export function useUploadCsvToTable() { return useMutation({ mutationFn: async ({ workspaceId, file }: UploadCsvParams) => { + // Text fields must precede the file part: the server parses the body as a + // stream and needs workspaceId before it reaches the (large) file. const formData = new FormData() - formData.append('file', file) formData.append('workspaceId', workspaceId) + formData.append('file', file) // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies const response = await fetch('/api/table/import-csv', { @@ -1114,8 +1151,102 @@ export function useUploadCsvToTable() { }) } +interface ImportCsvAsyncParams { + workspaceId: string + file: File + onProgress?: (percent: number) => void +} + +/** + * Uploads a CSV/TSV straight to workspace storage (bypassing the server's request-body + * cap) and returns its storage key. Shared by the async-import kickoff hooks. + */ +async function uploadCsvToWorkspaceStorage( + file: File, + workspaceId: string, + onProgress?: (percent: number) => void +): Promise { + const upload = await runUploadStrategy({ + file, + workspaceId, + context: 'workspace', + presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, + onProgress: onProgress ? (event) => onProgress(event.percent) : undefined, + }) + return upload.key +} + +/** + * Uploads a large CSV/TSV straight to storage, then kicks off a background import into a + * new table. Resolves with `{ tableId, importId }` immediately — load progress and the + * terminal state arrive over the table-events SSE stream (see `useTableEventStream`). + */ +export function useImportCsvAsync() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ workspaceId, file, onProgress }: ImportCsvAsyncParams) => { + const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) + const response = await requestJson(importTableAsyncContract, { + body: { workspaceId, fileKey, fileName: file.name }, + }) + return response.data + }, + onError: (error) => { + logger.error('Failed to start async CSV import:', error) + toast.error(error.message, { duration: 5000 }) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + }, + }) +} + export type CsvImportMode = 'append' | 'replace' +interface ImportCsvIntoTableAsyncParams { + workspaceId: string + tableId: string + file: File + mode: CsvImportMode + mapping?: CsvHeaderMapping + createColumns?: string[] + onProgress?: (percent: number) => void +} + +/** + * Async append/replace import into an existing table for large files: uploads straight to + * storage (bypassing the server's request-body cap), then kicks off the background worker. + * Resolves immediately; progress + completion arrive over the table-events SSE stream. + */ +export function useImportCsvIntoTableAsync() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + tableId, + file, + mode, + mapping, + createColumns, + onProgress, + }: ImportCsvIntoTableAsyncParams) => { + const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) + const response = await requestJson(importIntoTableAsyncContract, { + params: { tableId }, + body: { workspaceId, fileKey, fileName: file.name, mode, mapping, createColumns }, + }) + return response.data + }, + onError: (error) => { + logger.error('Failed to start async CSV import:', error) + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, variables) => { + invalidateRowCount(queryClient, variables.tableId) + }, + }) +} + interface ImportCsvIntoTableParams { workspaceId: string tableId: string @@ -1157,8 +1288,9 @@ export function useImportCsvIntoTable() { mapping, createColumns, }: ImportCsvIntoTableParams): Promise => { + // Text fields must precede the file part: the server parses the body as a + // stream and needs these fields before it reaches the (large) file. const formData = new FormData() - formData.append('file', file) formData.append('workspaceId', workspaceId) formData.append('mode', mode) if (mapping) { @@ -1167,6 +1299,7 @@ export function useImportCsvIntoTable() { if (createColumns && createColumns.length > 0) { formData.append('createColumns', JSON.stringify(createColumns)) } + formData.append('file', file) // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies const response = await fetch(`/api/table/${tableId}/import`, { @@ -1195,6 +1328,21 @@ export function useImportCsvIntoTable() { * Downloads the full contents of a table to the user's device by streaming * `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON. */ +/** + * Cancels an in-flight async import. Plain function (not a hook) because the import dropdown lists + * multiple tables and cancels a chosen one by id rather than binding to a single table. + */ +export async function cancelTableImport( + workspaceId: string, + tableId: string, + importId: string +): Promise { + await requestJson(cancelTableImportContract, { + params: { tableId }, + body: { workspaceId, importId }, + }) +} + export async function downloadTableExport( tableId: string, fileName: string, @@ -1563,6 +1711,7 @@ interface UpdateWorkflowGroupVariables { newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns'] mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates'] inputMappings?: UpdateWorkflowGroupBodyInput['inputMappings'] + deploymentMode?: UpdateWorkflowGroupBodyInput['deploymentMode'] type?: UpdateWorkflowGroupBodyInput['type'] autoRun?: boolean } diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 289fcc01c70..6009d9f8eac 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -5,7 +5,6 @@ import { useAddTableColumn, useBatchCreateTableRows, useBatchUpdateTableRows, - useCreateTableRow, useDeleteColumn, useDeleteTableRow, useDeleteTableRows, @@ -56,7 +55,6 @@ export function useTableUndo({ const canRedo = useTableUndoStore((s) => (s.stacks[tableId]?.redo.length ?? 0) > 0) const updateRowMutation = useUpdateTableRow({ workspaceId, tableId }) - const createRowMutation = useCreateTableRow({ workspaceId, tableId }) const batchCreateRowsMutation = useBatchCreateTableRows({ workspaceId, tableId }) const batchUpdateRowsMutation = useBatchUpdateTableRows({ workspaceId, tableId }) const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId }) @@ -137,11 +135,18 @@ export function useTableUndo({ if (direction === 'undo') { deleteRowMutation.mutate(action.rowId) } else { - createRowMutation.mutate( - { data: action.data ?? {}, position: action.position }, + // Redo via the batch path so the saved orderKey restores exact placement. + // The single-insert API has no orderKey field, and under the fractional-ordering + // flag its `position` is read as a rank — a gappy saved position misplaces. + batchCreateRowsMutation.mutate( + { + rows: [action.data ?? {}], + positions: [action.position], + orderKeys: action.orderKey ? [action.orderKey] : undefined, + }, { onSuccess: (response) => { - const newRowId = extractCreatedRowId(response as Record) + const newRowId = response?.data?.rows?.[0]?.id if (newRowId && newRowId !== action.rowId) { patchUndoRowId(tableId, action.rowId, newRowId) } @@ -165,6 +170,9 @@ export function useTableUndo({ { rows: action.rows.map((r) => r.data), positions: action.rows.map((r) => r.position), + orderKeys: action.rows.every((r) => r.orderKey) + ? action.rows.map((r) => r.orderKey as string) + : undefined, }, { onSuccess: (response) => { @@ -187,6 +195,9 @@ export function useTableUndo({ { rows: action.rows.map((row) => row.data), positions: action.rows.map((row) => row.position), + orderKeys: action.rows.every((row) => row.orderKey) + ? action.rows.map((row) => row.orderKey as string) + : undefined, }, { onSuccess: (response) => { diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index 68ef65d0e6d..e52eb884a9e 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -2,6 +2,7 @@ // prefix (`sim-mothership:` / `go-mothership:`) to separate the two // halves of a mothership trace in the OTLP backend. +import { hostname } from 'node:os' import type { Attributes, Context, Link, SpanKind } from '@opentelemetry/api' import { DiagConsoleLogger, DiagLogLevel, diag, TraceFlags, trace } from '@opentelemetry/api' import type { @@ -63,6 +64,25 @@ function normalizeOtlpTracesUrl(url: string): string { } } +// Metrics counterpart to `normalizeOtlpTracesUrl`. Operates on the parsed +// pathname (not a raw string suffix) so query strings and trailing slashes +// don't corrupt the result: swap a `/v1/traces` suffix for `/v1/metrics`, +// otherwise append `/v1/metrics`. +function normalizeOtlpMetricsUrl(url: string): string { + if (!url) return url + try { + const u = new URL(url) + const path = u.pathname.replace(/\/$/, '') + if (path.endsWith('/v1/metrics')) return url + u.pathname = path.endsWith('/v1/traces') + ? path.replace(/\/v1\/traces$/, '/v1/metrics') + : `${path}/v1/metrics` + return u.toString() + } catch { + return url + } +} + // Sampling ratio from env (mirrors Go's `samplerFromEnv`); fallback // is 100% everywhere. Retention caps cost, not sampling. function resolveSamplingRatio(_isLocalEndpoint: boolean): number { @@ -144,6 +164,8 @@ async function initializeOpenTelemetry() { '@opentelemetry/semantic-conventions/incubating' ) const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http') + const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http') + const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics') const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node') const { TraceIdRatioBasedSampler, SamplingDecision } = await import( '@opentelemetry/sdk-trace-base' @@ -226,10 +248,24 @@ async function initializeOpenTelemetry() { exportTimeoutMillis: telemetryConfig.batchSettings.exportTimeoutMillis, }) - // Unique instance id per origin keeps Jaeger's clock-skew adjuster - // from grouping Sim+Go spans together (they'd see multi-second - // drift as intra-service and emit spurious warnings). - const serviceInstanceId = `${telemetryConfig.serviceName}-${SERVICE_INSTANCE_SLUG}` + // Metrics (hosted-key counters/histograms) share the trace endpoint and + // headers — only the signal path differs. Unlike spans these aren't sampled. + const metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ + url: normalizeOtlpMetricsUrl(telemetryConfig.endpoint), + headers: otlpHeaders, + timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), + keepAlive: false, + }), + exportIntervalMillis: 60000, + }) + + // Must be unique per process: replicas sharing one instance id collapse + // into a single Prometheus series, so their independent cumulative + // counters interleave and corrupt rate()/increase(). The slug keeps Sim + // distinct from Go for Jaeger's clock-skew grouping; the hostname (the + // container id under ECS) makes each replica its own series. + const serviceInstanceId = `${telemetryConfig.serviceName}-${SERVICE_INSTANCE_SLUG}-${hostname()}` const resource = defaultResource().merge( resourceFromAttributes({ [ATTR_SERVICE_NAME]: telemetryConfig.serviceName, @@ -268,6 +304,7 @@ async function initializeOpenTelemetry() { resource, spanProcessors, sampler, + metricReader, }) sdk.start() diff --git a/apps/sim/lib/api-key/auth.test.ts b/apps/sim/lib/api-key/auth.test.ts index ac4bb4089f0..6a67b144f37 100644 --- a/apps/sim/lib/api-key/auth.test.ts +++ b/apps/sim/lib/api-key/auth.test.ts @@ -10,12 +10,7 @@ */ import { randomBytes } from 'crypto' -import { - createEncryptedApiKey, - createLegacyApiKey, - expectApiKeyInvalid, - expectApiKeyValid, -} from '@sim/testing' +import { createEncryptedApiKey, createLegacyApiKey } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' const cryptoMock = vi.hoisted(() => ({ @@ -40,7 +35,6 @@ const cryptoMock = vi.hoisted(() => ({ vi.mock('@/lib/api-key/crypto', () => cryptoMock) import { - authenticateApiKey, formatApiKeyForDisplay, getApiKeyLast4, isEncryptedKey, @@ -113,110 +107,6 @@ describe('isLegacyApiKeyFormat', () => { }) }) -describe('authenticateApiKey', () => { - describe('encrypted format key (sk-sim-) against encrypted storage', () => { - it('should authenticate matching encrypted key', async () => { - const plainKey = 'sk-sim-test-key-123' - const encryptedStorage = `mock-iv:${Buffer.from(plainKey).toString('hex')}:mock-tag` - - const result = await authenticateApiKey(plainKey, encryptedStorage) - expectApiKeyValid(result) - }) - - it('should reject non-matching encrypted key', async () => { - const inputKey = 'sk-sim-test-key-123' - const differentKey = 'sk-sim-different-key' - const encryptedStorage = `mock-iv:${Buffer.from(differentKey).toString('hex')}:mock-tag` - - const result = await authenticateApiKey(inputKey, encryptedStorage) - expectApiKeyInvalid(result) - }) - - it('should reject encrypted format key against plain text storage', async () => { - const inputKey = 'sk-sim-test-key-123' - const plainStorage = inputKey // Same key but stored as plain text - - const result = await authenticateApiKey(inputKey, plainStorage) - expectApiKeyInvalid(result) - }) - }) - - describe('legacy format key (sim_) against storage', () => { - it('should authenticate legacy key against encrypted storage', async () => { - const plainKey = 'sim_legacy-test-key' - const encryptedStorage = `mock-iv:${Buffer.from(plainKey).toString('hex')}:mock-tag` - - const result = await authenticateApiKey(plainKey, encryptedStorage) - expectApiKeyValid(result) - }) - - it('should authenticate legacy key against plain text storage', async () => { - const plainKey = 'sim_legacy-test-key' - const plainStorage = plainKey - - const result = await authenticateApiKey(plainKey, plainStorage) - expectApiKeyValid(result) - }) - - it('should reject non-matching legacy key', async () => { - const inputKey = 'sim_test-key' - const storedKey = 'sim_different-key' - - const result = await authenticateApiKey(inputKey, storedKey) - expectApiKeyInvalid(result) - }) - }) - - describe('unrecognized format keys', () => { - it('should authenticate unrecognized key against plain text match', async () => { - const plainKey = 'custom-api-key-format' - const plainStorage = plainKey - - const result = await authenticateApiKey(plainKey, plainStorage) - expectApiKeyValid(result) - }) - - it('should authenticate unrecognized key against encrypted storage', async () => { - const plainKey = 'custom-api-key-format' - const encryptedStorage = `mock-iv:${Buffer.from(plainKey).toString('hex')}:mock-tag` - - const result = await authenticateApiKey(plainKey, encryptedStorage) - expectApiKeyValid(result) - }) - - it('should reject non-matching unrecognized key', async () => { - const inputKey = 'custom-key-1' - const storedKey = 'custom-key-2' - - const result = await authenticateApiKey(inputKey, storedKey) - expectApiKeyInvalid(result) - }) - }) - - describe('edge cases', () => { - it('should reject empty input key', async () => { - const result = await authenticateApiKey('', 'sim_stored-key') - expectApiKeyInvalid(result) - }) - - it('should reject empty stored key', async () => { - const result = await authenticateApiKey('sim_input-key', '') - expectApiKeyInvalid(result) - }) - - it('should handle keys with special characters', async () => { - const specialKey = 'sim_key-with-special+chars/and=more' - const result = await authenticateApiKey(specialKey, specialKey) - expectApiKeyValid(result) - }) - - it('should be case-sensitive', async () => { - const result = await authenticateApiKey('sim_TestKey', 'sim_testkey') - expectApiKeyInvalid(result) - }) - }) -}) - describe('isValidApiKeyFormat', () => { it('should accept valid length keys', () => { expect(isValidApiKeyFormat(`sim_${'a'.repeat(20)}`)).toBe(true) @@ -330,58 +220,3 @@ describe('generateEncryptedApiKey', () => { expect(key.length).toBeLessThan(100) }) }) - -describe('API key lifecycle', () => { - it('should authenticate newly generated legacy key against itself (plain storage)', async () => { - const key = generateApiKey() - const result = await authenticateApiKey(key, key) - expectApiKeyValid(result) - }) - - it('should authenticate newly generated encrypted key against encrypted storage', async () => { - const key = generateEncryptedApiKey() - const encryptedStorage = `mock-iv:${Buffer.from(key).toString('hex')}:mock-tag` - const result = await authenticateApiKey(key, encryptedStorage) - expectApiKeyValid(result) - }) - - it('should reject key if storage is tampered', async () => { - const key = generateApiKey() - const lastChar = key.slice(-1) - // Ensure tampered character is different from original (handles edge case where key ends in 'X') - const tamperedChar = lastChar === 'X' ? 'Y' : 'X' - const tamperedStorage = `${key.slice(0, -1)}${tamperedChar}` - const result = await authenticateApiKey(key, tamperedStorage) - expectApiKeyInvalid(result) - }) -}) - -describe('security considerations', () => { - it('should not accept partial key matches', async () => { - const fullKey = 'sim_abcdefghijklmnop' - const partialKey = 'sim_abcdefgh' - const result = await authenticateApiKey(partialKey, fullKey) - expectApiKeyInvalid(result) - }) - - it('should not accept keys with extra characters', async () => { - const storedKey = 'sim_abcdefgh' - const extendedKey = 'sim_abcdefghXXX' - const result = await authenticateApiKey(extendedKey, storedKey) - expectApiKeyInvalid(result) - }) - - it('should not accept key with whitespace variations', async () => { - const key = 'sim_testkey' - const keyWithSpace = ' sim_testkey' - const result = await authenticateApiKey(keyWithSpace, key) - expectApiKeyInvalid(result) - }) - - it('should not accept key with trailing whitespace', async () => { - const key = 'sim_testkey' - const keyWithTrailing = 'sim_testkey ' - const result = await authenticateApiKey(keyWithTrailing, key) - expectApiKeyInvalid(result) - }) -}) diff --git a/apps/sim/lib/api-key/auth.ts b/apps/sim/lib/api-key/auth.ts index 76c2cad229e..5a8e44ddcea 100644 --- a/apps/sim/lib/api-key/auth.ts +++ b/apps/sim/lib/api-key/auth.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { safeCompare } from '@sim/security/compare' import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { @@ -32,61 +31,6 @@ export function isEncryptedKey(storedKey: string): boolean { return storedKey.includes(':') && storedKey.split(':').length === 3 } -/** - * Authenticates an API key against a stored key, supporting both legacy and new encrypted formats - * @param inputKey - The API key provided by the client - * @param storedKey - The key stored in the database (may be plain text or encrypted) - * @returns Promise - true if the key is valid - */ -export async function authenticateApiKey(inputKey: string, storedKey: string): Promise { - try { - // If input key has new encrypted prefix (sk-sim-), only check against encrypted storage - if (isEncryptedApiKeyFormat(inputKey)) { - if (isEncryptedKey(storedKey)) { - try { - const { decrypted } = await decryptApiKey(storedKey) - return safeCompare(inputKey, decrypted) - } catch (decryptError) { - logger.error('Failed to decrypt stored API key:', { error: decryptError }) - return false - } - } - // New format keys should never match against plain text storage - return false - } - - // If input key has legacy prefix (sim_), check both encrypted and plain text - if (isLegacyApiKeyFormat(inputKey)) { - if (isEncryptedKey(storedKey)) { - try { - const { decrypted } = await decryptApiKey(storedKey) - return safeCompare(inputKey, decrypted) - } catch (decryptError) { - logger.error('Failed to decrypt stored API key:', { error: decryptError }) - // Fall through to plain text comparison if decryption fails - } - } - // Legacy format can match against plain text storage - return safeCompare(inputKey, storedKey) - } - - // If no recognized prefix, fall back to original behavior - if (isEncryptedKey(storedKey)) { - try { - const { decrypted } = await decryptApiKey(storedKey) - return safeCompare(inputKey, decrypted) - } catch (decryptError) { - logger.error('Failed to decrypt stored API key:', { error: decryptError }) - } - } - - return safeCompare(inputKey, storedKey) - } catch (error) { - logger.error('API key authentication error:', { error }) - return false - } -} - /** * Encrypts an API key for secure storage * @param apiKey - The plain text API key to encrypt diff --git a/apps/sim/lib/api-key/service.test.ts b/apps/sim/lib/api-key/service.test.ts index c38b4a7d550..0fab5400d1e 100644 --- a/apps/sim/lib/api-key/service.test.ts +++ b/apps/sim/lib/api-key/service.test.ts @@ -1,10 +1,9 @@ /** * Tests for authenticateApiKeyFromHeader. * - * The path was rewritten to look up rows by the SHA-256 hash of the incoming - * API key. A fallback loop — full scan + decrypt — is preserved while the - * `key_hash` backfill runs, and emits a warn log whenever it actually matches - * a row so we can tell when it's safe to delete. + * Authentication looks up a single row by the SHA-256 hash of the incoming + * API key and applies the scope / expiry / permission gates. Any miss — no + * matching hash or a failed gate — returns an invalid result. * * @vitest-environment node */ @@ -36,14 +35,6 @@ vi.mock('@sim/logger', () => ({ getRequestContext: vi.fn(() => undefined), })) -const { mockAuthenticateApiKey } = vi.hoisted(() => ({ - mockAuthenticateApiKey: vi.fn(), -})) - -vi.mock('@/lib/api-key/auth', () => ({ - authenticateApiKey: mockAuthenticateApiKey, -})) - const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({ mockGetWorkspaceBillingSettings: vi.fn(), })) @@ -63,15 +54,12 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { hashApiKey } from '@/lib/api-key/crypto' import { authenticateApiKeyFromHeader } from '@/lib/api-key/service' -const warnSpy = serviceLogger.warn - function personalKeyRecord(overrides: Partial> = {}) { return { id: 'key-1', userId: 'user-1', workspaceId: null as string | null, type: 'personal', - key: 'encrypted:stored:value', expiresAt: null as Date | null, ...overrides, } @@ -80,7 +68,6 @@ function personalKeyRecord(overrides: Partial> = {}) { describe('authenticateApiKeyFromHeader', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthenticateApiKey.mockReset() mockGetWorkspaceBillingSettings.mockReset() mockGetUserEntityPermissions.mockReset() }) @@ -91,7 +78,7 @@ describe('authenticateApiKeyFromHeader', () => { expect(dbChainMockFns.where).not.toHaveBeenCalled() }) - it('resolves on the fast path when the hash lookup finds a row', async () => { + it('resolves when the hash lookup finds a row', async () => { const record = personalKeyRecord() dbChainMockFns.where.mockResolvedValueOnce([record]) @@ -107,8 +94,6 @@ describe('authenticateApiKeyFromHeader', () => { workspaceId: undefined, }) expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) - expect(mockAuthenticateApiKey).not.toHaveBeenCalled() - expect(warnSpy).not.toHaveBeenCalled() }) it('returns invalid when the hash lookup finds a row that fails scope checks', async () => { @@ -121,63 +106,20 @@ describe('authenticateApiKeyFromHeader', () => { expect(result).toEqual({ success: false, error: 'Invalid API key' }) expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) - expect(mockAuthenticateApiKey).not.toHaveBeenCalled() - }) - - it('falls back to the decrypt loop when no row matches the hash, and warns on success', async () => { - const record = personalKeyRecord() - dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([record]) - mockAuthenticateApiKey.mockResolvedValueOnce(true) - - const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', { - userId: 'user-1', - }) - - expect(result).toEqual({ - success: true, - userId: 'user-1', - keyId: 'key-1', - keyType: 'personal', - workspaceId: undefined, - }) - expect(dbChainMockFns.where).toHaveBeenCalledTimes(2) - expect(mockAuthenticateApiKey).toHaveBeenCalledWith( - 'sk-sim-plain-key', - 'encrypted:stored:value' - ) - expect(warnSpy).toHaveBeenCalledWith('API key matched via fallback decrypt loop', { - keyId: 'key-1', - }) - }) - - it('returns invalid when the hash lookup misses and the fallback scan also misses', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([]) - - const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', { - userId: 'user-1', - }) - - expect(result).toEqual({ success: false, error: 'Invalid API key' }) - expect(dbChainMockFns.where).toHaveBeenCalledTimes(2) - expect(mockAuthenticateApiKey).not.toHaveBeenCalled() - expect(warnSpy).not.toHaveBeenCalled() }) - it('returns invalid when the hash lookup misses and every fallback candidate fails decrypt comparison', async () => { - const record = personalKeyRecord() - dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([record]) - mockAuthenticateApiKey.mockResolvedValueOnce(false) + it('returns invalid when the hash lookup finds no row', async () => { + dbChainMockFns.where.mockResolvedValueOnce([]) const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1', }) expect(result).toEqual({ success: false, error: 'Invalid API key' }) - expect(mockAuthenticateApiKey).toHaveBeenCalledTimes(1) - expect(warnSpy).not.toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) }) - it('queries by the sha256 hash of the incoming header on the fast path', async () => { + it('queries by the sha256 hash of the incoming header', async () => { dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord()]) await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' }) diff --git a/apps/sim/lib/api-key/service.ts b/apps/sim/lib/api-key/service.ts index 4c0709b9df6..b00166805d1 100644 --- a/apps/sim/lib/api-key/service.ts +++ b/apps/sim/lib/api-key/service.ts @@ -2,7 +2,6 @@ import { db } from '@sim/db' import { apiKey as apiKeyTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils' @@ -53,11 +52,9 @@ interface HashCandidate { /** * Authenticate an API key from header with flexible filtering options. * - * Tries the hash lookup first. If that misses (legacy row not yet backfilled, - * or writer missed the hash column), falls back to the original scan+decrypt - * loop. The fallback emits a warn log whenever it actually matches a row so - * we can confirm the fast path is covering 100% of traffic before deleting - * the fallback block below in a follow-up PR. + * Looks up a single row by `sha256(apiKeyHeader)` and applies the scope / + * expiry / permission gates. Any miss — no matching hash or a failed gate — + * returns `INVALID`. */ export async function authenticateApiKeyFromHeader( apiKeyHeader: string, @@ -77,180 +74,62 @@ export async function authenticateApiKeyFromHeader( } } - const hashResult = await authenticateApiKeyByHash(apiKeyHeader, options, workspaceSettings) - if (hashResult !== null) return hashResult - - // LEGACY FALLBACK — delete once `logger.warn('API key matched via fallback - // decrypt loop', ...)` count stays at zero in prod. The block below is the - // pre-hash-lookup implementation, preserved verbatim as a safety net while - // the `key_hash` backfill rolls out. - let query = db + const keyHash = hashApiKey(apiKeyHeader) + const rows: HashCandidate[] = await db .select({ id: apiKeyTable.id, userId: apiKeyTable.userId, workspaceId: apiKeyTable.workspaceId, type: apiKeyTable.type, - key: apiKeyTable.key, expiresAt: apiKeyTable.expiresAt, }) .from(apiKeyTable) + .where(eq(apiKeyTable.keyHash, keyHash)) - const conditions = [] + if (rows.length === 0) return INVALID - if (options.userId) { - conditions.push(eq(apiKeyTable.userId, options.userId)) - } + const record = rows[0] + const keyType = record.type as 'personal' | 'workspace' - if (options.keyTypes?.length) { - if (options.keyTypes.length === 1) { - conditions.push(eq(apiKeyTable.type, options.keyTypes[0])) - } - } + if (options.userId && record.userId !== options.userId) return INVALID + if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID + if (record.expiresAt && record.expiresAt < new Date()) return INVALID - if (conditions.length > 0) { - query = query.where(and(...conditions)) as any + if ( + options.workspaceId && + keyType === 'workspace' && + record.workspaceId !== options.workspaceId + ) { + return INVALID } - const keyRecords = await query - - const filteredRecords = keyRecords.filter((record) => { - const keyType = record.type as 'personal' | 'workspace' - - if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) { - return false - } - - if (options.workspaceId) { - if (keyType === 'workspace') { - return record.workspaceId === options.workspaceId - } - - if (keyType === 'personal') { - return workspaceSettings?.allowPersonalApiKeys ?? false - } - } - - return true - }) - - const permissionCache = new Map() - - for (const storedKey of filteredRecords) { - if (storedKey.expiresAt && storedKey.expiresAt < new Date()) { - continue - } - - if (options.workspaceId && (storedKey.type as 'personal' | 'workspace') === 'personal') { - if (!workspaceSettings?.allowPersonalApiKeys) { - continue - } - - if (!storedKey.userId) { - continue - } + if (options.workspaceId && keyType === 'personal') { + if (!workspaceSettings?.allowPersonalApiKeys) return INVALID + if (!record.userId) return INVALID - if (!permissionCache.has(storedKey.userId)) { - const permission = await getUserEntityPermissions( - storedKey.userId, - 'workspace', - options.workspaceId - ) - permissionCache.set(storedKey.userId, permission !== null) - } + const permission = await getUserEntityPermissions( + record.userId, + 'workspace', + options.workspaceId + ) + if (permission === null) return INVALID + } - if (!permissionCache.get(storedKey.userId)) { - continue - } - } + logger.debug('API key matched via hash lookup', { keyId: record.id, keyType }) - try { - const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key) - if (isValid) { - logger.warn('API key matched via fallback decrypt loop', { keyId: storedKey.id }) - return { - success: true, - userId: storedKey.userId, - keyId: storedKey.id, - keyType: storedKey.type as 'personal' | 'workspace', - workspaceId: storedKey.workspaceId || options.workspaceId || undefined, - } - } - } catch (error) { - logger.error('Error authenticating API key:', error) - } + return { + success: true, + userId: record.userId, + keyId: record.id, + keyType, + workspaceId: record.workspaceId || options.workspaceId || undefined, } - - return INVALID } catch (error) { logger.error('API key authentication error:', error) return { success: false, error: 'Authentication failed' } } } -/** - * Fast path: look up a single row by `sha256(apiKeyHeader)` and apply the - * scope / expiry / permission gates. Returns `null` when no row matched the - * hash (caller should fall through to the legacy scan+decrypt loop). A hash - * hit that fails a gate returns a concrete `INVALID` — the key definitely - * belongs to that row, it's just not authorized in this scope. - */ -async function authenticateApiKeyByHash( - apiKeyHeader: string, - options: ApiKeyAuthOptions, - workspaceSettings: WorkspaceBillingSettings | null -): Promise { - const keyHash = hashApiKey(apiKeyHeader) - const rows: HashCandidate[] = await db - .select({ - id: apiKeyTable.id, - userId: apiKeyTable.userId, - workspaceId: apiKeyTable.workspaceId, - type: apiKeyTable.type, - expiresAt: apiKeyTable.expiresAt, - }) - .from(apiKeyTable) - .where(eq(apiKeyTable.keyHash, keyHash)) - - if (rows.length === 0) return null - - const record = rows[0] - const keyType = record.type as 'personal' | 'workspace' - - if (options.userId && record.userId !== options.userId) return INVALID - if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID - if (record.expiresAt && record.expiresAt < new Date()) return INVALID - - if ( - options.workspaceId && - keyType === 'workspace' && - record.workspaceId !== options.workspaceId - ) { - return INVALID - } - - if (options.workspaceId && keyType === 'personal') { - if (!workspaceSettings?.allowPersonalApiKeys) return INVALID - if (!record.userId) return INVALID - - const permission = await getUserEntityPermissions( - record.userId, - 'workspace', - options.workspaceId - ) - if (permission === null) return INVALID - } - - logger.debug('API key matched via hash lookup', { keyId: record.id, keyType }) - - return { - success: true, - userId: record.userId, - keyId: record.id, - keyType, - workspaceId: record.workspaceId || options.workspaceId || undefined, - } -} - /** * Update the last used timestamp for an API key */ diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index f56c22a1222..7c73e37b938 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -147,12 +147,29 @@ export const rowDataSchema = domainObjectSchema() export const tableDefinitionSchema = domainObjectSchema() export const tableRowSchema = domainObjectSchema() -export const insertTableRowBodySchema = z.object({ +/** + * Plain-object base for the single-row insert body. Kept un-refined so callers + * (e.g. the v1 public contract) can `.omit()` fields before applying + * {@link rowAnchorMutexRefine} — Zod forbids `.omit()` on a refined schema. + */ +export const insertTableRowBodyBaseSchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), data: rowDataSchema, position: z.number().int().min(0).optional(), + /** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */ + afterRowId: z.string().min(1).optional(), + /** Fractional ordering: insert directly before this row id. Takes precedence over `position`. */ + beforeRowId: z.string().min(1).optional(), }) +/** `afterRowId` and `beforeRowId` are mutually exclusive insert anchors. */ +export const rowAnchorMutexRefine = [ + (data: { afterRowId?: string; beforeRowId?: string }) => !data.afterRowId || !data.beforeRowId, + { message: 'afterRowId and beforeRowId are mutually exclusive' }, +] as const + +export const insertTableRowBodySchema = insertTableRowBodyBaseSchema.refine(...rowAnchorMutexRefine) + /** * POST `/api/table/[tableId]/rows/upsert` body — insert-or-update keyed by a * unique column name. `conflictTarget` is optional (server picks a single @@ -175,6 +192,8 @@ export const batchInsertTableRowsBodySchema = z `Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch` ), positions: z.array(z.number().int().min(0)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(), + /** Fractional ordering: exact per-row order keys (undo restore). Takes precedence over `positions`. */ + orderKeys: z.array(z.string().min(1)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(), }) .refine((data) => !data.positions || data.positions.length === data.rows.length, { message: 'positions array length must match rows array length', @@ -182,6 +201,9 @@ export const batchInsertTableRowsBodySchema = z .refine((data) => !data.positions || new Set(data.positions).size === data.positions.length, { message: 'positions must not contain duplicates', }) + .refine((data) => !data.orderKeys || data.orderKeys.length === data.rows.length, { + message: 'orderKeys array length must match rows array length', + }) /** * POST `/api/table/[tableId]/rows` body — accepts either a batch payload @@ -348,6 +370,34 @@ export const createTableContract = defineRouteContract({ }, }) +/** + * Kickoff body for an asynchronous large-CSV import into a NEW table. The file is + * already uploaded to storage (the client sends its `fileKey`); the route creates an + * `importing` table and runs the load in the background. + */ +export const importTableAsyncBodySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + fileKey: z.string().min(1, 'fileKey is required'), + fileName: z.string().min(1, 'fileName is required'), +}) + +export type ImportTableAsyncBody = z.input + +export const importTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/table/import-async', + body: importTableAsyncBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + tableId: z.string(), + importId: z.string(), + }) + ), + }, +}) + export const getTableContract = defineRouteContract({ method: 'GET', path: '/api/table/[tableId]', @@ -565,6 +615,38 @@ export const csvExtensionSchema = z.enum(['csv', 'tsv'], { error: 'Only CSV and TSV files are supported', }) +/** + * Kickoff body for an asynchronous CSV import into an EXISTING table (append/replace). + * The file is already uploaded to storage; `mapping`/`createColumns` are the client's + * resolved column mapping (the dialog computes them from its preview). + */ +export const importIntoTableAsyncBodySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + fileKey: z.string().min(1, 'fileKey is required'), + fileName: z.string().min(1, 'fileName is required'), + mode: csvImportModeSchema, + mapping: z.record(z.string(), z.string().nullable()).optional(), + createColumns: z.array(z.string()).optional(), +}) + +export type ImportIntoTableAsyncBody = z.input + +export const importIntoTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/import-async', + params: tableIdParamsSchema, + body: importIntoTableAsyncBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + tableId: z.string(), + importId: z.string(), + }) + ), + }, +}) + /** * `createColumns` form field — a JSON-encoded array of CSV header names that * the import should auto-create as new columns on the target table. @@ -731,6 +813,11 @@ const workflowGroupDependenciesSchema = z.object({ const workflowGroupTypeSchema = z.enum(['manual', 'enrichment']) +/** Which workflow state a group's per-cell runs execute against: `'live'` (the + * editable draft) or `'deployed'` (the latest active deployment). Defaults to + * `'live'` when omitted. */ +const workflowGroupDeploymentModeSchema = z.enum(['live', 'deployed']) + /** One workflow Start-block input field ← one table column. */ const workflowGroupInputMappingSchema = z.object({ inputName: z.string().min(1, 'inputName cannot be empty'), @@ -764,6 +851,8 @@ export const addWorkflowGroupBodySchema = z.object({ outputs: z.array(workflowGroupOutputSchema).min(1), /** Maps the workflow's Start-block inputs to table columns. */ inputMappings: z.array(workflowGroupInputMappingSchema).optional(), + /** Which workflow state per-cell runs execute against. Defaults to `'live'`. */ + deploymentMode: workflowGroupDeploymentModeSchema.optional(), /** When `false`, the group never auto-fires from the scheduler — it can * only be triggered manually. Defaults to `true`. Persisted on the * group; distinct from the top-level `autoRun` below which is a @@ -808,6 +897,8 @@ export const updateWorkflowGroupBodySchema = z.object({ mappingUpdates: z.array(workflowGroupMappingUpdateSchema).optional(), /** Replace the group's input mappings. Omit to leave unchanged. */ inputMappings: z.array(workflowGroupInputMappingSchema).optional(), + /** Change which workflow state the group runs against. Omit to leave unchanged. */ + deploymentMode: workflowGroupDeploymentModeSchema.optional(), /** Update the group's provenance. Omit to leave unchanged. */ type: workflowGroupTypeSchema.optional(), /** Toggle the group's persisted auto-run flag. Omit to leave unchanged. */ @@ -891,6 +982,24 @@ export const cancelTableRunsContract = defineRouteContract({ }, }) +export const cancelTableImportBodySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + importId: z.string().min(1, 'Import ID is required'), +}) + +/** Cancel an in-flight async CSV import. The worker stops; committed rows are left in place. */ +export const cancelTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/import/cancel', + params: tableIdParamsSchema, + body: cancelTableImportBodySchema, + response: { + mode: 'json', + schema: successResponseSchema(z.object({ canceled: z.boolean() })), + }, +}) +export type CancelTableImportBody = z.input + /** * Run modes for `POST /api/table/[tableId]/columns/run`: * - `all` — every dep-satisfied row not already running/pending diff --git a/apps/sim/lib/api/contracts/tools/databases/clickhouse.ts b/apps/sim/lib/api/contracts/tools/databases/clickhouse.ts new file mode 100644 index 00000000000..57158d3de8e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/databases/clickhouse.ts @@ -0,0 +1,319 @@ +import { z } from 'zod' +import { + introspectionResponseSchema, + nonEmptyRecordSchema, + sqlRowsResponseSchema, +} from '@/lib/api/contracts/tools/databases/shared' +import { + type ContractBodyInput, + type ContractJsonResponse, + defineRouteContract, +} from '@/lib/api/contracts/types' + +const secureFlagSchema = z + .union([z.boolean(), z.string()]) + .transform((value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value)) + .default(true) + +export const clickhouseConnectionBodySchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().default(''), + secure: secureFlagSchema, +}) + +export const clickhouseQueryBodySchema = clickhouseConnectionBodySchema.extend({ + query: z.string().min(1, 'Query is required'), +}) + +export const clickhouseExecuteBodySchema = clickhouseQueryBodySchema + +export const clickhouseInsertBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + data: nonEmptyRecordSchema('Data object cannot be empty'), +}) + +export const clickhouseUpdateBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + data: nonEmptyRecordSchema('Data object cannot be empty'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export const clickhouseDeleteBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export const clickhouseIntrospectBodySchema = clickhouseConnectionBodySchema + +export const clickhouseQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/query', + body: clickhouseQueryBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseExecuteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/execute', + body: clickhouseExecuteBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseInsertContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/insert', + body: clickhouseInsertBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseUpdateContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/update', + body: clickhouseUpdateBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDeleteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/delete', + body: clickhouseDeleteBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseIntrospectContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/introspect', + body: clickhouseIntrospectBodySchema, + response: { mode: 'json', schema: introspectionResponseSchema }, +}) + +const clickhouseTableBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), +}) + +const clickhouseCountResponseSchema = z.object({ + message: z.string(), + count: z.number(), +}) + +const clickhouseDdlResponseSchema = z.object({ + message: z.string(), + ddl: z.string(), +}) + +export const clickhouseListDatabasesBodySchema = clickhouseConnectionBodySchema +export const clickhouseListTablesBodySchema = clickhouseConnectionBodySchema +export const clickhouseDescribeTableBodySchema = clickhouseTableBodySchema +export const clickhouseShowCreateTableBodySchema = clickhouseTableBodySchema +export const clickhouseCountRowsBodySchema = clickhouseTableBodySchema.extend({ + where: z.string().optional(), +}) +export const clickhouseListPartitionsBodySchema = clickhouseTableBodySchema +export const clickhouseListMutationsBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().optional(), + onlyRunning: z + .union([z.boolean(), z.string()]) + .transform((value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value)) + .default(false), +}) +export const clickhouseListRunningQueriesBodySchema = clickhouseConnectionBodySchema +export const clickhouseTableStatsBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().optional(), +}) +export const clickhouseListClustersBodySchema = clickhouseConnectionBodySchema +export const clickhouseCreateDatabaseBodySchema = clickhouseConnectionBodySchema.extend({ + name: z.string().min(1, 'Database name is required'), +}) +export const clickhouseDropDatabaseBodySchema = clickhouseConnectionBodySchema.extend({ + name: z.string().min(1, 'Database name is required'), +}) +export const clickhouseCreateTableBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + columns: z + .array( + z.object({ + name: z.string().min(1, 'Column name is required'), + type: z.string().min(1, 'Column type is required'), + }) + ) + .min(1, 'At least one column is required'), + engine: z.string().min(1).default('MergeTree'), + orderBy: z.string().min(1, 'ORDER BY expression is required'), + partitionBy: z.string().optional(), +}) +export const clickhouseDropTableBodySchema = clickhouseTableBodySchema +export const clickhouseTruncateTableBodySchema = clickhouseTableBodySchema +export const clickhouseRenameTableBodySchema = clickhouseTableBodySchema.extend({ + newTable: z.string().min(1, 'New table name is required'), +}) +export const clickhouseOptimizeTableBodySchema = clickhouseTableBodySchema.extend({ + final: z + .union([z.boolean(), z.string()]) + .transform((value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value)) + .default(false), +}) +export const clickhouseDropPartitionBodySchema = clickhouseTableBodySchema.extend({ + partition: z.string().min(1, 'Partition expression is required'), +}) +export const clickhouseKillQueryBodySchema = clickhouseConnectionBodySchema.extend({ + queryId: z.string().min(1, 'Query ID is required'), +}) +export const clickhouseInsertRowsBodySchema = clickhouseTableBodySchema.extend({ + rows: z.array(z.record(z.string(), z.unknown())).min(1, 'At least one row is required'), +}) + +export const clickhouseListDatabasesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-databases', + body: clickhouseListDatabasesBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-tables', + body: clickhouseListTablesBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDescribeTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/describe-table', + body: clickhouseDescribeTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseShowCreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/show-create-table', + body: clickhouseShowCreateTableBodySchema, + response: { mode: 'json', schema: clickhouseDdlResponseSchema }, +}) + +export const clickhouseCountRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/count-rows', + body: clickhouseCountRowsBodySchema, + response: { mode: 'json', schema: clickhouseCountResponseSchema }, +}) + +export const clickhouseListPartitionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-partitions', + body: clickhouseListPartitionsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListMutationsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-mutations', + body: clickhouseListMutationsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListRunningQueriesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-running-queries', + body: clickhouseListRunningQueriesBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseTableStatsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/table-stats', + body: clickhouseTableStatsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListClustersContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-clusters', + body: clickhouseListClustersBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseCreateDatabaseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/create-database', + body: clickhouseCreateDatabaseBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDropDatabaseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/drop-database', + body: clickhouseDropDatabaseBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseCreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/create-table', + body: clickhouseCreateTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDropTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/drop-table', + body: clickhouseDropTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseTruncateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/truncate-table', + body: clickhouseTruncateTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseRenameTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/rename-table', + body: clickhouseRenameTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseOptimizeTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/optimize-table', + body: clickhouseOptimizeTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDropPartitionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/drop-partition', + body: clickhouseDropPartitionBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseKillQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/kill-query', + body: clickhouseKillQueryBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseInsertRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/insert-rows', + body: clickhouseInsertRowsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export type ClickHouseQueryRequest = ContractBodyInput +export type ClickHouseQueryResponse = ContractJsonResponse +export type ClickHouseExecuteRequest = ContractBodyInput +export type ClickHouseExecuteResponse = ContractJsonResponse +export type ClickHouseInsertRequest = ContractBodyInput +export type ClickHouseInsertResponse = ContractJsonResponse +export type ClickHouseUpdateRequest = ContractBodyInput +export type ClickHouseUpdateResponse = ContractJsonResponse +export type ClickHouseDeleteRequest = ContractBodyInput +export type ClickHouseDeleteResponse = ContractJsonResponse +export type ClickHouseIntrospectRequest = ContractBodyInput +export type ClickHouseIntrospectResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index b25e5852289..78b642e42dc 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -4,7 +4,8 @@ import { createTableColumnBodySchema, deleteTableColumnBodySchema, deleteTableRowsBodySchema, - insertTableRowBodySchema, + insertTableRowBodyBaseSchema, + rowAnchorMutexRefine, rowDataSchema, tableIdParamsSchema, tableRowParamsSchema, @@ -60,7 +61,9 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({ * Public API insert row body — no caller-controlled `position`. Server places * new rows at the tail; ordering by index is an in-app affordance only. */ -export const v1InsertTableRowBodySchema = insertTableRowBodySchema.omit({ position: true }) +export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema + .omit({ position: true }) + .refine(...rowAnchorMutexRefine) /** * Public API batch insert body — no `positions`. Same rationale as above. diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index fe4d578a781..7bca16a8298 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -101,12 +101,14 @@ export type CopilotChatDetailRow = Pick< | 'workspaceId' | 'type' | 'title' - | 'messages' | 'conversationId' | 'resources' | 'createdAt' | 'updatedAt' -> +> & { + /** Transcript assembled from `copilot_messages` (no longer a chat-row column). */ + messages: unknown[] +} export type CopilotChatLegacyDetailRow = CopilotChatDetailRow & Pick diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 772c4ba03ba..f9d308df5b0 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -12,7 +12,9 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getTableById } from '@/lib/table/service' +import type { RowData } from '@/lib/table' +import { nKeysBetween } from '@/lib/table/order-key' +import { buildOrderedRowValues, getTableById } from '@/lib/table/service' const logger = createLogger('CopilotToolResultTables') @@ -102,21 +104,23 @@ export async function maybeWriteOutputToTable( await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) const now = new Date() + // Replace-all: table was just cleared — mint a fresh contiguous key run. + const orderKeys = nKeysBetween(null, null, rows.length) for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) - const values = chunk.map((rowData, j) => ({ - id: `row_${generateId().replace(/-/g, '')}`, + const values = buildOrderedRowValues({ tableId: outputTable, workspaceId: context.workspaceId!, - data: rowData, - position: i + j, - createdAt: now, - updatedAt: now, + rows: chunk as RowData[], + startPosition: i, + orderKeys: orderKeys.slice(i, i + BATCH_CHUNK_SIZE), + now, createdBy: context.userId, - })) + makeId: () => `row_${generateId().replace(/-/g, '')}`, + }) await tx.insert(userTableRows).values(values) } }) @@ -246,21 +250,23 @@ export async function maybeWriteReadCsvToTable( await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) const now = new Date() + // Replace-all: table was just cleared — mint a fresh contiguous key run. + const orderKeys = nKeysBetween(null, null, rows.length) for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) - const values = chunk.map((rowData, j) => ({ - id: `row_${generateId().replace(/-/g, '')}`, + const values = buildOrderedRowValues({ tableId: outputTable, workspaceId: context.workspaceId!, - data: rowData, - position: i + j, - createdAt: now, - updatedAt: now, + rows: chunk as RowData[], + startPosition: i, + orderKeys: orderKeys.slice(i, i + BATCH_CHUNK_SIZE), + now, createdBy: context.userId, - })) + makeId: () => `row_${generateId().replace(/-/g, '')}`, + }) await tx.insert(userTableRows).values(values) } }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts new file mode 100644 index 00000000000..84184b0e357 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFindUpload, + mockFetchBuffer, + mockParseFileRows, + mockInferSchema, + mockCoerceRows, + mockCreateTable, + mockBatchInsertRows, + mockDeleteTable, + mockGetLimits, +} = vi.hoisted(() => ({ + mockFindUpload: vi.fn(), + mockFetchBuffer: vi.fn(), + mockParseFileRows: vi.fn(), + mockInferSchema: vi.fn(), + mockCoerceRows: vi.fn(), + mockCreateTable: vi.fn(), + mockBatchInsertRows: vi.fn(), + mockDeleteTable: vi.fn(), + mockGetLimits: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ + findMothershipUploadRowByChatAndName: mockFindUpload, +})) + +vi.mock('@/lib/uploads', () => ({ + getServePathPrefix: () => '/api/files/serve/', +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mockFetchBuffer, +})) + +vi.mock('@/lib/table', () => ({ + CSV_MAX_BATCH_SIZE: 1000, + TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 100 }, + parseFileRows: mockParseFileRows, + inferSchemaFromCsv: mockInferSchema, + coerceRowsForTable: mockCoerceRows, + createTable: mockCreateTable, + batchInsertRows: mockBatchInsertRows, + deleteTable: mockDeleteTable, + getWorkspaceTableLimits: mockGetLimits, + sanitizeName: (raw: string) => raw.replace(/[^a-zA-Z0-9_]/g, '_'), +})) + +vi.mock('@/lib/workflows/operations/import-export', () => ({ parseWorkflowJson: vi.fn() })) +vi.mock('@/lib/workflows/persistence/utils', () => ({ saveWorkflowToNormalizedTables: vi.fn() })) +vi.mock('@/lib/workflows/utils', () => ({ deduplicateWorkflowName: vi.fn() })) +vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() })) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file' + +const context = { + chatId: 'chat-1', + workspaceId: 'ws-1', + userId: 'user-1', + workflowId: 'wf-1', +} as ExecutionContext + +const uploadRow = { + id: 'file-1', + workspaceId: 'ws-1', + displayName: 'data.csv', + originalName: 'data.csv', + key: 'uploads/data.csv', + size: 123, + contentType: 'text/csv', + userId: 'user-1', + deletedAt: null, + uploadedAt: new Date(), + updatedAt: new Date(), +} + +describe('executeMaterializeFile - table operation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFindUpload.mockResolvedValue(uploadRow) + mockFetchBuffer.mockResolvedValue(Buffer.from('name\nAlice')) + mockParseFileRows.mockResolvedValue({ headers: ['name'], rows: [{ name: 'Alice' }] }) + mockInferSchema.mockReturnValue({ + columns: [{ name: 'name', type: 'string' }], + headerToColumn: new Map([['name', 'name']]), + }) + mockCoerceRows.mockReturnValue([{ name: 'Alice' }]) + mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 }) + mockCreateTable.mockResolvedValue({ id: 'tbl_abc', name: 'data', schema: { columns: [] } }) + mockBatchInsertRows.mockResolvedValue([{ id: 'row-1' }]) + mockDeleteTable.mockResolvedValue(undefined) + }) + + it('creates a table and returns a table resource', async () => { + const result = await executeMaterializeFile( + { fileNames: ['data.csv'], operation: 'table' }, + context + ) + + expect(result.success).toBe(true) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'data', + workspaceId: 'ws-1', + userId: 'user-1', + maxRows: 1_000_000, + maxTables: 50, + }), + expect.any(String) + ) + expect(result.resources).toEqual([{ type: 'table', id: 'tbl_abc', title: 'data' }]) + expect((result.output as { succeeded: string[] }).succeeded).toEqual(['data.csv']) + }) + + it('honors an explicit tableName', async () => { + await executeMaterializeFile( + { fileNames: ['data.csv'], operation: 'table', tableName: 'My Customers' }, + context + ) + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My_Customers' }), + expect.any(String) + ) + }) + + it('deletes the table and fails when row insertion throws', async () => { + mockBatchInsertRows.mockRejectedValueOnce(new Error('insert exploded')) + + const result = await executeMaterializeFile( + { fileNames: ['data.csv'], operation: 'table' }, + context + ) + + expect(result.success).toBe(false) + expect(mockDeleteTable).toHaveBeenCalledWith('tbl_abc', expect.any(String)) + expect((result.output as { failed: Array<{ error: string }> }).failed[0].error).toContain( + 'insert exploded' + ) + }) + + it('fails fast (no table created) when the upload is missing', async () => { + mockFindUpload.mockResolvedValue(null) + + const result = await executeMaterializeFile( + { fileNames: ['missing.csv'], operation: 'table' }, + context + ) + + expect(result.success).toBe(false) + expect(mockCreateTable).not.toHaveBeenCalled() + expect((result.output as { failed: Array<{ error: string }> }).failed[0].error).toContain( + 'Upload not found' + ) + }) +}) + +describe('executeMaterializeFile - unsupported operation', () => { + beforeEach(() => vi.clearAllMocks()) + + it('rejects an unimplemented operation instead of silently saving', async () => { + const result = await executeMaterializeFile( + { fileNames: ['data.csv'], operation: 'knowledge_base' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not implemented') + expect(mockFindUpload).not.toHaveBeenCalled() + expect(mockCreateTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 7aa2b88c724..2d2ff8db0ba 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -7,6 +7,19 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' +import { + batchInsertRows, + CSV_MAX_BATCH_SIZE, + coerceRowsForTable, + createTable, + deleteTable, + getWorkspaceTableLimits, + inferSchemaFromCsv, + parseFileRows, + sanitizeName, + TABLE_LIMITS, + type TableSchema, +} from '@/lib/table' import { getServePathPrefix } from '@/lib/uploads' import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' @@ -184,6 +197,88 @@ async function executeImport( } } +async function executeTable( + fileName: string, + chatId: string, + workspaceId: string, + userId: string, + requestedTableName?: string +): Promise { + const row = await findMothershipUploadRowByChatAndName(chatId, fileName) + if (!row) { + return { + success: false, + error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`, + } + } + + const fileRecord = toFileRecord(row) + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + const { headers, rows } = await parseFileRows(buffer, fileRecord.name, fileRecord.type) + if (rows.length === 0) { + return { success: false, error: `"${fileName}" contains no data rows.` } + } + + const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) + const baseName = requestedTableName?.trim() || fileName.replace(/\.[^.]+$/, '') + const tableName = sanitizeName(baseName, 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const schema: TableSchema = { columns } + const planLimits = await getWorkspaceTableLimits(workspaceId) + const requestId = generateId().slice(0, 8) + + const table = await createTable( + { + name: tableName, + description: `Imported from ${fileName}`, + schema, + workspaceId, + userId, + maxRows: planLimits.maxRowsPerTable, + maxTables: planLimits.maxTables, + }, + requestId + ) + + try { + const coerced = coerceRowsForTable(rows, schema, headerToColumn) + let inserted = 0 + for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { + const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) + const result = await batchInsertRows( + { tableId: table.id, rows: batch, workspaceId, userId }, + table, + generateId().slice(0, 8) + ) + inserted += result.length + } + + logger.info('Created table from upload', { + fileName, + tableId: table.id, + columns: columns.length, + rows: inserted, + chatId, + }) + + return { + success: true, + output: { + message: `File "${fileName}" imported as table "${table.name}" with ${columns.length} columns and ${inserted} rows.`, + tableId: table.id, + tableName: table.name, + rowCount: inserted, + }, + resources: [{ type: 'table', id: table.id, title: table.name }], + } + } catch (insertError) { + await deleteTable(table.id, requestId).catch(() => {}) + throw insertError + } +} + export async function executeMaterializeFile( params: Record, context: ExecutionContext @@ -205,17 +300,43 @@ export async function executeMaterializeFile( } const operation = (params.operation as string | undefined) || 'save' + + const supportedOperations = new Set(['save', 'import', 'table']) + if (!supportedOperations.has(operation)) { + return { + success: false, + error: `materialize_file operation "${operation}" is not implemented. Supported operations: ${[...supportedOperations].join(', ')}.`, + } + } + + const requestedTableName = params.tableName as string | undefined const succeeded: string[] = [] const failed: Array<{ fileName: string; error: string }> = [] + const resources: NonNullable = [] for (const fileName of fileNames) { try { + let result: ToolCallResult if (operation === 'import') { - await executeImport(fileName, context.chatId, context.workspaceId, context.userId) + result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId) + } else if (operation === 'table') { + result = await executeTable( + fileName, + context.chatId, + context.workspaceId, + context.userId, + requestedTableName + ) + } else { + result = await executeSave(fileName, context.chatId) + } + + if (result.success) { + succeeded.push(fileName) + if (result.resources) resources.push(...result.resources) } else { - await executeSave(fileName, context.chatId) + failed.push({ fileName, error: result.error ?? 'Failed to materialize file' }) } - succeeded.push(fileName) } catch (err) { logger.error('materialize_file failed', { fileName, @@ -237,5 +358,6 @@ export async function executeMaterializeFile( failed.length > 0 ? `Failed to materialize: ${failed.map((f) => f.fileName).join(', ')}` : undefined, + resources: resources.length > 0 ? resources : undefined, } } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index d20d172711a..16e36a6c4fb 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -16,8 +16,7 @@ import { coerceRowsForTable, getWorkspaceTableLimits, inferSchemaFromCsv, - parseCsvBuffer, - sanitizeName, + parseFileRows, validateMapping, } from '@/lib/table' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' @@ -55,6 +54,7 @@ import type { TableDefinition, WorkflowGroup, WorkflowGroupDependencies, + WorkflowGroupDeploymentMode, WorkflowGroupInputMapping, WorkflowGroupOutput, } from '@/lib/table/types' @@ -98,39 +98,6 @@ async function resolveWorkspaceFile( return { buffer, name: record.name, type: record.type } } -/** - * Sanitizes raw JSON headers/rows so they conform to the same rules as CSV - * imports (so `inferSchemaFromCsv` and friends can be reused). - */ -function sanitizeJsonHeaders( - headers: string[], - rows: Record[] -): { headers: string[]; rows: Record[] } { - const renamed = new Map() - const seen = new Set() - - for (const raw of headers) { - let safe = sanitizeName(raw) - while (seen.has(safe)) safe = `${safe}_` - seen.add(safe) - renamed.set(raw, safe) - } - - const noChange = headers.every((h) => renamed.get(h) === h) - if (noChange) return { headers, rows } - - return { - headers: headers.map((h) => renamed.get(h)!), - rows: rows.map((row) => { - const out: Record = {} - for (const [raw, safe] of renamed) { - if (raw in row) out[safe] = row[raw] - } - return out - }), - } -} - /** * Loads the live workflow state and flattens it into pickable outputs. Used * to validate `(blockId, path)` pairs the AI passes to add/update_workflow_group @@ -173,40 +140,14 @@ function validateOutputsAgainstWorkflow( return `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${flattened.length > 12 ? ' (first 12)' : ''}:\n${sample}\n\nCall list_workflow_outputs with workflowId="${workflowId}" to see all valid (blockId, path) picks.` } -async function parseJsonRows( - buffer: Buffer -): Promise<{ headers: string[]; rows: Record[] }> { - const parsed = JSON.parse(buffer.toString('utf-8')) - if (!Array.isArray(parsed)) { - throw new Error('JSON file must contain an array of objects') - } - if (parsed.length === 0) { - throw new Error('JSON file contains an empty array') - } - const headerSet = new Set() - for (const row of parsed) { - if (typeof row !== 'object' || row === null || Array.isArray(row)) { - throw new Error('Each element in the JSON array must be a plain object') - } - for (const key of Object.keys(row)) headerSet.add(key) - } - return sanitizeJsonHeaders([...headerSet], parsed) -} - -async function parseFileRows( - buffer: Buffer, - fileName: string, - contentType: string -): Promise<{ headers: string[]; rows: Record[] }> { - const ext = fileName.split('.').pop()?.toLowerCase() - if (ext === 'json' || contentType === 'application/json') { - return parseJsonRows(buffer) - } - if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') { - const delimiter = ext === 'tsv' ? '\t' : ',' - return parseCsvBuffer(buffer, delimiter) - } - throw new Error(`Unsupported file format: "${ext}". Supported: csv, tsv, json`) +/** + * Narrows a raw `deploymentMode` arg to the `'live' | 'deployed'` union, or + * `undefined` when absent/invalid (leaving the group's existing value — which + * itself defaults to `'live'`). Lets Mothership choose whether a group's + * per-cell runs execute the live draft or the latest active deployment. + */ +function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | undefined { + return value === 'live' || value === 'deployed' ? value : undefined } async function batchInsertAll( @@ -1256,11 +1197,13 @@ export const userTableServerTool: BaseServerTool } const dependencies = args.dependencies as WorkflowGroupDependencies | undefined const name = args.name as string | undefined + const deploymentMode = parseDeploymentMode(args.deploymentMode) const group: WorkflowGroup = { id: groupId, workflowId, ...(name ? { name } : {}), ...(dependencies ? { dependencies } : {}), + ...(deploymentMode ? { deploymentMode } : {}), outputs, } const requestId = generateId().slice(0, 8) @@ -1339,6 +1282,7 @@ export const userTableServerTool: BaseServerTool mappingUpdates: args.mappingUpdates as | Array<{ columnName: string; blockId: string; path: string }> | undefined, + deploymentMode: parseDeploymentMode(args.deploymentMode), autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, }, requestId diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 0a863112f10..35e5d9d24cf 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -68,6 +68,7 @@ export const env = createEnv({ ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users ENTERPRISE_STORAGE_LIMIT_GB: z.number().optional().default(500), // Default storage limit in GB for enterprise tier (can be overridden per org) BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking + TABLES_FRACTIONAL_ORDERING: z.boolean().optional(), // Order table rows by fractional order_key (O(1) insert/delete) instead of integer position // Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans. FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 3) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index bde9252d652..b9580772b79 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -36,6 +36,14 @@ export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.a */ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) +/** + * Order table rows by fractional `order_key` (O(1) insert/delete) instead of the + * legacy integer `position`. When off, behavior is unchanged. Keys are written + * regardless of this flag; it only controls which column is authoritative for + * reads/ordering and whether inserts/deletes reshift positions. + */ +export const isTablesFractionalOrderingEnabled = isTruthy(env.TABLES_FRACTIONAL_ORDERING) + /** * Is email verification enabled */ diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index d76928ade83..cdf5f28a9a1 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -206,6 +206,112 @@ export async function validateDatabaseHost( } } +/** + * Patterns run against the WHERE clause with string/identifier literals masked + * out (so an attacker cannot smuggle `OR 1` or `; DROP` inside a quoted value). + * + * The connector-literal rules below are intentionally `OR`-only: only an + * `OR ` term broadens a mutation to every row. `AND ` is a no-op + * for broadening and is also exactly what `BETWEEN low AND high` produces, so + * matching it would reject legitimate range filters (e.g. `id BETWEEN 1 AND 10`). + */ +const SQL_WHERE_MASKED_PATTERNS: readonly RegExp[] = [ + /;\s*\w/, // stacked statement + /\bunion\s+(?:all\s+)?select\b/i, + /\binto\s+(?:out|dump)file\b/i, + /--/, + /\/\*/, + /\*\//, + /\b(?:sleep|pg_sleep|benchmark)\s*\(/i, + /\b(\w+)\s*=\s*\1\b/i, // same (unquoted) operand both sides: x=x, 1=1 + /\b\d+(?:\.\d+)?\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+(?:\.\d+)?\b/, // constant vs constant: 1=1, 1<2, 2>1 + /\bor\s+(?:true|false)\b/i, // OR TRUE / OR FALSE + /\bor\s+\d+(?:\.\d+)?\b(?!\s*[=<>!+\-*/%])/i, // standalone truthy literal after OR: OR 1, OR 42 + /^\s*(?:\d+(?:\.\d+)?|true|false)\s*$/i, // bare constant: "1" / "true" / "false" +] + +/** + * Patterns run against the raw WHERE clause (need the literal contents intact), + * e.g. equality between two identical string literals. + */ +const SQL_WHERE_RAW_PATTERNS: readonly RegExp[] = [ + /(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/, // 'a'='a' / "x"="x" +] + +/** + * Replaces the contents of string literals ('...'), double-quoted and + * backtick-quoted identifiers with spaces (preserving length) so structural + * scans do not treat data inside quotes as SQL. Comments are intentionally left + * intact so comment-injection sequences are still detected. + */ +function maskSqlStringLiterals(sql: string): string { + let out = '' + let i = 0 + while (i < sql.length) { + const ch = sql[i] + if (ch === "'" || ch === '"' || ch === '`') { + out += ' ' + i++ + while (i < sql.length && sql[i] !== ch) { + if (ch !== '`' && sql[i] === '\\') { + out += ' ' + i += 2 + continue + } + out += ' ' + i++ + } + if (i < sql.length) { + out += ' ' + i++ + } + continue + } + out += ch + i++ + } + return out +} + +/** + * Validates a free-form SQL `WHERE` condition for injection and always-true + * tautology patterns. Returns a {@link ValidationResult}; callers decide whether + * to throw or surface the error. + * + * IMPORTANT: this is **defense-in-depth, not a security boundary**. A free-form + * SQL condition cannot be exhaustively validated against every always-true + * expression (e.g. `OR 2 > 1`, `OR (1)`, `OR NOT 0`, `OR length(x) >= 0`). The + * real boundary is that the caller supplies their own database credentials and + * could run equivalent SQL directly (e.g. via a raw-SQL/execute operation). This + * guard stops the easy, obvious ways an injected condition broadens a mutation + * to every row; it is not a substitute for constraining untrusted input upstream. + * + * @param where - The WHERE clause condition (without the `WHERE` keyword) + * @param paramName - Label used in the error message + */ +export function validateSqlWhereClause( + where: string | null | undefined, + paramName = 'WHERE clause' +): ValidationResult { + if (typeof where !== 'string' || where.trim().length === 0) { + return { isValid: false, error: `${paramName} is required` } + } + + const masked = maskSqlStringLiterals(where) + const matched = + SQL_WHERE_MASKED_PATTERNS.some((pattern) => pattern.test(masked)) || + SQL_WHERE_RAW_PATTERNS.some((pattern) => pattern.test(where)) + + if (matched) { + return { + isValid: false, + error: `${paramName} contains a disallowed or always-true expression`, + } + } + + return { isValid: true } +} + export interface SecureFetchOptions { method?: string headers?: Record diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index 0ae2be8c1d9..4eb06aa112e 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -21,6 +21,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import type { TraceSpan } from '@/lib/logs/types' +import { hostedKeyMetrics } from '@/lib/monitoring/metrics' /** * GenAI Semantic Convention Attributes @@ -954,14 +955,10 @@ export const PlatformEvents = { workspaceId?: string workflowId?: string }) => { - trackPlatformEvent('platform.hosted_key.user_throttled', { - 'tool.id': attrs.toolId, - 'throttle.reason': attrs.reason, - ...(attrs.provider && { 'provider.id': attrs.provider }), - ...(attrs.retryAfterMs != null && { 'rate_limit.retry_after_ms': attrs.retryAfterMs }), - ...(attrs.userId && { 'user.id': attrs.userId }), - ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }), - ...(attrs.workflowId && { 'workflow.id': attrs.workflowId }), + hostedKeyMetrics.recordThrottled({ + provider: attrs.provider ?? attrs.toolId, + tool: attrs.toolId, + reason: attrs.reason, }) }, @@ -978,16 +975,7 @@ export const PlatformEvents = { workspaceId?: string workflowId?: string }) => { - trackPlatformEvent('platform.hosted_key.rate_limited', { - 'tool.id': attrs.toolId, - 'hosted_key.env_var': attrs.envVarName, - 'rate_limit.attempt': attrs.attempt, - 'rate_limit.max_retries': attrs.maxRetries, - 'rate_limit.delay_ms': attrs.delayMs, - ...(attrs.userId && { 'user.id': attrs.userId }), - ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }), - ...(attrs.workflowId && { 'workflow.id': attrs.workflowId }), - }) + hostedKeyMetrics.recordUpstreamRateLimited({ tool: attrs.toolId, key: attrs.envVarName }) }, hostedKeyUnknownModelCost: (attrs: { @@ -995,11 +983,7 @@ export const PlatformEvents = { modelName: string defaultCost: number }) => { - trackPlatformEvent('platform.hosted_key.unknown_model_cost', { - 'tool.id': attrs.toolId, - 'model.name': attrs.modelName, - 'cost.default_cost': attrs.defaultCost, - }) + hostedKeyMetrics.recordUnknownModelCost({ tool: attrs.toolId }) }, /** @@ -1016,14 +1000,9 @@ export const PlatformEvents = { dimension?: string queuePosition?: number }) => { - trackPlatformEvent('platform.hosted_key.queue_waited', { - 'provider.id': attrs.provider, - 'workspace.id': attrs.workspaceId, - 'queue.waited_ms': attrs.waitedMs, - 'queue.attempts': attrs.attempts, - 'queue.reason': attrs.reason, - ...(attrs.dimension && { 'queue.dimension': attrs.dimension }), - ...(attrs.queuePosition != null && { 'queue.position': attrs.queuePosition }), + hostedKeyMetrics.recordQueueWait(attrs.waitedMs, { + provider: attrs.provider, + reason: attrs.reason, }) }, @@ -1037,12 +1016,9 @@ export const PlatformEvents = { reason: 'actor_requests' | 'dimension' | 'queue_position' dimension?: string }) => { - trackPlatformEvent('platform.hosted_key.queue_wait_exceeded', { - 'provider.id': attrs.provider, - 'workspace.id': attrs.workspaceId, - 'queue.waited_ms': attrs.waitedMs, - 'queue.reason': attrs.reason, - ...(attrs.dimension && { 'queue.dimension': attrs.dimension }), + hostedKeyMetrics.recordQueueWaitExceeded({ + provider: attrs.provider, + reason: attrs.reason, }) }, diff --git a/apps/sim/lib/core/utils/multipart.test.ts b/apps/sim/lib/core/utils/multipart.test.ts new file mode 100644 index 00000000000..81c09619612 --- /dev/null +++ b/apps/sim/lib/core/utils/multipart.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ +import type { Readable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { isMultipartError, type MultipartError, readMultipart } from '@/lib/core/utils/multipart' + +type Part = + | { name: string; value: string } + | { name: string; filename: string; value: string; contentType?: string } + +const BOUNDARY = '----testboundary1234' + +function buildBody(parts: Part[], boundary = BOUNDARY): Buffer { + const segments: Buffer[] = [] + for (const part of parts) { + let header = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"` + if ('filename' in part) { + header += `; filename="${part.filename}"\r\nContent-Type: ${part.contentType ?? 'text/csv'}` + } + header += '\r\n\r\n' + segments.push(Buffer.from(header, 'utf8'), Buffer.from(part.value, 'utf8'), Buffer.from('\r\n')) + } + segments.push(Buffer.from(`--${boundary}--\r\n`, 'utf8')) + return Buffer.concat(segments) +} + +function toWebStream(body: Buffer, chunkSize?: number): ReadableStream { + return new ReadableStream({ + start(controller) { + if (chunkSize) { + for (let i = 0; i < body.length; i += chunkSize) { + controller.enqueue(new Uint8Array(body.subarray(i, i + chunkSize))) + } + } else { + controller.enqueue(new Uint8Array(body)) + } + controller.close() + }, + }) +} + +function makeRequest( + parts: Part[], + opts?: { chunkSize?: number; contentType?: string; boundary?: string } +) { + const boundary = opts?.boundary ?? BOUNDARY + return { + headers: new Headers({ + 'content-type': opts?.contentType ?? `multipart/form-data; boundary=${boundary}`, + }), + body: toWebStream(buildBody(parts, boundary), opts?.chunkSize), + } +} + +async function readStream(stream: Readable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks).toString('utf8') +} + +function expectCode(error: unknown, code: MultipartError['code']) { + expect(isMultipartError(error)).toBe(true) + expect((error as MultipartError).code).toBe(code) +} + +describe('readMultipart', () => { + it('parses text fields (before the file) and exposes the file stream', async () => { + const csv = 'name,age\nAlice,30\n' + const request = makeRequest([ + { name: 'workspaceId', value: 'ws-1' }, + { name: 'file', filename: 'data.csv', value: csv }, + ]) + + const { fields, file } = await readMultipart(request, { + maxFileBytes: 1024, + requiredFieldsBeforeFile: ['workspaceId'], + }) + + expect(fields.workspaceId).toBe('ws-1') + expect(file?.filename).toBe('data.csv') + expect(file?.fieldName).toBe('file') + expect(await readStream(file!.stream)).toBe(csv) + }) + + it('handles a body delivered in tiny chunks (split mid-boundary)', async () => { + const csv = 'name,age\nAlice,30\nBob,40\n' + const request = makeRequest( + [ + { name: 'workspaceId', value: 'ws-1' }, + { name: 'file', filename: 'data.csv', value: csv }, + ], + { chunkSize: 3 } + ) + + const { file } = await readMultipart(request, { maxFileBytes: 1024 }) + expect(await readStream(file!.stream)).toBe(csv) + }) + + it('rejects FIELD_AFTER_FILE when a required field comes after the file', async () => { + const request = makeRequest([ + { name: 'file', filename: 'data.csv', value: 'name\nAlice\n' }, + { name: 'workspaceId', value: 'ws-1' }, + ]) + + await readMultipart(request, { + maxFileBytes: 1024, + requiredFieldsBeforeFile: ['workspaceId'], + }).then( + () => { + throw new Error('expected rejection') + }, + (err) => expectCode(err, 'FIELD_AFTER_FILE') + ) + }) + + it('rejects NO_FILE when the body has no file part', async () => { + const request = makeRequest([{ name: 'workspaceId', value: 'ws-1' }]) + await readMultipart(request, { maxFileBytes: 1024 }).then( + () => { + throw new Error('expected rejection') + }, + (err) => expectCode(err, 'NO_FILE') + ) + }) + + it('rejects NOT_MULTIPART for a non-multipart content type', async () => { + const request = { + headers: new Headers({ 'content-type': 'application/json' }), + body: toWebStream(Buffer.from('{}')), + } + await readMultipart(request, { maxFileBytes: 1024 }).then( + () => { + throw new Error('expected rejection') + }, + (err) => expectCode(err, 'NOT_MULTIPART') + ) + }) + + it('errors the file stream with FILE_TOO_LARGE when the cap is exceeded', async () => { + const request = makeRequest([ + { name: 'workspaceId', value: 'ws-1' }, + { name: 'file', filename: 'big.csv', value: 'x'.repeat(500) }, + ]) + + const { file } = await readMultipart(request, { maxFileBytes: 50 }) + await readStream(file!.stream).then( + () => { + throw new Error('expected stream error') + }, + (err) => expectCode(err, 'FILE_TOO_LARGE') + ) + }) + + it('rejects when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + const request = makeRequest([ + { name: 'workspaceId', value: 'ws-1' }, + { name: 'file', filename: 'data.csv', value: 'name\nAlice\n' }, + ]) + + await expect( + readMultipart(request, { maxFileBytes: 1024, signal: controller.signal }) + ).rejects.toBeTruthy() + }) + + it('destroys the file stream when the signal aborts mid-upload (after resolve)', async () => { + const controller = new AbortController() + // A body that delivers the file-part header but never closes, so the file stream stays open + // after readMultipart resolves — mimicking a client still uploading. + let enqueue!: (b: Buffer) => void + const body = new ReadableStream({ + start(c) { + enqueue = (b) => c.enqueue(new Uint8Array(b)) + }, + }) + const head = Buffer.concat([ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="workspaceId"\r\n\r\nws-1\r\n` + ), + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\nContent-Type: text/csv\r\n\r\n` + ), + Buffer.from('name,age\n'), + ]) + const request = { + headers: new Headers({ 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }), + body, + } + enqueue(head) + + const parsed = await readMultipart(request, { + maxFileBytes: 1024, + requiredFieldsBeforeFile: ['workspaceId'], + signal: controller.signal, + }) + expect(parsed.file).toBeTruthy() + + controller.abort() + await expect(readStream(parsed.file!.stream)).rejects.toBeTruthy() + }) +}) diff --git a/apps/sim/lib/core/utils/multipart.ts b/apps/sim/lib/core/utils/multipart.ts new file mode 100644 index 00000000000..f23969def51 --- /dev/null +++ b/apps/sim/lib/core/utils/multipart.ts @@ -0,0 +1,256 @@ +import { Readable } from 'node:stream' +import type { ReadableStream as NodeReadableStream } from 'node:stream/web' +import busboy from 'busboy' + +/** + * Streaming multipart/form-data reader built on `busboy`. + * + * Unlike `request.formData()` (undici), this never buffers the whole request + * body in memory and does not depend on a correct `content-length`/boundary — + * it parses the request as it streams off the socket. The single file part is + * surfaced as an un-drained Node {@link Readable} so the caller can run auth / + * create-table work BEFORE consuming the (potentially huge) file bytes. + * + * @see readMultipart + */ + +/** Error codes surfaced by {@link readMultipart} and the returned file stream. */ +export type MultipartErrorCode = + | 'NOT_MULTIPART' + | 'NO_BODY' + | 'FILE_TOO_LARGE' + | 'FIELD_AFTER_FILE' + | 'NO_FILE' + | 'PARSE_ERROR' + +/** + * Error thrown by {@link readMultipart} (for pre-file failures) or emitted on + * the returned file stream (for failures during consumption, e.g. + * `FILE_TOO_LARGE`). Callers map `code` to an HTTP status. + */ +export class MultipartError extends Error { + readonly code: MultipartErrorCode + + constructor(code: MultipartErrorCode, message: string) { + super(message) + this.name = 'MultipartError' + this.code = code + } +} + +export function isMultipartError(error: unknown): error is MultipartError { + return error instanceof MultipartError +} + +export interface MultipartFilePart { + /** The multipart field name that carried the file (expected: `file`). */ + fieldName: string + filename: string + mimeType: string + /** + * The file bytes. The caller MUST fully consume or `destroy()` this stream + * (use a `finally`) or the request will hang. On overflow of `maxFileBytes` + * the stream is destroyed with a {@link MultipartError} (`FILE_TOO_LARGE`). + */ + stream: Readable +} + +export interface ParsedMultipart { + /** Text fields that arrived before the file part, keyed by field name. */ + fields: Record + /** The single file part, or `null` if the body had no file part. */ + file: MultipartFilePart | null +} + +export interface ReadMultipartOptions { + /** Per-file byte cap. Overflow destroys the file stream with `FILE_TOO_LARGE`. */ + maxFileBytes: number + /** + * Field names that must arrive before the file part. If the file part is + * seen while any are still missing, the parse rejects with `FIELD_AFTER_FILE`. + */ + requiredFieldsBeforeFile?: string[] + /** Field name expected to carry the file. Defaults to `file`. */ + fileFieldName?: string + /** Abort signal — cancels parsing and destroys the underlying stream. */ + signal?: AbortSignal +} + +interface MultipartRequest { + headers: Headers + body: ReadableStream | null +} + +/** + * Parse a `multipart/form-data` request as a stream. Resolves as soon as the + * file-part header is seen (text fields collected up to that point are in + * `fields`); the file bytes are NOT yet consumed — the caller drives + * `result.file.stream`. + * + * Pre-file failures reject the returned promise; failures that happen while the + * file streams (size limit, mid-body parse errors, abort) are surfaced as an + * error on `result.file.stream`. + */ +export function readMultipart( + request: MultipartRequest, + options: ReadMultipartOptions +): Promise { + const { maxFileBytes, requiredFieldsBeforeFile = [], fileFieldName = 'file', signal } = options + + return new Promise((resolve, reject) => { + const contentType = request.headers.get('content-type') + if (!contentType || !contentType.toLowerCase().includes('multipart/form-data')) { + reject(new MultipartError('NOT_MULTIPART', 'Expected multipart/form-data request')) + return + } + if (!request.body) { + reject(new MultipartError('NO_BODY', 'Request has no body')) + return + } + + let bb: busboy.Busboy + try { + bb = busboy({ + headers: { 'content-type': contentType }, + limits: { fileSize: maxFileBytes, files: 1 }, + }) + } catch (err) { + reject( + new MultipartError( + 'NOT_MULTIPART', + err instanceof Error ? err.message : 'Invalid multipart request' + ) + ) + return + } + + // double-cast-allowed: the web ReadableStream on request.body isn't structurally assignable to the Node type Readable.fromWeb expects + const nodeStream = Readable.fromWeb(request.body as unknown as NodeReadableStream) + const fields: Record = {} + let settled = false + let fileSeen = false + + const onAbort = () => { + const reason = signal?.reason instanceof Error ? signal.reason : new Error('Aborted') + nodeStream.destroy(reason) + bb.destroy() + if (!settled) { + settled = true + reject(reason) + } + } + + const cleanup = () => { + signal?.removeEventListener('abort', onAbort) + } + + const settle = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + if (signal?.aborted) { + // `destroy()` with no reason emits 'close', not an unhandled 'error'. + nodeStream.destroy() + settled = true + reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted')) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + + bb.on('field', (name, value) => { + fields[name] = value + }) + + bb.on('file', (name, stream, info) => { + if (settled || fileSeen) { + stream.resume() + return + } + fileSeen = true + + if (name !== fileFieldName) { + stream.resume() + nodeStream.destroy() + settle(() => + reject( + new MultipartError('NO_FILE', `Expected file field "${fileFieldName}", got "${name}"`) + ) + ) + return + } + + const missing = requiredFieldsBeforeFile.filter((field) => !(field in fields)) + if (missing.length > 0) { + stream.resume() + nodeStream.destroy() + settle(() => + reject( + new MultipartError( + 'FIELD_AFTER_FILE', + `Field(s) must precede the file in the request body: ${missing.join(', ')}` + ) + ) + ) + return + } + + stream.once('limit', () => { + stream.destroy( + new MultipartError('FILE_TOO_LARGE', `File exceeds maximum size of ${maxFileBytes} bytes`) + ) + }) + + settle(() => { + // settle() detached the pre-file abort handler. Re-arm one scoped to the file stream so a + // client disconnect mid-upload tears it down — otherwise the caller's consume loop hangs + // until maxDuration. Detach when the stream closes so it can't fire afterward. + if (signal) { + const onStreamAbort = () => { + const reason = signal.reason instanceof Error ? signal.reason : new Error('Aborted') + stream.destroy(reason) + nodeStream.destroy(reason) + bb.destroy() + } + if (signal.aborted) onStreamAbort() + else { + signal.addEventListener('abort', onStreamAbort, { once: true }) + stream.once('close', () => signal.removeEventListener('abort', onStreamAbort)) + } + } + resolve({ + fields, + file: { fieldName: name, filename: info.filename, mimeType: info.mimeType, stream }, + }) + }) + }) + + bb.on('error', (err) => { + const message = err instanceof Error ? err.message : 'Failed to parse multipart body' + settle(() => reject(new MultipartError('PARSE_ERROR', message))) + }) + + bb.on('close', () => { + if (!fileSeen) { + settle(() => reject(new MultipartError('NO_FILE', 'No file part in multipart body'))) + } + }) + + nodeStream.on('error', (err) => { + settle(() => + reject( + err instanceof MultipartError + ? err + : new MultipartError( + 'PARSE_ERROR', + err instanceof Error ? err.message : 'Failed to read request body' + ) + ) + ) + }) + + nodeStream.pipe(bb) + }) +} diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 6eb786ce7d8..0e6494bbfe4 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -37,6 +37,46 @@ vi.mock('@/connectors/registry', () => ({ }, })) +describe('shouldReconcileDeletions', () => { + it('runs on a clean full listing', async () => { + const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldReconcileDeletions(false, {}, undefined)).toBe(true) + expect(shouldReconcileDeletions(false, undefined, undefined)).toBe(true) + }) + + it('never runs on incremental syncs', async () => { + const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldReconcileDeletions(true, {}, undefined)).toBe(false) + expect(shouldReconcileDeletions(true, {}, true)).toBe(false) + expect(shouldReconcileDeletions(true, { listingCapped: true }, true)).toBe(false) + }) + + it('skips when a connector capped the listing', async () => { + const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldReconcileDeletions(false, { listingCapped: true }, undefined)).toBe(false) + expect(shouldReconcileDeletions(false, { listingCapped: true }, false)).toBe(false) + }) + + it('lets a forced fullSync override a connector cap', async () => { + const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldReconcileDeletions(false, { listingCapped: true }, true)).toBe(true) + }) + + it('never runs when the engine truncated pagination, even on a forced fullSync', async () => { + const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldReconcileDeletions(false, { listingTruncated: true }, undefined)).toBe(false) + expect(shouldReconcileDeletions(false, { listingTruncated: true }, true)).toBe(false) + expect( + shouldReconcileDeletions(false, { listingCapped: true, listingTruncated: true }, true) + ).toBe(false) + }) +}) + describe('resolveTagMapping', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 7ae8cfe38fa..7da5d4b956f 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -128,6 +128,27 @@ async function completeSyncLog( .where(eq(knowledgeConnectorSyncLog.id, syncLogId)) } +/** + * Decides whether deletion reconciliation may run for a sync. + * + * Reconciliation hard-deletes every stored document absent from the listing, + * so it must only run against a complete source set: + * - never on incremental syncs (they list only changed documents) + * - never when the engine truncated pagination (`listingTruncated`) — a forced + * fullSync cannot fix truncation, so it cannot override it + * - not when a connector capped its listing (`listingCapped`), unless a forced + * fullSync deliberately overrides the cap to reconcile the capped scope + */ +export function shouldReconcileDeletions( + isIncremental: boolean | undefined, + syncContext: Record | undefined, + fullSync: boolean | undefined +): boolean { + if (isIncremental) return false + if (syncContext?.listingTruncated) return false + return !syncContext?.listingCapped || Boolean(fullSync) +} + /** * Resolves tag values from connector metadata using the connector's mapTags function. * Translates semantic keys returned by mapTags to actual DB slots using the @@ -415,6 +436,22 @@ export async function executeSync( hasMore = page.hasMore } + if (hasMore) { + /** + * Pagination stopped before source exhaustion (MAX_PAGES or a missing + * cursor), so the listing is incomplete. `listingTruncated` blocks + * deletion reconciliation absolutely — unlike connector-set + * `listingCapped`, it cannot be overridden by a forced fullSync, since + * re-running one truncates identically. + */ + syncContext.listingCapped = true + syncContext.listingTruncated = true + logger.warn('Pagination ended before source exhaustion; skipping deletion reconciliation', { + connectorId, + docsSoFar: externalDocs.length, + }) + } + logger.info(`Fetched ${externalDocs.length} documents from ${connectorConfig.name}`, { connectorId, }) @@ -635,9 +672,7 @@ export async function executeSync( } } - // Reconcile deletions for non-incremental syncs that returned ALL docs. - // Skip when listing was capped (maxFiles/maxThreads) — unseen docs may still exist in the source. - if (!isIncremental && (!syncContext?.listingCapped || options?.fullSync)) { + if (shouldReconcileDeletions(isIncremental, syncContext, options?.fullSync)) { const removedIds = existingDocs .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) .map((d) => d.id) diff --git a/apps/sim/lib/mcp/oauth/revoke.ts b/apps/sim/lib/mcp/oauth/revoke.ts index dc80065a65c..6ec08d63d9c 100644 --- a/apps/sim/lib/mcp/oauth/revoke.ts +++ b/apps/sim/lib/mcp/oauth/revoke.ts @@ -1,4 +1,5 @@ import { discoverOAuthServerInfo } from '@modelcontextprotocol/sdk/client/auth.js' +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,6 +7,7 @@ import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { decryptSecret } from '@/lib/core/security/encryption' import { loadOauthRow } from '@/lib/mcp/oauth/storage' +import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' const logger = createLogger('McpOauthRevoke') const REVOKE_TIMEOUT_MS = 5000 @@ -30,7 +32,10 @@ export async function revokeMcpOauthTokens(mcpServerId: string): Promise { .limit(1) if (!server?.url) return - const info = await discoverOAuthServerInfo(server.url).catch(() => undefined) + const ssrfGuardedFetch = createSsrfGuardedMcpFetch() + const info = await discoverOAuthServerInfo(server.url, { fetchFn: ssrfGuardedFetch }).catch( + () => undefined + ) const metadata = info?.authorizationServerMetadata as | (Record & { revocation_endpoint?: string }) | undefined @@ -60,7 +65,7 @@ export async function revokeMcpOauthTokens(mcpServerId: string): Promise { } for (const { token, hint } of tokensToRevoke) { - await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret) + await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret, ssrfGuardedFetch) } } catch (error) { logger.warn(`Token revocation failed for server ${mcpServerId}`, { @@ -74,7 +79,8 @@ async function postRevoke( token: string, hint: 'refresh_token' | 'access_token', clientId: string, - clientSecret: string | undefined + clientSecret: string | undefined, + fetchFn: FetchLike ): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS) @@ -89,7 +95,7 @@ async function postRevoke( } else { params.set('client_id', clientId) } - const res = await fetch(endpoint, { + const res = await fetchFn(endpoint, { method: 'POST', headers, body: params.toString(), diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 8a4c27be0df..9f6b5919bf2 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -25,12 +25,23 @@ const { mockAgent, mockCreatePinnedLookup, mockUndiciFetch, capturedAgentOptions } }) +const { mockValidateMcpServerSsrf } = vi.hoisted(() => ({ + mockValidateMcpServerSsrf: vi.fn(), +})) + vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) vi.mock('@/lib/core/security/input-validation.server', () => ({ createPinnedLookup: mockCreatePinnedLookup, })) +vi.mock('@/lib/mcp/domain-check', () => ({ + validateMcpServerSsrf: mockValidateMcpServerSsrf, +})) -import { __resetPinnedAgentsForTests, createMcpPinnedFetch } from '@/lib/mcp/pinned-fetch' +import { + __resetPinnedAgentsForTests, + createMcpPinnedFetch, + createSsrfGuardedMcpFetch, +} from '@/lib/mcp/pinned-fetch' describe('createMcpPinnedFetch', () => { beforeEach(() => { @@ -125,3 +136,45 @@ describe('createMcpPinnedFetch', () => { expect(mockUndiciFetch).toHaveBeenCalledTimes(1) }) }) + +describe('createSsrfGuardedMcpFetch', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedAgentOptions.length = 0 + __resetPinnedAgentsForTests() + mockCreatePinnedLookup.mockReturnValue('pinned-lookup-fn') + mockUndiciFetch.mockResolvedValue(new Response('ok')) + }) + + it('validates each request URL and pins to the resolved IP', async () => { + mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10') + const fetchLike = createSsrfGuardedMcpFetch() + await fetchLike('https://attacker.example/revoke', { method: 'POST' }) + + expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke') + expect(mockUndiciFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockUndiciFetch.mock.calls[0] + expect(url).toBe('https://attacker.example/revoke') + expect((init as { dispatcher?: unknown }).dispatcher).toBeInstanceOf(mockAgent) + expect((init as { method?: string }).method).toBe('POST') + }) + + it('rejects URLs that resolve to blocked IPs without issuing the request', async () => { + mockValidateMcpServerSsrf.mockRejectedValue(new Error('blocked')) + const fetchLike = createSsrfGuardedMcpFetch() + + await expect( + fetchLike('http://169.254.169.254/latest/meta-data/', { method: 'POST' }) + ).rejects.toThrow('blocked') + expect(mockUndiciFetch).not.toHaveBeenCalled() + }) + + it('accepts URL objects and validates their href', async () => { + mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10') + const fetchLike = createSsrfGuardedMcpFetch() + await fetchLike(new URL('https://attacker.example/discover')) + + expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover') + expect(mockUndiciFetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index 236518d13ec..f395d6b03c5 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -1,6 +1,7 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' import { createPinnedLookup } from '@/lib/core/security/input-validation.server' +import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' /** * Pins outbound HTTP connections to a pre-resolved IP to prevent DNS-rebinding @@ -54,3 +55,31 @@ export function createMcpPinnedFetch(resolvedIP: string): FetchLike { return response as unknown as Response }) satisfies FetchLike } + +/** + * Builds a `FetchLike` that validates every outbound request URL against the + * MCP SSRF policy before issuing it, then pins the connection to the resolved + * IP. Unlike the live transport — where the server URL is validated once up + * front — OAuth discovery and RFC 7009 revocation follow URLs taken verbatim + * from attacker-controllable authorization-server metadata + * (`authorization_servers`, `token_endpoint`, `revocation_endpoint`, …). Each + * such hop must be re-validated, so this guard runs `validateMcpServerSsrf` + * per request and rejects private/reserved/loopback targets (honoring + * `ALLOWED_MCP_DOMAINS` and self-hosted localhost rules). + * + * Note: a caller-provided `AbortSignal` in `init` only bounds the HTTP request, + * not the validation DNS lookup — Node's `dns.lookup` does not accept a signal, + * so a hanging resolution can extend the overall call past the caller's timeout + * by up to the OS DNS timeout. Acceptable here because all consumers are + * best-effort, non-blocking flows (OAuth discovery and RFC 7009 revocation). + * + * @throws McpSsrfError if a request URL resolves to a blocked IP address + */ +export function createSsrfGuardedMcpFetch(): FetchLike { + return (async (url, init) => { + const target = typeof url === 'string' ? url : url.href + const resolvedIP = await validateMcpServerSsrf(target) + const pinnedFetch: FetchLike = resolvedIP ? createMcpPinnedFetch(resolvedIP) : globalThis.fetch + return pinnedFetch(url, init) + }) satisfies FetchLike +} diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts new file mode 100644 index 00000000000..7eaad52c1b5 --- /dev/null +++ b/apps/sim/lib/monitoring/metrics.ts @@ -0,0 +1,139 @@ +/** + * Hosted-key OTel metrics. + * + * Point events (usage, cost, throttles, queue waits) are emitted as metrics — + * not spans — so they bypass trace sampling and survive aggregation. Reads the + * global MeterProvider, which the Next.js app registers in `instrumentation-node.ts` + * and trigger.dev registers from `trigger.config.ts`; with no provider the API + * returns a no-op meter, so these recorders are always safe to call. + * + * Labels stay low-cardinality (provider, tool, reason, key). `key` is the env var + * NAME of the chosen hosted key (e.g. `PERPLEXITY_API_KEY_2`) — never the secret — + * and the pool is operator-managed, so it's safe to label. Per-workspace/user cost + * lives exactly in the `usage_log` table — never put those on metric labels. + */ + +import { type Counter, type Histogram, metrics } from '@opentelemetry/api' + +const METER_NAME = 'sim.hosted-key' +const METER_VERSION = '1.0.0' + +type ThrottleReason = 'billing_actor_limit' | 'upstream_retries_exhausted' +type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' +type FailureReason = 'rate_limited' | 'auth' | 'other' + +let meter: ReturnType | undefined +let usedCounter: Counter | undefined +let failedCounter: Counter | undefined +let costCounter: Counter | undefined +let throttledCounter: Counter | undefined +let upstreamRateLimitedCounter: Counter | undefined +let queueWaitHistogram: Histogram | undefined +let queueWaitExceededCounter: Counter | undefined +let unknownModelCostCounter: Counter | undefined + +function getMeter() { + if (!meter) meter = metrics.getMeter(METER_NAME, METER_VERSION) + return meter +} + +function getUsedCounter() { + if (!usedCounter) { + usedCounter = getMeter().createCounter('hosted_key.used', { + description: 'Successful tool executions backed by a Sim-hosted API key', + }) + } + return usedCounter +} + +function getFailedCounter() { + if (!failedCounter) { + failedCounter = getMeter().createCounter('hosted_key.failed', { + description: 'Failed tool executions backed by a Sim-hosted API key', + }) + } + return failedCounter +} + +function getCostCounter() { + if (!costCounter) { + costCounter = getMeter().createCounter('hosted_key.cost_charged', { + description: 'Dollar cost charged to the billing actor for hosted-key usage', + unit: 'USD', + }) + } + return costCounter +} + +function getThrottledCounter() { + if (!throttledCounter) { + throttledCounter = getMeter().createCounter('hosted_key.throttled', { + description: 'Rate-limit errors surfaced to the end user (not retried/absorbed)', + }) + } + return throttledCounter +} + +function getUpstreamRateLimitedCounter() { + if (!upstreamRateLimitedCounter) { + upstreamRateLimitedCounter = getMeter().createCounter('hosted_key.upstream_rate_limited', { + description: 'Upstream provider 429s absorbed via retry/backoff', + }) + } + return upstreamRateLimitedCounter +} + +function getQueueWaitHistogram() { + if (!queueWaitHistogram) { + queueWaitHistogram = getMeter().createHistogram('hosted_key.queue_wait_duration', { + description: 'Time a hosted-key acquisition spent waiting in the per-workspace queue/bucket', + unit: 'ms', + }) + } + return queueWaitHistogram +} + +function getQueueWaitExceededCounter() { + if (!queueWaitExceededCounter) { + queueWaitExceededCounter = getMeter().createCounter('hosted_key.queue_wait_exceeded', { + description: 'Hosted-key acquisitions that exceeded the queue wait cap and fell back to 429', + }) + } + return queueWaitExceededCounter +} + +function getUnknownModelCostCounter() { + if (!unknownModelCostCounter) { + unknownModelCostCounter = getMeter().createCounter('hosted_key.unknown_model_cost', { + description: 'Hosted-key cost calculations that fell back to a default for an unmapped model', + }) + } + return unknownModelCostCounter +} + +export const hostedKeyMetrics = { + recordUsed(labels: { provider: string; tool: string; key: string }) { + getUsedCounter().add(1, labels) + }, + recordFailed(labels: { provider: string; tool: string; key: string; reason: FailureReason }) { + getFailedCounter().add(1, labels) + }, + recordCostCharged(costUsd: number, labels: { provider: string; tool: string }) { + if (costUsd > 0) getCostCounter().add(costUsd, labels) + }, + recordThrottled(labels: { provider: string; tool: string; reason: ThrottleReason }) { + getThrottledCounter().add(1, labels) + }, + recordUpstreamRateLimited(labels: { tool: string; key: string }) { + getUpstreamRateLimitedCounter().add(1, labels) + }, + recordQueueWait(durationMs: number, labels: { provider: string; reason: QueueReason }) { + getQueueWaitHistogram().record(durationMs, labels) + }, + recordQueueWaitExceeded(labels: { provider: string; reason: QueueReason }) { + getQueueWaitExceededCounter().add(1, labels) + }, + recordUnknownModelCost(labels: { tool: string }) { + getUnknownModelCostCounter().add(1, labels) + }, +} diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index c86a1c92c5c..952b5d5aec8 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -17,6 +17,12 @@ import { getUniqueColumns } from '@/lib/table/validation' vi.mock('@sim/db', () => dbChainMock) +// These suites assert flag-off position-shift semantics; pin the flag so they're +// deterministic regardless of a local TABLES_FRACTIONAL_ORDERING env value. +vi.mock('@/lib/core/config/feature-flags', () => ({ + isTablesFractionalOrderingEnabled: false, +})) + vi.mock('@/lib/table/validation', () => ({ validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 00597130b71..04084ed8217 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -108,6 +108,13 @@ export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i export const USER_TABLE_ROWS_SQL_NAME = 'user_table_rows' +/** + * CSV/TSV uploads at or above this size import in the background (direct-to-storage + * upload + async worker) instead of being POSTed through the server. Kept safely under + * the Next.js proxy request-body cap (10MB) so a synchronous upload is never truncated. + */ +export const CSV_ASYNC_IMPORT_THRESHOLD_BYTES = 8 * 1024 * 1024 + const TABLE_NAME_ADJECTIVES = [ 'Radiant', 'Luminous', diff --git a/apps/sim/lib/table/events.ts b/apps/sim/lib/table/events.ts index dd35e3a799e..24156409a16 100644 --- a/apps/sim/lib/table/events.ts +++ b/apps/sim/lib/table/events.ts @@ -113,6 +113,21 @@ export type TableEvent = * skip capped dispatches (see `resolveCellExec`). */ limit?: { type: 'rows'; max: number } } + | { + /** Async large-import progress. The background import worker emits + * `importing` ticks as batches commit, then a terminal `ready`/`failed`. + * The client reveals the (hidden) rows on `ready` and shows a failure + * badge on `failed`. See `apps/sim/lib/table/import-runner.ts`. */ + kind: 'import' + tableId: string + importId: string + status: 'importing' | 'ready' | 'failed' | 'canceled' + /** Rows committed so far (importing) or in total (ready). */ + progress?: number + /** Byte-based completion percent (0–100) — exact and monotonic, for the determinate bar. */ + percent?: number + error?: string + } | { /** A dispatch was stopped because the billed account is over its usage * limit. The client surfaces an upgrade prompt and redirects to billing. diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts new file mode 100644 index 00000000000..0d60d6586c1 --- /dev/null +++ b/apps/sim/lib/table/import-runner.ts @@ -0,0 +1,308 @@ +import { type Readable, Transform } from 'node:stream' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { + buildAutoMapping, + CSV_MAX_BATCH_SIZE, + CSV_SCHEMA_SAMPLE_SIZE, + type CsvHeaderMapping, + coerceRowsForTable, + createCsvParser, + inferColumnType, + inferSchemaFromCsv, + sanitizeName, + type TableSchema, + validateMapping, +} from '@/lib/table' +import { appendTableEvent } from '@/lib/table/events' +import { + addImportColumns, + bulkInsertImportBatch, + deleteAllTableRows, + getTableById, + markImportFailed, + markImportReady, + nextImportStartOrderKey, + nextImportStartPosition, + setTableSchemaForImport, + updateImportProgress, +} from '@/lib/table/service' +import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' +import { normalizeColumn } from '@/app/api/table/utils' + +const logger = createLogger('TableImportRunner') + +/** Emit a progress event / DB update at most every this many rows. */ +const PROGRESS_INTERVAL_ROWS = 5000 + +/** + * Thrown when this worker discovers it no longer owns the table's import (the stale-job janitor + * marked its run failed and a newer import took over). The worker stops inserting rather than + * writing into a table a second worker now owns. + */ +class ImportSupersededError extends Error {} + +/** `create` infers a schema for a new table; `append`/`replace` map onto an existing one. */ +export type TableImportMode = 'create' | 'append' | 'replace' + +export interface TableImportPayload { + importId: string + tableId: string + workspaceId: string + userId: string + /** Storage key of the already-uploaded CSV/TSV file. */ + fileKey: string + fileName: string + delimiter: ',' | '\t' + mode: TableImportMode + /** (append/replace) Explicit CSV-header → column mapping; auto-mapped when omitted. */ + mapping?: CsvHeaderMapping + /** (append/replace) CSV headers to auto-create as new columns (types inferred from the sample). */ + createColumns?: string[] +} + +/** + * Background worker for large CSV/TSV imports. Runs detached on the web container + * (see the kickoff routes). Streams the stored file through `createCsvParser`, resolves + * the target schema + header→column mapping from the first sample (inferring a new schema + * for `create`, mapping onto the existing schema for `append`/`replace`), then bulk-inserts + * in committed batches — **no rollback**: committed batches persist even if a later batch + * fails. Progress and the terminal state are surfaced via the table-events SSE stream. + */ +export async function runTableImport(payload: TableImportPayload): Promise { + const { importId, tableId, workspaceId, userId, fileKey, fileName, delimiter, mode } = payload + const requestId = generateId().slice(0, 8) + // Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks + // open until it times out. + let source: Readable | undefined + + try { + const loaded = await getTableById(tableId, { includeArchived: true }) + if (!loaded) throw new Error(`Import target table ${tableId} not found`) + const table = loaded + + // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on + // the local dev provider, in which case the bar stays indeterminate (rows still show). + const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0 + + // Stream the file rather than buffering it — a ~1M-row import must never be held in memory. + source = await downloadFileStream({ key: fileKey, context: 'workspace' }) + + // Append must continue after the existing rows; create/replace start empty. Read once up + // front (the import is the table's sole writer) and assign contiguous positions / threaded + // order keys from it. + const basePosition = mode === 'append' ? await nextImportStartPosition(tableId) : 0 + let lastOrderKey = mode === 'append' ? await nextImportStartOrderKey(tableId) : null + + // Count bytes as they flow so the row total can be extrapolated from byte progress. + let bytesRead = 0 + const byteCounter = new Transform({ + transform(chunk: Buffer, _enc, cb) { + bytesRead += chunk.length + cb(null, chunk) + }, + }) + + const parser = createCsvParser(delimiter) + // `.pipe` doesn't forward source errors; forward so the iterator throws. + source.on('error', (err) => parser.destroy(err)) + byteCounter.on('error', (err) => parser.destroy(err)) + source.pipe(byteCounter).pipe(parser) + + let schema: TableSchema | null = null + let headerToColumn: Map | null = null + let inserted = 0 + let lastReported = 0 + const sample: Record[] = [] + let batch: Record[] = [] + + /** + * Resolve the schema + header→column mapping from the buffered sample (runs once). + * `create` infers a fresh schema and overwrites the placeholder; `append`/`replace` + * map onto the existing schema, optionally auto-creating `createColumns` first. + */ + const resolveSetup = async () => { + const headers = Object.keys(sample[0]) + + if (mode === 'create') { + const inferred = inferSchemaFromCsv(headers, sample) + schema = { columns: inferred.columns.map(normalizeColumn) } + headerToColumn = inferred.headerToColumn + await setTableSchemaForImport(tableId, schema) + return + } + + // append / replace into an existing table. + let targetSchema = table.schema + let effectiveMapping: CsvHeaderMapping = + payload.mapping ?? buildAutoMapping(headers, table.schema) + + if (payload.createColumns && payload.createColumns.length > 0) { + const unknown = payload.createColumns.filter((h) => !headers.includes(h)) + if (unknown.length > 0) { + throw new Error(`Columns to create are not in the CSV: ${unknown.join(', ')}`) + } + const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) + const additions: { name: string; type: string }[] = [] + const updatedMapping: CsvHeaderMapping = { ...effectiveMapping } + for (const header of payload.createColumns) { + const base = sanitizeName(header) + let columnName = base + let suffix = 2 + while (usedNames.has(columnName.toLowerCase())) { + columnName = `${base}_${suffix}` + suffix++ + } + usedNames.add(columnName.toLowerCase()) + additions.push({ name: columnName, type: inferColumnType(sample.map((r) => r[header])) }) + updatedMapping[header] = columnName + } + const updated = await addImportColumns(table, additions, requestId) + targetSchema = updated.schema + effectiveMapping = updatedMapping + } + + const validation = validateMapping({ + csvHeaders: headers, + mapping: effectiveMapping, + tableSchema: targetSchema, + }) + schema = targetSchema + headerToColumn = validation.effectiveMap + + // Replace deletes existing rows only after schema/mapping validation passes, so an + // invalid or empty file fails the import with the old rows still intact (a mid-stream + // insert failure after this point leaves a partial replace — replace is destructive). + if (mode === 'replace') await deleteAllTableRows(tableId) + } + + const flush = async (rows: Record[]) => { + if (rows.length === 0 || !schema || !headerToColumn) return + // Ownership gate before every insert: once this run loses the table (cancel/supersede), + // updateImportProgress returns false and we stop before writing into a table a newer import + // may own. Runs per batch (not just at the emit cadence) so we stop within one batch. + const owns = await updateImportProgress(tableId, inserted, importId) + if (!owns) throw new ImportSupersededError() + const coerced = coerceRowsForTable(rows, schema, headerToColumn) + const result = await bulkInsertImportBatch( + { + tableId, + workspaceId, + userId, + rows: coerced, + startPosition: basePosition + inserted, + afterOrderKey: lastOrderKey, + }, + { ...table, schema }, + requestId + ) + inserted += result.inserted + lastOrderKey = result.lastOrderKey + // Emit after the first batch, then every interval, so the bar appears early without flooding. + if ( + inserted - lastReported >= PROGRESS_INTERVAL_ROWS || + (lastReported === 0 && inserted > 0) + ) { + lastReported = inserted + // Exact, monotonic completion from bytes consumed — no wobbly row estimate. + const percent = + totalBytes > 0 ? Math.min(99, Math.round((bytesRead / totalBytes) * 100)) : undefined + void appendTableEvent({ + kind: 'import', + tableId, + importId, + status: 'importing', + progress: inserted, + percent, + }) + } + } + + let ready = false + for await (const record of parser as AsyncIterable>) { + if (!ready) { + sample.push(record) + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + await resolveSetup() + await flush(sample) + ready = true + } + continue + } + batch.push(record) + if (batch.length >= CSV_MAX_BATCH_SIZE) { + await flush(batch) + batch = [] + } + } + + if (!ready) { + // Fewer than CSV_SCHEMA_SAMPLE_SIZE rows total (or zero). + if (sample.length === 0) { + // No data rows — fail rather than report a successful empty import (matches the sync route). + const message = 'CSV file has no data rows' + await markImportFailed(tableId, importId, message) + void appendTableEvent({ + kind: 'import', + tableId, + importId, + status: 'failed', + error: message, + }) + logger.warn(`[${requestId}] Import has no data rows`, { tableId, fileName }) + return + } + await resolveSetup() + await flush(sample) + } else { + await flush(batch) + } + + await updateImportProgress(tableId, inserted, importId) + // Only announce success if we actually won the transition — a cancel/supersede that landed + // right at the end makes this a no-op, and we must not emit a false `ready`. + const becameReady = await markImportReady(tableId, importId) + if (becameReady) { + void appendTableEvent({ + kind: 'import', + tableId, + importId, + status: 'ready', + progress: inserted, + percent: 100, + }) + logger.info(`[${requestId}] Import complete`, { tableId, fileName, mode, rows: inserted }) + } else { + logger.info( + `[${requestId}] Import finished but no longer owns the run (canceled/superseded)`, + { + tableId, + importId, + } + ) + } + } catch (err) { + if (err instanceof ImportSupersededError) { + // A newer import owns the table now — leave its status alone and just stop. + logger.info(`[${requestId}] Import superseded by a newer run; stopping`, { + tableId, + importId, + }) + } else { + const message = getErrorMessage(err, 'Import failed') + logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) + // Scoped to importId — a no-op if a newer import has taken over. + await markImportFailed(tableId, importId, message).catch(() => {}) + void appendTableEvent({ kind: 'import', tableId, importId, status: 'failed', error: message }) + } + } finally { + // Release the storage stream so its HTTP connection doesn't leak on failure. + source?.destroy() + // The uploaded source file is single-use (a fresh upload per import) — delete it once the + // import is terminal so the workspace bucket doesn't accumulate. Best-effort. + await deleteFile({ key: fileKey, context: 'workspace' }).catch((err) => { + logger.warn(`[${requestId}] Failed to delete imported file`, { fileKey, err }) + }) + } +} diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 65d16073012..d25ee031e0e 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -1,12 +1,15 @@ /** * @vitest-environment node */ +import { Readable } from 'node:stream' import { describe, expect, it } from 'vitest' import { buildAutoMapping, CsvImportValidationError, coerceRowsForTable, coerceValue, + createCsvParser, + csvParseOptions, inferColumnType, inferSchemaFromCsv, parseCsvBuffer, @@ -274,4 +277,43 @@ describe('import', () => { expect(rows).toEqual([{ name: 'Alice' }]) }) }) + + describe('createCsvParser', () => { + async function parseViaStream(csv: string, delimiter = ',') { + const parser = createCsvParser(delimiter) + Readable.from([csv]).pipe(parser) + const rows: Record[] = [] + for await (const record of parser as AsyncIterable>) { + rows.push(record) + } + return rows + } + + it('streams records keyed by header, matching parseCsvBuffer', async () => { + const csv = 'name,age\nAlice,30\nBob,40\n' + const streamed = await parseViaStream(csv) + const { rows: buffered } = await parseCsvBuffer(csv) + expect(streamed).toEqual(buffered) + expect(streamed).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '40' }, + ]) + }) + + it('honors a TSV delimiter', async () => { + const rows = await parseViaStream('name\tage\nAlice\t30\n', '\t') + expect(rows).toEqual([{ name: 'Alice', age: '30' }]) + }) + + it('strips a leading UTF-8 BOM', async () => { + const rows = await parseViaStream('name,age\nAlice,30\n') + expect(Object.keys(rows[0])).toEqual(['name', 'age']) + }) + }) + + describe('csvParseOptions', () => { + it('sets columns, bom, and the delimiter', () => { + expect(csvParseOptions('\t')).toMatchObject({ columns: true, bom: true, delimiter: '\t' }) + }) + }) }) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 23566c145d5..843edd3d7a6 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -2,15 +2,47 @@ * Shared CSV import helpers for user-defined tables. * * Used by: - * - `POST /api/table/import-csv` (create new table from CSV) + * - `POST /api/table/import-csv` (create new table from CSV — streams via {@link createCsvParser}) * - `POST /api/table/[tableId]/import` (append/replace into existing table) - * - Copilot `user-table` tool (`create_from_file`, `import_file`) + * - Copilot `user-table` tool (`create_from_file`, `import_file` — buffers via {@link parseCsvBuffer}) * * Keeping a single implementation avoids drift between HTTP and agent code paths. + * Both the buffered ({@link parseCsvBuffer}) and streaming ({@link createCsvParser}) + * parsers share {@link csvParseOptions} so their behavior can't drift. */ +import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' +/** + * Single source of truth for the `csv-parse` options used by both the buffered + * sync parser and the streaming parser. `columns: true` emits each record as an + * object keyed by the (first-row) headers. + */ +export function csvParseOptions(delimiter = ','): CsvParseOptions { + return { + columns: true, + skip_empty_lines: true, + trim: true, + relax_column_count: true, + relax_quotes: true, + skip_records_with_error: true, + cast: false, + bom: true, + delimiter, + } +} + +/** + * Returns a streaming `csv-parse` parser (a `Transform`/async-iterable). Pipe a + * file stream into it and iterate records with `for await`; backpressure flows + * back to the source while each record is processed. Use this for HTTP uploads + * so the file is never fully buffered in memory. + */ +export function createCsvParser(delimiter = ','): Parser { + return parseCsvStream(csvParseOptions(delimiter)) +} + /** Narrower type than `COLUMN_TYPES` used internally for coercion. */ export type CsvColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' @@ -53,8 +85,10 @@ export class CsvImportValidationError extends Error { /** * Parses a CSV/TSV payload using `csv-parse/sync`. Accepts a Node `Buffer`, - * browser-friendly `Uint8Array`, or already-decoded string. Strips a leading - * UTF-8 BOM so headers are not silently prefixed with `\uFEFF`. + * browser-friendly `Uint8Array`, or already-decoded string. A leading UTF-8 BOM + * is stripped by csv-parse (`bom: true` in {@link csvParseOptions}). + * + * For HTTP uploads prefer {@link createCsvParser} so the file isn't buffered. */ export async function parseCsvBuffer( input: Buffer | Uint8Array | string, @@ -70,18 +104,10 @@ export async function parseCsvBuffer( } else { text = new TextDecoder('utf-8').decode(input as Uint8Array) } - text = text.replace(/^\uFEFF/, '') - const parsed = parse(text, { - columns: true, - skip_empty_lines: true, - trim: true, - relax_column_count: true, - relax_quotes: true, - skip_records_with_error: true, - cast: false, - delimiter, - }) as Record[] + // double-cast-allowed: shared csvParseOptions() loses the `columns: true` literal that drives + // csv-parse's record-vs-string[][] overload, but `columns: true` is always set so records are objects + const parsed = parse(text, csvParseOptions(delimiter)) as unknown as Record[] if (parsed.length === 0) { throw new Error('CSV file has no data rows') @@ -389,3 +415,86 @@ export function coerceRowsForTable( return coerced }) } + +/** + * Sanitizes raw JSON keys so they conform to the same column-name rules as CSV + * headers, letting `inferSchemaFromCsv` and `coerceRowsForTable` be reused for + * JSON imports. Collisions after sanitization are disambiguated with a trailing + * underscore. Returns the headers and rows untouched when no key needs renaming. + */ +export function sanitizeJsonHeaders( + headers: string[], + rows: Record[] +): { headers: string[]; rows: Record[] } { + const renamed = new Map() + const seen = new Set() + + for (const raw of headers) { + let safe = sanitizeName(raw) + while (seen.has(safe)) safe = `${safe}_` + seen.add(safe) + renamed.set(raw, safe) + } + + const noChange = headers.every((h) => renamed.get(h) === h) + if (noChange) return { headers, rows } + + return { + headers: headers.map((h) => renamed.get(h)!), + rows: rows.map((row) => { + const out: Record = {} + for (const [raw, safe] of renamed) { + if (raw in row) out[safe] = row[raw] + } + return out + }), + } +} + +/** + * Parses a JSON payload that must be an array of plain objects into the same + * `{ headers, rows }` shape produced by `parseCsvBuffer`. The header set is the + * union of all object keys, sanitized via {@link sanitizeJsonHeaders}. + */ +export function parseJsonRows(buffer: Buffer | string): { + headers: string[] + rows: Record[] +} { + const text = typeof buffer === 'string' ? buffer : buffer.toString('utf-8') + const parsed = JSON.parse(text) + if (!Array.isArray(parsed)) { + throw new Error('JSON file must contain an array of objects') + } + if (parsed.length === 0) { + throw new Error('JSON file contains an empty array') + } + const headerSet = new Set() + for (const row of parsed) { + if (typeof row !== 'object' || row === null || Array.isArray(row)) { + throw new Error('Each element in the JSON array must be a plain object') + } + for (const key of Object.keys(row)) headerSet.add(key) + } + return sanitizeJsonHeaders([...headerSet], parsed) +} + +/** + * Parses a tabular upload (CSV, TSV, or JSON array-of-objects) into a uniform + * `{ headers, rows }` shape, dispatching on file extension and falling back to + * the MIME content type. Throws on unsupported formats so callers fail fast. + */ +export async function parseFileRows( + buffer: Buffer, + fileName: string, + contentType?: string +): Promise<{ headers: string[]; rows: Record[] }> { + const ext = fileName.split('.').pop()?.toLowerCase() + if (ext === 'json' || contentType === 'application/json') { + return parseJsonRows(buffer) + } + if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') { + const delimiter = ext === 'tsv' ? '\t' : ',' + return parseCsvBuffer(buffer, delimiter) + } + throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`) +} diff --git a/apps/sim/lib/table/order-key.ts b/apps/sim/lib/table/order-key.ts new file mode 100644 index 00000000000..49c63857074 --- /dev/null +++ b/apps/sim/lib/table/order-key.ts @@ -0,0 +1,33 @@ +/** + * Fractional order keys for table-row ordering. + * + * A row's order is a base-62 string key, not an integer position. Inserting + * between two rows mints a key strictly between their keys, so no other row's + * key changes — insert and delete become O(1) (no position reshift / recompact). + * + * Thin wrapper over `fractional-indexing` (Figma/rocicorp algorithm) so the + * implementation is swappable. Keys never run out (variable-length strings); + * the only cost is gradual length growth under repeated same-spot inserts. + */ + +import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing' + +/** + * Returns a key that sorts strictly between `a` and `b`. Pass `null` for an open + * end: `keyBetween(null, first)` prepends, `keyBetween(last, null)` appends, + * `keyBetween(null, null)` is the first key in an empty table. + * + * @throws if `a >= b` (callers must pass ordered, distinct bounds) + */ +export function keyBetween(a: string | null, b: string | null): string { + return generateKeyBetween(a, b) +} + +/** + * Returns `n` keys evenly spaced strictly between `a` and `b` (same open-end + * semantics as {@link keyBetween}). Used for batch inserts and the backfill + * (`nKeysBetween(null, null, count)` mints an ordered run for an empty range). + */ +export function nKeysBetween(a: string | null, b: string | null, n: number): string[] { + return generateNKeysBetween(a, b, n) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 280a8453404..4288e43308f 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -17,13 +17,30 @@ import { import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, gt, gte, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { + and, + asc, + count, + desc, + eq, + gt, + gte, + inArray, + isNull, + ne, + or, + type SQL, + sql, +} from 'drizzle-orm' +import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/feature-flags' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants' import { areGroupDepsSatisfied } from './deps' +import { CSV_MAX_BATCH_SIZE } from './import' +import { keyBetween, nKeysBetween } from './order-key' import { buildFilterClause, buildSortClause } from './sql' import { fireTableTrigger } from './trigger' import type { @@ -112,22 +129,6 @@ async function setTableTxTimeouts( await trx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${i}ms'`)) } -/** - * Serializes writers that compute `max(position) + 1` for the same table. - * - * The row-count trigger (migration 0198) serializes capacity via a row lock on - * `user_table_definitions` — but it fires AFTER INSERT, so two concurrent - * auto-positioned inserts can read the same snapshot and assign the same - * position (the `(table_id, position)` index is non-unique). This advisory - * lock restores the pre-trigger serialization scoped to a single table, with - * no cross-table contention. Released automatically at COMMIT/ROLLBACK. - */ -async function acquireTablePositionLock(trx: DbTransaction, tableId: string) { - await trx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))` - ) -} - /** * Serializes schema/metadata read-modify-writes for a single table so * concurrent mutators can't clobber each other's `schema` JSONB @@ -162,12 +163,12 @@ async function withLockedTable( } /** - * Returns the next auto-assigned `position` for a table (max(position) + 1, or 0 - * if empty). Callers must hold `acquireTablePositionLock` to avoid two concurrent - * writers computing the same value against the same snapshot. + * Starting `position` for an append import — `max(position) + 1`, or 0 when empty. Read once, + * unlocked, before streaming: the import worker is the table's sole writer, so it can assign + * contiguous positions from this offset without per-batch position scans. */ -async function nextAutoPosition(trx: DbTransaction, tableId: string): Promise { - const [{ maxPos }] = await trx +export async function nextImportStartPosition(tableId: string): Promise { + const [{ maxPos }] = await db .select({ maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), }) @@ -176,6 +177,15 @@ async function nextAutoPosition(trx: DbTransaction, tableId: string): Promise { + return maxOrderKey(db, tableId) +} + const TIMEOUT_CAP_MS = 10 * 60_000 /** @@ -252,6 +262,11 @@ export async function getTableById( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, + importStatus: userTableDefinitions.importStatus, + importId: userTableDefinitions.importId, + importError: userTableDefinitions.importError, + importRowsProcessed: userTableDefinitions.importRowsProcessed, + importStartedAt: userTableDefinitions.importStartedAt, }) .from(userTableDefinitions) .where( @@ -278,6 +293,11 @@ export async function getTableById( archivedAt: table.archivedAt, createdAt: table.createdAt, updatedAt: table.updatedAt, + importStatus: table.importStatus as TableDefinition['importStatus'], + importId: table.importId, + importError: table.importError, + importRowsProcessed: table.importRowsProcessed, + importStartedAt: table.importStartedAt, } } @@ -319,6 +339,11 @@ export async function listTables( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, + importStatus: userTableDefinitions.importStatus, + importId: userTableDefinitions.importId, + importError: userTableDefinitions.importError, + importRowsProcessed: userTableDefinitions.importRowsProcessed, + importStartedAt: userTableDefinitions.importStartedAt, }) .from(userTableDefinitions) .where( @@ -351,6 +376,11 @@ export async function listTables( archivedAt: t.archivedAt, createdAt: t.createdAt, updatedAt: t.updatedAt, + importStatus: t.importStatus as TableDefinition['importStatus'], + importId: t.importId, + importError: t.importError, + importRowsProcessed: t.importRowsProcessed, + importStartedAt: t.importStartedAt, } }) } @@ -397,6 +427,9 @@ export async function createTable( archivedAt: null, createdAt: now, updatedAt: now, + importStatus: data.importStatus ?? null, + importId: data.importId ?? null, + importStartedAt: data.importStatus ? now : null, } // Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE @@ -440,11 +473,13 @@ export async function createTable( const initialRowCount = data.initialRowCount ?? 0 if (initialRowCount > 0) { + const orderKeys = nKeysBetween(null, null, initialRowCount) const rowsToInsert = Array.from({ length: initialRowCount }, (_, i) => ({ id: `row_${generateId().replace(/-/g, '')}`, tableId, data: {}, position: i, + orderKey: orderKeys[i], workspaceId: data.workspaceId, createdAt: now, updatedAt: now, @@ -477,6 +512,10 @@ export async function createTable( archivedAt: newTable.archivedAt, createdAt: newTable.createdAt, updatedAt: newTable.updatedAt, + importStatus: newTable.importStatus as TableDefinition['importStatus'], + importId: newTable.importId, + importRowsProcessed: 0, + importStartedAt: newTable.importStartedAt, } } @@ -932,6 +971,525 @@ export async function restoreTable(tableId: string, requestId: string): Promise< logger.info(`[${requestId}] Restored table ${tableId} as "${attemptedRestoreName}"`) } +/** + * Loads `tableRowExecutions` rows for the given row ids and groups them into a + * `Map` suitable for plugging into `TableRow.executions`. + */ +async function loadExecutionsByRow( + trx: DbOrTx, + rowIds: Iterable +): Promise> { + const ids = Array.from(new Set(rowIds)) + const result = new Map() + if (ids.length === 0) return result + const rows = await trx + .select() + .from(tableRowExecutions) + .where(inArray(tableRowExecutions.rowId, ids)) + for (const r of rows) { + const existing = result.get(r.rowId) ?? {} + const meta: RowExecutionMetadata = { + status: r.status as RowExecutionMetadata['status'], + executionId: r.executionId ?? null, + jobId: r.jobId ?? null, + workflowId: r.workflowId, + error: r.error ?? null, + ...(r.runningBlockIds && r.runningBlockIds.length > 0 + ? { runningBlockIds: r.runningBlockIds } + : {}), + ...(r.blockErrors && Object.keys(r.blockErrors as Record).length > 0 + ? { blockErrors: r.blockErrors as Record } + : {}), + ...(r.cancelledAt ? { cancelledAt: r.cancelledAt.toISOString() } : {}), + } + existing[r.groupId] = meta + result.set(r.rowId, existing) + } + return result +} + +/** Convenience: load executions for one row, returning `{}` when missing. */ +async function loadExecutionsForRow(trx: DbOrTx, rowId: string): Promise { + const byRow = await loadExecutionsByRow(trx, [rowId]) + return byRow.get(rowId) ?? {} +} + +/** + * Serializes writers that assign `position` for the same table. The row-count + * trigger (migration 0198) serializes capacity via a row lock on + * `user_table_definitions`, but it fires AFTER INSERT, so two concurrent + * auto-positioned inserts could read the same snapshot and assign the same + * position (the `(table_id, position)` index is non-unique). This advisory lock + * restores per-table serialization. Released at COMMIT/ROLLBACK. + */ +async function acquireRowOrderLock(trx: DbTransaction, tableId: string) { + await trx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))` + ) +} + +/** Next append position for a table (max(position) + 1, or 0 if empty). */ +async function nextRowPosition(trx: DbTransaction, tableId: string): Promise { + const [{ maxPos }] = await trx + .select({ + maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), + }) + .from(userTableRows) + .where(eq(userTableRows.tableId, tableId)) + return maxPos + 1 +} + +/** Largest `order_key` for a table, or `null` when empty — the append anchor for new keys. */ +async function maxOrderKey(executor: DbOrTx, tableId: string): Promise { + const [{ maxKey }] = await executor + .select({ maxKey: sql`max(${userTableRows.orderKey})` }) + .from(userTableRows) + .where(eq(userTableRows.tableId, tableId)) + return maxKey ?? null +} + +/** Shifts every row at or after `position` up by one (`position + 1`). */ +async function shiftRowsUpFrom(trx: DbTransaction, tableId: string, position: number) { + await trx + .update(userTableRows) + .set({ position: sql`position + 1` }) + .where(and(eq(userTableRows.tableId, tableId), gte(userTableRows.position, position))) +} + +/** Shifts every row after `position` down by one (`position - 1`). */ +async function shiftRowsDownAfter(trx: DbTransaction, tableId: string, position: number) { + await trx + .update(userTableRows) + .set({ position: sql`position - 1` }) + .where(and(eq(userTableRows.tableId, tableId), gt(userTableRows.position, position))) +} + +/** + * Reserves the `position` for a single inserted row and returns where to INSERT. + * Acquires the row-order lock, then opens a slot at `requestedPosition` (shifting + * the occupant + tail up) or computes the append position. Caller runs inside a + * transaction. + */ +async function reserveInsertPosition( + trx: DbTransaction, + tableId: string, + requestedPosition?: number +): Promise { + await acquireRowOrderLock(trx, tableId) + if (requestedPosition === undefined) { + return nextRowPosition(trx, tableId) + } + const [existing] = await trx + .select({ id: userTableRows.id }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), eq(userTableRows.position, requestedPosition))) + .limit(1) + if (existing) { + await shiftRowsUpFrom(trx, tableId, requestedPosition) + } + return requestedPosition +} + +/** + * Reserves positions for a batch of `count` rows. Opens each requested slot + * (ascending, preserving prior gaps) and returns the requested positions in + * original order; otherwise returns a contiguous append range. + */ +async function reserveBatchPositions( + trx: DbTransaction, + tableId: string, + count: number, + requestedPositions?: number[] +): Promise { + await acquireRowOrderLock(trx, tableId) + if (requestedPositions && requestedPositions.length > 0) { + for (const pos of [...requestedPositions].sort((a, b) => a - b)) { + await shiftRowsUpFrom(trx, tableId, pos) + } + return requestedPositions + } + const start = await nextRowPosition(trx, tableId) + return Array.from({ length: count }, (_, i) => start + i) +} + +/** + * Recompacts row positions to be contiguous after a bulk delete. With + * `minDeletedPos`, only rows at/after it are re-numbered; single-row deletes use + * the cheaper {@link shiftRowsDownAfter}. + */ +async function compactPositions(trx: DbTransaction, tableId: string, minDeletedPos?: number) { + if (minDeletedPos === undefined) { + await trx.execute(sql` + UPDATE user_table_rows t + SET position = r.new_pos + FROM ( + SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos + FROM user_table_rows + WHERE table_id = ${tableId} + ) r + WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos + `) + return + } + await trx.execute(sql` + UPDATE user_table_rows t + SET position = r.new_pos + FROM ( + SELECT id, ${minDeletedPos}::int + ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos + FROM user_table_rows + WHERE table_id = ${tableId} AND position >= ${minDeletedPos} + ) r + WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos + `) +} + +/** A row value ready to INSERT into `user_table_rows`, with its assigned order. */ +export interface OrderedRowValue { + id: string + tableId: string + workspaceId: string + data: RowData + position: number + orderKey: string + createdAt: Date + updatedAt: Date + createdBy?: string +} + +/** + * Builds INSERT values for a contiguous run of rows, assigning sequential + * positions `startPosition + i` and the supplied `orderKeys[i]`. Centralizes + * row assignment for callers that write a fresh ordered run (e.g. the copilot + * tool's replace-all write). `orderKeys` must be index-aligned with `rows` — + * mint them once for the whole run with {@link nKeysBetween}. + */ +export function buildOrderedRowValues(opts: { + tableId: string + workspaceId: string + rows: RowData[] + startPosition: number + orderKeys: string[] + now: Date + createdBy?: string + makeId: () => string +}): OrderedRowValue[] { + const { tableId, workspaceId, rows, startPosition, orderKeys, now, createdBy, makeId } = opts + return rows.map((data, i) => ({ + id: makeId(), + tableId, + workspaceId, + data, + position: startPosition + i, + orderKey: orderKeys[i], + createdAt: now, + updatedAt: now, + ...(createdBy ? { createdBy } : {}), + })) +} + +/** + * Computes the fractional `order_key` for a row inserted at the integer + * `requestedPosition` (or appended when omitted). Used by position-based callers + * (mothership tool, v1 API, undo position-fallback, transient old clients). + * + * The neighbor at slot `s` is resolved differently per flag state: + * - **off**: `WHERE position = s` (positions are contiguous, so the row at + * position `s` is the `s`-th row — an indexed O(1) lookup). + * - **on**: the `s`-th row in `order_key, id` order (`OFFSET s`) — positions are + * gappy and non-authoritative, so `position = s` would miss; the visual + * ordinal is the key's ordinal. O(s), acceptable for these low-volume callers. + * + * Caller holds the row-order lock. + */ +async function resolveInsertOrderKey( + trx: DbTransaction, + tableId: string, + requestedPosition?: number +): Promise { + const orderKeyAtSlot = async (slot: number): Promise => { + if (slot < 0) return null + if (isTablesFractionalOrderingEnabled) { + const [r] = await trx + .select({ orderKey: userTableRows.orderKey }) + .from(userTableRows) + .where(eq(userTableRows.tableId, tableId)) + .orderBy(asc(userTableRows.orderKey), asc(userTableRows.id)) + .limit(1) + .offset(slot) + return r?.orderKey ?? null + } + const [r] = await trx + .select({ orderKey: userTableRows.orderKey }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), eq(userTableRows.position, slot))) + .limit(1) + return r?.orderKey ?? null + } + if (requestedPosition === undefined) { + return keyBetween(await maxOrderKey(trx, tableId), null) + } + const lo = await orderKeyAtSlot(requestedPosition - 1) + const hi = await orderKeyAtSlot(requestedPosition) + return keyBetween(lo, hi) +} + +/** + * Resolves the `order_key` for an insert expressed by an anchor row id — + * `afterRowId` (place directly after) or `beforeRowId` (directly before). Finds + * the anchor and its adjacent key via the `(table_id, order_key, id)` index + * (O(1)) and mints a key between them. Also returns a legacy integer `position` + * (anchor's position ±) so the flag-off shift path still works. Caller holds the + * row-order lock. + */ +async function resolveInsertByNeighbor( + trx: DbTransaction, + tableId: string, + afterRowId?: string, + beforeRowId?: string +): Promise<{ orderKey: string; position: number }> { + const anchorId = afterRowId ?? beforeRowId! + const [anchor] = await trx + .select({ orderKey: userTableRows.orderKey, position: userTableRows.position }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), eq(userTableRows.id, anchorId))) + .limit(1) + // The client targets a specific neighbor; a missing one (concurrent delete / + // stale view) is an error, not a silent insert at the front. + if (!anchor) throw new Error(`Row not found: ${anchorId}`) + const anchorKey = anchor.orderKey ?? null + // A null key on the anchor means the table isn't backfilled. With the flag on + // (key is authoritative) the adjacent-key lookup below can't work — fail + // loudly rather than mint a wrong key. Flag off keeps `position` authoritative, + // so a best-effort key here is fine (the backfill re-keys before the flip). + if (anchorKey === null && isTablesFractionalOrderingEnabled) { + throw new Error(`Row ${anchorId} has no order_key yet (table not backfilled)`) + } + + if (afterRowId) { + // hi = the smallest key strictly after the anchor. + const [next] = await trx + .select({ orderKey: userTableRows.orderKey }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + sql`(${userTableRows.orderKey}, ${userTableRows.id}) > (${anchorKey}, ${afterRowId})` + ) + ) + .orderBy(asc(userTableRows.orderKey), asc(userTableRows.id)) + .limit(1) + return { + orderKey: keyBetween(anchorKey, next?.orderKey ?? null), + position: anchor.position + 1, + } + } + + // beforeRowId: lo = the largest key strictly before the anchor. + const [prev] = await trx + .select({ orderKey: userTableRows.orderKey }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + sql`(${userTableRows.orderKey}, ${userTableRows.id}) < (${anchorKey}, ${beforeRowId})` + ) + ) + .orderBy(desc(userTableRows.orderKey), desc(userTableRows.id)) + .limit(1) + return { + orderKey: keyBetween(prev?.orderKey ?? null, anchorKey), + position: anchor.position, + } +} + +/** + * Computes fractional `order_key`s for a batch insert. With no `positions`, + * appends a contiguous run after the current max key. With explicit `positions` + * (undo restore), keys each row between its pre-shift position neighbors — + * correct because requested positions are distinct. Caller holds the lock. + * + * The explicit-`positions` path is meaningful only when `position` is + * authoritative (flag off): with the flag on, a saved `position` is a gappy + * column value, not a visual rank, so feeding it to {@link resolveInsertOrderKey} + * (which reads `position` as an `OFFSET` rank under the flag) would mint keys at + * the wrong ranks. Callers needing exact placement under the flag pass + * `orderKeys` (handled before this function); here we just append a run. + */ +async function resolveBatchInsertOrderKeys( + trx: DbTransaction, + tableId: string, + count: number, + positions?: number[] +): Promise { + if (!positions || positions.length === 0 || isTablesFractionalOrderingEnabled) { + return nKeysBetween(await maxOrderKey(trx, tableId), null, count) + } + const keys: string[] = [] + for (const pos of positions) { + keys.push(await resolveInsertOrderKey(trx, tableId, pos)) + } + return keys +} + +/** + * Inserts a single row in its own transaction. Always assigns a fractional + * `order_key`. When the fractional-ordering flag is on, `order_key` is + * authoritative and `position` is a best-effort append (no O(N) shift); when + * off, `position` is reserved as before (shifting to open the slot). Validation + * and side-effect dispatch stay with the caller; capacity is enforced by the + * `increment_user_table_row_count` trigger. + */ +async function insertOrderedRow(params: { + tableId: string + workspaceId: string + data: RowData + rowId: string + position?: number + afterRowId?: string + beforeRowId?: string + createdBy?: string + now: Date +}): Promise<{ + id: string + data: RowData + position: number + orderKey: string | null + createdAt: Date + updatedAt: Date +}> { + const { tableId, workspaceId, data, rowId, position, afterRowId, beforeRowId, createdBy, now } = + params + const [row] = await db.transaction(async (trx) => { + await setTableTxTimeouts(trx) + await acquireRowOrderLock(trx, tableId) + + // Resolve the order key (and a legacy slot position for the flag-off shift + // path) from neighbor ids when given, else from the requested position. + let orderKey: string + let slotPosition = position + if (afterRowId || beforeRowId) { + const resolved = await resolveInsertByNeighbor(trx, tableId, afterRowId, beforeRowId) + orderKey = resolved.orderKey + slotPosition = resolved.position + } else { + orderKey = await resolveInsertOrderKey(trx, tableId, position) + } + + let targetPosition: number + if (isTablesFractionalOrderingEnabled) { + // order_key is authoritative — keep a best-effort, no-shift position. + targetPosition = await nextRowPosition(trx, tableId) + } else if (slotPosition !== undefined) { + const [existing] = await trx + .select({ id: userTableRows.id }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), eq(userTableRows.position, slotPosition))) + .limit(1) + if (existing) await shiftRowsUpFrom(trx, tableId, slotPosition) + targetPosition = slotPosition + } else { + targetPosition = await nextRowPosition(trx, tableId) + } + + return trx + .insert(userTableRows) + .values({ + id: rowId, + tableId, + workspaceId, + data, + position: targetPosition, + orderKey, + createdAt: now, + updatedAt: now, + ...(createdBy ? { createdBy } : {}), + }) + .returning() + }) + return { + id: row.id, + data: row.data as RowData, + position: row.position, + orderKey: row.orderKey, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +/** + * Deletes a single row by id in its own transaction, then closes the positional + * gap. Returns `false` when no row matched. + */ +async function deleteOrderedRow(params: { + tableId: string + rowId: string + workspaceId: string +}): Promise { + const { tableId, rowId, workspaceId } = params + return db.transaction(async (trx) => { + await setTableTxTimeouts(trx) + const [deleted] = await trx + .delete(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .returning({ position: userTableRows.position }) + if (!deleted) return false + // Fractional ordering: deleting a row never changes another row's order_key, + // so the O(N) position reshift is skipped entirely. + if (!isTablesFractionalOrderingEnabled) { + await shiftRowsDownAfter(trx, tableId, deleted.position) + } + return true + }) +} + +/** + * Deletes the given row ids in batches within one transaction, then recompacts + * positions from the earliest deleted slot. Returns the deleted rows (id + prior + * position). The caller resolves which ids to delete (used by both delete-by-ids + * and delete-by-filter). + */ +async function deleteOrderedRowsByIds(params: { + tableId: string + workspaceId: string + rowIds: string[] +}): Promise<{ id: string; position: number }[]> { + const { tableId, workspaceId, rowIds } = params + if (rowIds.length === 0) return [] + return db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + const deleted: { id: string; position: number }[] = [] + for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { + const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) + const rows = await trx + .delete(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + inArray(userTableRows.id, batch) + ) + ) + .returning({ id: userTableRows.id, position: userTableRows.position }) + deleted.push(...rows) + } + // Fractional ordering: deletes leave order_key untouched, so no recompaction. + if (!isTablesFractionalOrderingEnabled && deleted.length > 0) { + const minDeletedPos = deleted.reduce( + (min, r) => (r.position < min ? r.position : min), + deleted[0].position + ) + await compactPositions(trx, tableId, minDeletedPos) + } + return deleted + }) +} + /** * Inserts a single row into a table. * @@ -974,57 +1532,16 @@ export async function insertRow( // (migration 0198): a single conditional UPDATE on user_table_definitions // increments row_count iff row_count < max_rows, taking the row lock // atomically. No app-level FOR UPDATE / COUNT needed. - const [row] = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx) - - let targetPosition: number - - // The `(table_id, position)` index is non-unique, so we serialize all - // position-aware writes (explicit and auto) through the per-table - // advisory lock. Without this, two concurrent explicit-position inserts - // at the same position can both observe an empty slot, both skip the - // shift, and each INSERT a row with a duplicate `(table_id, position)`. - await acquireTablePositionLock(trx, data.tableId) - - if (data.position !== undefined) { - targetPosition = data.position - - const [existing] = await trx - .select({ id: userTableRows.id }) - .from(userTableRows) - .where( - and(eq(userTableRows.tableId, data.tableId), eq(userTableRows.position, targetPosition)) - ) - .limit(1) - - if (existing) { - await trx - .update(userTableRows) - .set({ position: sql`position + 1` }) - .where( - and( - eq(userTableRows.tableId, data.tableId), - gte(userTableRows.position, targetPosition) - ) - ) - } - } else { - targetPosition = await nextAutoPosition(trx, data.tableId) - } - - return trx - .insert(userTableRows) - .values({ - id: rowId, - tableId: data.tableId, - workspaceId: data.workspaceId, - data: data.data, - position: targetPosition, - createdAt: now, - updatedAt: now, - ...(data.userId ? { createdBy: data.userId } : {}), - }) - .returning() + const row = await insertOrderedRow({ + tableId: data.tableId, + workspaceId: data.workspaceId, + data: data.data, + rowId, + position: data.position, + afterRowId: data.afterRowId, + beforeRowId: data.beforeRowId, + createdBy: data.userId, + now, }) logger.info(`[${requestId}] Inserted row ${rowId} into table ${data.tableId}`) @@ -1034,6 +1551,7 @@ export async function insertRow( data: row.data as RowData, executions: {}, position: row.position, + orderKey: row.orderKey ?? undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, } @@ -1128,78 +1646,309 @@ export async function batchInsertRowsWithTx( await setTableTxTimeouts(trx, { statementMs: 60_000 }) - const buildRow = (rowData: RowData, position: number) => ({ + const buildRow = (rowData: RowData, position: number, orderKey: string) => ({ id: `row_${generateId().replace(/-/g, '')}`, tableId: data.tableId, workspaceId: data.workspaceId, data: rowData, position, + orderKey, createdAt: now, updatedAt: now, ...(data.userId ? { createdBy: data.userId } : {}), }) - await acquireTablePositionLock(trx, data.tableId) + await acquireRowOrderLock(trx, data.tableId) + // Undo restore passes exact saved keys; otherwise derive from positions/append. + const orderKeys = + data.orderKeys && data.orderKeys.length > 0 + ? data.orderKeys + : await resolveBatchInsertOrderKeys(trx, data.tableId, data.rows.length, data.positions) + let positions: number[] + if (isTablesFractionalOrderingEnabled) { + // order_key authoritative — best-effort append positions, no shift. + const start = await nextRowPosition(trx, data.tableId) + positions = Array.from({ length: data.rows.length }, (_, i) => start + i) + } else { + positions = await reserveBatchPositions(trx, data.tableId, data.rows.length, data.positions) + } + const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, positions[i], orderKeys[i])) + const insertedRows = await trx.insert(userTableRows).values(rowsToInsert).returning() + + logger.info(`[${requestId}] Batch inserted ${data.rows.length} rows into table ${data.tableId}`) + + const result: TableRow[] = insertedRows.map((r) => ({ + id: r.id, + data: r.data as RowData, + executions: {}, + position: r.position, + orderKey: r.orderKey ?? undefined, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })) + + return result +} + +/** + * Side-effect dispatch for an insert batch. Caller fires this AFTER the + * surrounding transaction commits — `fireTableTrigger` and `runWorkflowColumn` + * both read through the global db connection, so firing inside the tx can see + * no rows and no-op. + */ +export function dispatchAfterBatchInsert( + table: TableDefinition, + result: TableRow[], + requestId: string +): void { + void fireTableTrigger(table.id, table.name, 'insert', result, null, table.schema, requestId) + // Scope to the newly-inserted row ids so the dispatcher doesn't walk every + // row in the table. After the sidecar migration, all existing rows have + // zero entries → `mode:'new'`'s `NOT EXISTS` filter would otherwise include + // them, dispatching workflows on every row in a populated table. + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: result.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) +} + +/** One batch of rows for a background import (see {@link bulkInsertImportBatch}). */ +export interface BulkImportBatch { + tableId: string + workspaceId: string + userId?: string + rows: RowData[] + /** Position of the first row in this batch; rows get contiguous positions from here. */ + startPosition: number + /** Previous batch's last `order_key` (the append anchor); null for the first batch / empty table. */ + afterOrderKey?: string | null +} + +/** + * Inserts one batch of rows for an async import in a single committed statement. + * + * Differs from {@link batchInsertRowsWithTx} for the bulk-load case: caller-supplied + * contiguous positions (no `acquireTablePositionLock` / `nextAutoPosition` scan — an + * import owns its hidden table as the sole writer), no `RETURNING`, and **no + * `fireTableTrigger` / `runWorkflowColumn`** (a 1M-row import must not dispatch a + * workflow run per row). `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. + */ +export async function bulkInsertImportBatch( + data: BulkImportBatch, + table: TableDefinition, + requestId: string +): Promise<{ inserted: number; lastOrderKey: string | null }> { + for (let i = 0; i < data.rows.length; i++) { + const sizeValidation = validateRowSize(data.rows[i]) + if (!sizeValidation.valid) { + throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + } + const schemaValidation = coerceRowToSchema(data.rows[i], table.schema) + if (!schemaValidation.valid) { + throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + } + } + + const uniqueColumns = getUniqueColumns(table.schema) + if (uniqueColumns.length > 0) { + const uniqueResult = await checkBatchUniqueConstraintsDb( + data.tableId, + data.rows, + table.schema, + db + ) + if (!uniqueResult.valid) { + throw new Error( + uniqueResult.errors.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`).join('; ') + ) + } + } + + const now = new Date() + // Import worker is the table's sole writer; append keys after the anchor the caller threads + // from the previous batch's last key — no per-batch max(order_key) scan over a growing table. + const orderKeys = nKeysBetween(data.afterOrderKey ?? null, null, data.rows.length) + const rowsToInsert = data.rows.map((rowData, i) => ({ + id: `row_${generateId().replace(/-/g, '')}`, + tableId: data.tableId, + workspaceId: data.workspaceId, + data: rowData, + position: data.startPosition + i, + orderKey: orderKeys[i], + createdAt: now, + updatedAt: now, + ...(data.userId ? { createdBy: data.userId } : {}), + })) + + await db.insert(userTableRows).values(rowsToInsert) + logger.info(`[${requestId}] Bulk-imported ${rowsToInsert.length} rows into table ${data.tableId}`) + return { + inserted: rowsToInsert.length, + lastOrderKey: orderKeys[orderKeys.length - 1] ?? data.afterOrderKey ?? null, + } +} - let insertedRows - if (data.positions && data.positions.length > 0) { - // Position-aware insert: shift existing rows to create gaps, then insert. - // Process positions ascending so each shift preserves gaps created by prior shifts. - const sortedPositions = [...data.positions].sort((a, b) => a - b) +/** Deletes every row of a table (set-based; the statement-level trigger zeroes `row_count`). */ +export async function deleteAllTableRows(tableId: string): Promise { + await db.delete(userTableRows).where(eq(userTableRows.tableId, tableId)) +} - for (const pos of sortedPositions) { - await trx - .update(userTableRows) - .set({ position: sql`position + 1` }) - .where(and(eq(userTableRows.tableId, data.tableId), gte(userTableRows.position, pos))) - } +/** + * Adds columns to a table during an import (the `createColumns` flow), wrapping the + * tx-bound {@link addTableColumnsWithTx} in its own transaction. Returns the updated table. + */ +export async function addImportColumns( + table: TableDefinition, + additions: { name: string; type: string }[], + requestId: string +): Promise { + return db.transaction((trx) => addTableColumnsWithTx(trx, table, additions, requestId)) +} - const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, data.positions![i])) - insertedRows = await trx.insert(userTableRows).values(rowsToInsert).returning() - } else { - const startPos = await nextAutoPosition(trx, data.tableId) - const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, startPos + i)) - insertedRows = await trx.insert(userTableRows).values(rowsToInsert).returning() - } +/** Overwrites a table's schema during an import (used when inferring columns from the file). */ +export async function setTableSchemaForImport(tableId: string, schema: TableSchema): Promise { + await db + .update(userTableDefinitions) + .set({ schema, updatedAt: new Date() }) + .where(eq(userTableDefinitions.id, tableId)) +} - logger.info(`[${requestId}] Batch inserted ${data.rows.length} rows into table ${data.tableId}`) +/** + * Atomically claims a table for an async import. The `import_status != 'importing'` guard makes + * this the single concurrency gate: of two racing kickoffs only one row-update matches, so only + * one wins (no TOCTOU between a separate status check and this write). Returns whether it claimed + * the table — the caller returns 409 when it didn't. + */ +export async function markTableImporting(tableId: string, importId: string): Promise { + const updated = await db + .update(userTableDefinitions) + .set({ + importStatus: 'importing', + importId, + importError: null, + importRowsProcessed: 0, + importStartedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(userTableDefinitions.id, tableId), + or( + isNull(userTableDefinitions.importStatus), + ne(userTableDefinitions.importStatus, 'importing') + ) + ) + ) + .returning({ id: userTableDefinitions.id }) + return updated.length > 0 +} - const result: TableRow[] = insertedRows.map((r) => ({ - id: r.id, - data: r.data as RowData, - executions: {}, - position: r.position, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })) +/** + * Releases a claim taken by {@link markTableImporting} for a synchronous import — clears the + * import state back to idle. Scoped to `importId` so it only clears its own claim, never a newer + * run that may have taken over. A sync route claims, writes, then releases here in a `finally`. + */ +export async function releaseImportClaim(tableId: string, importId: string): Promise { + await db + .update(userTableDefinitions) + .set({ importStatus: null, importId: null, importStartedAt: null, updatedAt: new Date() }) + .where( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.importId, importId), + eq(userTableDefinitions.importStatus, 'importing') + ) + ) +} - return result +/** + * Records import progress (rows processed so far). Also bumps `updatedAt` so the + * stale-import janitor (`cleanup-stale-executions`) sees a live heartbeat and doesn't mark a + * still-running import as failed. + * + * Scoped to `importId` AND `import_status = 'importing'`: a stale/superseded worker no longer + * matches (its write is a no-op), and once the import is terminal (e.g. canceled) the match fails + * too — so this returning `false` is also the worker's signal to stop. Returns whether this worker + * still owns an in-flight import. + */ +export async function updateImportProgress( + tableId: string, + rowsProcessed: number, + importId: string +): Promise { + const updated = await db + .update(userTableDefinitions) + .set({ importRowsProcessed: rowsProcessed, updatedAt: new Date() }) + .where( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.importId, importId), + eq(userTableDefinitions.importStatus, 'importing') + ) + ) + .returning({ id: userTableDefinitions.id }) + return updated.length > 0 +} + +/** Shared WHERE for terminal transitions: this import run, and still in-flight (write-once). */ +function ownsActiveImport(tableId: string, importId: string) { + return and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.importId, importId), + eq(userTableDefinitions.importStatus, 'importing') + ) } /** - * Side-effect dispatch for an insert batch. Caller fires this AFTER the - * surrounding transaction commits — `fireTableTrigger` and `runWorkflowColumn` - * both read through the global db connection, so firing inside the tx can see - * no rows and no-op. + * Marks an import complete; rows become visible. No-op unless it's still this in-flight run. + * Returns whether it transitioned, so the worker only emits the `ready` event when it actually + * won (and not after a cancel / supersede). */ -export function dispatchAfterBatchInsert( - table: TableDefinition, - result: TableRow[], - requestId: string -): void { - void fireTableTrigger(table.id, table.name, 'insert', result, null, table.schema, requestId) - // Scope to the newly-inserted row ids so the dispatcher doesn't walk every - // row in the table. After the sidecar migration, all existing rows have - // zero entries → `mode:'new'`'s `NOT EXISTS` filter would otherwise include - // them, dispatching workflows on every row in a populated table. - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: result.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) +export async function markImportReady(tableId: string, importId: string): Promise { + const updated = await db + .update(userTableDefinitions) + .set({ importStatus: 'ready', importError: null, updatedAt: new Date() }) + .where(ownsActiveImport(tableId, importId)) + .returning({ id: userTableDefinitions.id }) + return updated.length > 0 +} + +/** + * Marks an import failed, leaving any already-committed rows in place. No-op unless it's still + * this in-flight run (so a stale worker can't clobber a newer import or a cancel). + */ +export async function markImportFailed( + tableId: string, + importId: string, + error: string +): Promise { + await db + .update(userTableDefinitions) + .set({ importStatus: 'failed', importError: error.slice(0, 2000), updatedAt: new Date() }) + .where(ownsActiveImport(tableId, importId)) +} + +/** + * Marks an in-flight import canceled (user-initiated). No-op unless it's still importing. The + * worker's next ownership check then returns `false` and it stops; committed rows are left in + * place (no rollback). Returns whether a running import was actually canceled. + */ +export async function markImportCanceled(tableId: string, importId: string): Promise { + const updated = await db + .update(userTableDefinitions) + .set({ importStatus: 'canceled', updatedAt: new Date() }) + .where(ownsActiveImport(tableId, importId)) + .returning({ id: userTableDefinitions.id }) + return updated.length > 0 } /** @@ -1299,7 +2048,7 @@ export async function replaceTableRowsWithTx( // snapshot for the DELETE; the second's DELETE would not observe rows the // first inserted, so both transactions commit and the table ends up with // the union of both row sets instead of only the last caller's rows. - await acquireTablePositionLock(trx, data.tableId) + await acquireRowOrderLock(trx, data.tableId) const deletedRows = await trx .delete(userTableRows) @@ -1308,12 +2057,15 @@ export async function replaceTableRowsWithTx( let insertedCount = 0 if (data.rows.length > 0) { + // All prior rows were just deleted — assign a fresh contiguous key run. + const orderKeys = nKeysBetween(null, null, data.rows.length) const rowsToInsert = data.rows.map((rowData, i) => ({ id: `row_${generateId().replace(/-/g, '')}`, tableId: data.tableId, workspaceId: data.workspaceId, data: rowData, position: i, + orderKey: orderKeys[i], createdAt: now, updatedAt: now, ...(data.userId ? { createdBy: data.userId } : {}), @@ -1336,6 +2088,62 @@ export async function replaceTableRowsWithTx( return { deletedCount: deletedRows.length, insertedCount } } +/** + * Owns the append-import transaction so the API route never holds a `trx`: + * optionally creates the new columns, then inserts every row in CSV-sized + * batches — all atomic. Caller fires {@link dispatchAfterBatchInsert} after this + * resolves (post-commit), mirroring the other batch-insert sites. + */ +export async function importAppendRows( + table: TableDefinition, + additions: { name: string; type: string; required?: boolean; unique?: boolean }[], + rows: RowData[], + ctx: { workspaceId: string; userId?: string; requestId: string } +): Promise<{ inserted: TableRow[]; table: TableDefinition }> { + return db.transaction(async (trx) => { + let working = table + if (additions.length > 0) { + working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId) + } + const inserted: TableRow[] = [] + for (let i = 0; i < rows.length; i += CSV_MAX_BATCH_SIZE) { + const batch = rows.slice(i, i + CSV_MAX_BATCH_SIZE) + const batchInserted = await batchInsertRowsWithTx( + trx, + { tableId: working.id, rows: batch, workspaceId: ctx.workspaceId, userId: ctx.userId }, + working, + generateId().slice(0, 8) + ) + inserted.push(...batchInserted) + } + return { inserted, table: working } + }) +} + +/** + * Owns the replace-import transaction: optionally creates the new columns, then + * replaces all rows — atomically. Keeps `trx` out of the API route. + */ +export async function importReplaceRows( + table: TableDefinition, + additions: { name: string; type: string; required?: boolean; unique?: boolean }[], + data: { rows: RowData[]; workspaceId: string; userId?: string }, + requestId: string +): Promise { + return db.transaction(async (trx) => { + let working = table + if (additions.length > 0) { + working = await addTableColumnsWithTx(trx, table, additions, requestId) + } + return replaceTableRowsWithTx( + trx, + { tableId: working.id, rows: data.rows, workspaceId: data.workspaceId, userId: data.userId }, + working, + requestId + ) + }) +} + /** * Upserts a row: updates an existing row if a match is found on the conflict target * column, otherwise inserts a new row. @@ -1448,7 +2256,7 @@ export async function upsertRow( let matchedRowId = existingRow?.id let previousData = existingRow?.data as RowData | undefined if (!matchedRowId) { - await acquireTablePositionLock(trx, data.tableId) + await acquireRowOrderLock(trx, data.tableId) const [racedRow] = await trx .select({ id: userTableRows.id, data: userTableRows.data }) .from(userTableRows) @@ -1480,6 +2288,7 @@ export async function upsertRow( data: updatedRow.data as RowData, executions, position: updatedRow.position, + orderKey: updatedRow.orderKey ?? undefined, createdAt: updatedRow.createdAt, updatedAt: updatedRow.updatedAt, }, @@ -1495,7 +2304,8 @@ export async function upsertRow( tableId: data.tableId, workspaceId: data.workspaceId, data: data.data, - position: await nextAutoPosition(trx, data.tableId), + position: await reserveInsertPosition(trx, data.tableId), + orderKey: await resolveInsertOrderKey(trx, data.tableId), createdAt: now, updatedAt: now, ...(data.userId ? { createdBy: data.userId } : {}), @@ -1508,6 +2318,7 @@ export async function upsertRow( data: insertedRow.data as RowData, executions: {}, position: insertedRow.position, + orderKey: insertedRow.orderKey ?? undefined, createdAt: insertedRow.createdAt, updatedAt: insertedRow.updatedAt, }, @@ -1580,12 +2391,12 @@ export async function queryRows( limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, offset = 0, includeTotal = true, + withExecutions = true, } = options const tableName = USER_TABLE_ROWS_SQL_NAME const columns = table.schema.columns - // Build WHERE clause const baseConditions = and( eq(userTableRows.tableId, table.id), eq(userTableRows.workspaceId, table.workspaceId) @@ -1599,50 +2410,59 @@ export async function queryRows( } } - let totalCount: number | null = null - if (includeTotal) { - const countResult = await db - .select({ count: count() }) - .from(userTableRows) - .where(whereClause ?? baseConditions) - totalCount = Number(countResult[0].count) - } - - // Build ORDER BY clause (default to position ASC for stable ordering) let orderByClause if (sort && Object.keys(sort).length > 0) { orderByClause = buildSortClause(sort, tableName, columns) } - // Execute query let query = db .select() .from(userTableRows) .where(whereClause ?? baseConditions) - if (orderByClause) { - query = query.orderBy(orderByClause) as typeof query + // Explicit data-column sort: tiebreak by the default order for stability. + query = query.orderBy( + orderByClause, + isTablesFractionalOrderingEnabled ? userTableRows.orderKey : userTableRows.position, + userTableRows.id + ) as typeof query + } else if (isTablesFractionalOrderingEnabled) { + query = query.orderBy(userTableRows.orderKey, userTableRows.id) as typeof query } else { query = query.orderBy(userTableRows.position) as typeof query } - const rows = await query.limit(limit).offset(offset) + // Count and page fetch are independent reads — run them concurrently so the + // `includeTotal` hot path doesn't pay two serial round-trips. + const rowsPromise = query.limit(limit).offset(offset) + const countPromise = includeTotal + ? db + .select({ count: count() }) + .from(userTableRows) + .where(whereClause ?? baseConditions) + : null + + const [rows, countResult] = await Promise.all([rowsPromise, countPromise]) + const totalCount = countResult ? Number(countResult[0].count) : null + + const executionsByRow = withExecutions + ? await loadExecutionsByRow( + db, + rows.map((r) => r.id) + ) + : null logger.info( `[${requestId}] Queried ${rows.length} rows from table ${table.id} (total: ${totalCount})` ) - const executionsByRow = await loadExecutionsByRow( - db, - rows.map((r) => r.id) - ) - return { rows: rows.map((r) => ({ id: r.id, data: r.data as RowData, - executions: executionsByRow.get(r.id) ?? {}, + executions: executionsByRow?.get(r.id) ?? {}, position: r.position, + orderKey: r.orderKey ?? undefined, createdAt: r.createdAt, updatedAt: r.updatedAt, })), @@ -1687,6 +2507,7 @@ export async function getRowById( data: row.data as RowData, executions, position: row.position, + orderKey: row.orderKey ?? undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, } @@ -1808,50 +2629,6 @@ function applyExecutionsPatch( return next } -/** - * Loads `tableRowExecutions` rows for the given row ids and groups them into - * a `Map` suitable for plugging into `TableRow.executions` - * everywhere callers used to read `userTableRows.executions` JSONB. - */ -async function loadExecutionsByRow( - trx: DbOrTx, - rowIds: Iterable -): Promise> { - const ids = Array.from(new Set(rowIds)) - const result = new Map() - if (ids.length === 0) return result - const rows = await trx - .select() - .from(tableRowExecutions) - .where(inArray(tableRowExecutions.rowId, ids)) - for (const r of rows) { - const existing = result.get(r.rowId) ?? {} - const meta: RowExecutionMetadata = { - status: r.status as RowExecutionMetadata['status'], - executionId: r.executionId ?? null, - jobId: r.jobId ?? null, - workflowId: r.workflowId, - error: r.error ?? null, - ...(r.runningBlockIds && r.runningBlockIds.length > 0 - ? { runningBlockIds: r.runningBlockIds } - : {}), - ...(r.blockErrors && Object.keys(r.blockErrors as Record).length > 0 - ? { blockErrors: r.blockErrors as Record } - : {}), - ...(r.cancelledAt ? { cancelledAt: r.cancelledAt.toISOString() } : {}), - } - existing[r.groupId] = meta - result.set(r.rowId, existing) - } - return result -} - -/** Convenience: load executions for one row, returning `{}` when missing. */ -async function loadExecutionsForRow(trx: DbOrTx, rowId: string): Promise { - const byRow = await loadExecutionsByRow(trx, [rowId]) - return byRow.get(rowId) ?? {} -} - /** * Writes a per-group execution patch for one row against the `tableRowExecutions` * sidecar. Non-null values upsert into the table; nulls delete the entry. When @@ -2171,26 +2948,8 @@ export async function deleteRow( workspaceId: string, requestId: string ): Promise { - await db.transaction(async (trx) => { - await setTableTxTimeouts(trx) - const [deleted] = await trx - .delete(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .returning({ position: userTableRows.position }) - - if (!deleted) throw new Error('Row not found') - - await trx - .update(userTableRows) - .set({ position: sql`position - 1` }) - .where(and(eq(userTableRows.tableId, tableId), gt(userTableRows.position, deleted.position))) - }) + const deleted = await deleteOrderedRow({ tableId, rowId, workspaceId }) + if (!deleted) throw new Error('Row not found') logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`) } @@ -2531,43 +3290,6 @@ export async function batchUpdateRows( } } -/** - * Recompacts row positions to be contiguous after batch deletions. - * - * When `minDeletedPos` is provided, only rows with `position >= minDeletedPos` - * are re-numbered (starting from `minDeletedPos`). Rows before the earliest - * deleted position are untouched since their position is unaffected. - * - * If `minDeletedPos` is omitted, the whole table is recompacted from 0. - * Single-row deletes use the more efficient `position - 1` shift in {@link deleteRow}. - */ -async function recompactPositions(tableId: string, trx: DbTransaction, minDeletedPos?: number) { - if (minDeletedPos === undefined) { - await trx.execute(sql` - UPDATE user_table_rows t - SET position = r.new_pos - FROM ( - SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos - FROM user_table_rows - WHERE table_id = ${tableId} - ) r - WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos - `) - return - } - - await trx.execute(sql` - UPDATE user_table_rows t - SET position = r.new_pos - FROM ( - SELECT id, ${minDeletedPos}::int + ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos - FROM user_table_rows - WHERE table_id = ${tableId} AND position >= ${minDeletedPos} - ) r - WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos - `) -} - /** * Deletes multiple rows matching a filter. * @@ -2611,28 +3333,11 @@ export async function deleteRowsByFilter( } const rowIds = matchingRows.map((r) => r.id) - const minDeletedPos = matchingRows.reduce( - (min, r) => (r.position < min ? r.position : min), - matchingRows[0].position - ) - - await db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { - const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) - await trx.delete(userTableRows).where( - and( - eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId), - sql`${userTableRows.id} = ANY(ARRAY[${sql.join( - batch.map((id) => sql`${id}`), - sql`, ` - )}])` - ) - ) - } - await recompactPositions(table.id, trx, minDeletedPos) + await deleteOrderedRowsByIds({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds, }) logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) @@ -2656,36 +3361,10 @@ export async function deleteRowsByIds( ): Promise { const uniqueRequestedRowIds = Array.from(new Set(data.rowIds)) - const deletedRows = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - const deleted: { id: string; position: number }[] = [] - for (let i = 0; i < uniqueRequestedRowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { - const batch = uniqueRequestedRowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) - const rows = await trx - .delete(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId), - sql`${userTableRows.id} = ANY(ARRAY[${sql.join( - batch.map((id) => sql`${id}`), - sql`, ` - )}])` - ) - ) - .returning({ id: userTableRows.id, position: userTableRows.position }) - deleted.push(...rows) - } - - if (deleted.length > 0) { - const minDeletedPos = deleted.reduce( - (min, r) => (r.position < min ? r.position : min), - deleted[0].position - ) - await recompactPositions(data.tableId, trx, minDeletedPos) - } - - return deleted + const deletedRows = await deleteOrderedRowsByIds({ + tableId: data.tableId, + workspaceId: data.workspaceId, + rowIds: uniqueRequestedRowIds, }) const deletedIds = deletedRows.map((r) => r.id) @@ -3465,6 +4144,7 @@ export async function updateWorkflowGroup( dependencies: data.dependencies ?? group.dependencies, outputs: newOutputs, ...(data.inputMappings !== undefined ? { inputMappings: data.inputMappings } : {}), + ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), ...(data.type !== undefined ? { type: data.type } : {}), ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), } diff --git a/apps/sim/lib/table/trigger.ts b/apps/sim/lib/table/trigger.ts index 2d7a9f3604d..24199d99530 100644 --- a/apps/sim/lib/table/trigger.ts +++ b/apps/sim/lib/table/trigger.ts @@ -21,7 +21,6 @@ interface TableTriggerPayload { changedColumns: string[] rowId: string headers: string[] - rowNumber: number tableId: string tableName: string timestamp: string @@ -113,7 +112,6 @@ export async function fireTableTrigger( changedColumns, rowId: row.id, headers, - rowNumber: row.position, tableId, tableName, timestamp: new Date().toISOString(), diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 279d149c39d..968f2b2a47e 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -63,6 +63,13 @@ export interface WorkflowGroupDependencies { */ export type WorkflowGroupType = 'manual' | 'enrichment' +/** + * Which workflow state a group's per-cell runs execute against: `'live'` runs + * the editable draft (current behavior); `'deployed'` runs the workflow's + * latest active deployment. Defaults to `'live'` when absent. + */ +export type WorkflowGroupDeploymentMode = 'live' | 'deployed' + /** One workflow Start-block input field ← one table column. */ export interface WorkflowGroupInputMapping { /** `inputFormat` field name on the workflow's Start block. */ @@ -88,6 +95,12 @@ export interface WorkflowGroup { * supply each per-row value. Absent / empty means no mapping configured yet. */ inputMappings?: WorkflowGroupInputMapping[] + /** + * Which workflow state per-cell runs execute against. Defaults to `'live'` + * (editable draft) when absent. `'deployed'` runs the workflow's latest + * active deployment. Only meaningful for `manual` groups. + */ + deploymentMode?: WorkflowGroupDeploymentMode /** * When `false`, the group never auto-fires from the scheduler — it can only * be triggered manually via the "Run" actions. Defaults to `true` so @@ -152,6 +165,9 @@ export interface TableMetadata { pinnedColumns?: string[] } +/** Async-import lifecycle state for a table. NULL/undefined = normal (no async import). */ +export type TableImportStatus = 'importing' | 'ready' | 'failed' | 'canceled' + export interface TableDefinition { id: string name: string @@ -165,6 +181,12 @@ export interface TableDefinition { archivedAt?: Date | string | null createdAt: Date | string updatedAt: Date | string + /** Async-import state (see `apps/sim/lib/table/import-runner.ts`). */ + importStatus?: TableImportStatus | null + importId?: string | null + importError?: string | null + importRowsProcessed?: number + importStartedAt?: Date | string | null } /** Minimal table info for UI components. */ @@ -182,6 +204,11 @@ export interface TableRow { /** Per-group execution state for this row. Empty `{}` if nothing has run. */ executions: RowExecutions position: number + /** + * Fractional order key. Authoritative row order when `TABLES_FRACTIONAL_ORDERING` + * is on; absent only for rows not yet backfilled (clients fall back to `position`). + */ + orderKey?: string createdAt: Date | string updatedAt: Date | string } @@ -269,6 +296,12 @@ export interface QueryOptions { * is returned as `null` to signal it was not computed. */ includeTotal?: boolean + /** + * When true (default), each returned row's `executions` is populated from the + * `tableRowExecutions` sidecar. Pass `false` to skip the join and return `{}` + * (the public v1 route does not expose executions). + */ + withExecutions?: boolean } export interface QueryResult { @@ -296,6 +329,10 @@ export interface CreateTableData { maxTables?: number /** Number of empty rows to create with the table. Defaults to 0. */ initialRowCount?: number + /** When set, the table is created in this async-import state (rows hidden until ready). */ + importStatus?: TableImportStatus + /** Async-import id stamped on the table when `importStatus` is set. */ + importId?: string } export interface InsertRowData { @@ -305,6 +342,10 @@ export interface InsertRowData { userId?: string /** Optional explicit position. When omitted, the row is appended after the last position. */ position?: number + /** Insert directly after this row (fractional ordering). Takes precedence over `position`. */ + afterRowId?: string + /** Insert directly before this row (fractional ordering). Takes precedence over `position`. */ + beforeRowId?: string } export interface BatchInsertData { @@ -314,6 +355,11 @@ export interface BatchInsertData { userId?: string /** Optional per-row target positions. Length must equal `rows.length`. */ positions?: number[] + /** + * Optional per-row exact order keys (undo restore re-inserts at the saved key). + * Length must equal `rows.length`. Takes precedence over `positions`. + */ + orderKeys?: string[] } export interface UpsertRowData { @@ -454,6 +500,8 @@ export interface UpdateWorkflowGroupData { mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> /** Replace the group's input mappings. Omit to leave them unchanged. */ inputMappings?: WorkflowGroupInputMapping[] + /** Change which workflow state the group runs against. Omit to leave unchanged. */ + deploymentMode?: WorkflowGroupDeploymentMode /** Update the group's provenance. Omit to leave it unchanged. */ type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index f730d49beae..d0973a5552a 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -1,3 +1,4 @@ +import type { Readable } from 'node:stream' import { randomBytes } from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -222,6 +223,34 @@ export async function downloadFile(options: DownloadFileOptions): Promise { + const { key, context } = options + const config = getStorageConfig(context) + + if (USE_BLOB_STORAGE) { + const { downloadFromBlobStream } = await import('@/lib/uploads/providers/blob/client') + return downloadFromBlobStream(key, createBlobConfig(config)) + } + + if (USE_S3_STORAGE) { + const { downloadFromS3Stream } = await import('@/lib/uploads/providers/s3/client') + return downloadFromS3Stream(key, createS3Config(config)) + } + + const { createReadStream } = await import('fs') + const { join } = await import('path') + const { UPLOAD_DIR_SERVER } = await import('./setup.server') + return createReadStream(join(UPLOAD_DIR_SERVER, sanitizeFileKey(key))) +} + /** * Delete a file from the configured storage provider */ diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 5ff536bfb58..b517d9ed360 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -1,3 +1,4 @@ +import type { Readable } from 'node:stream' import type { BlobServiceClient as BlobServiceClientType } from '@azure/storage-blob' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' @@ -341,6 +342,49 @@ export async function downloadFromBlob( return downloaded } +/** + * Stream a blob out of storage without buffering it. The caller MUST fully consume or + * `destroy()` the returned stream. Used by the large-CSV import worker. + */ +export async function downloadFromBlobStream( + key: string, + customConfig?: BlobConfig +): Promise { + const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') + let blobServiceClient: BlobServiceClientType + let containerName: string + + if (customConfig) { + if (customConfig.connectionString) { + blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) + } else if (customConfig.accountName && customConfig.accountKey) { + const credential = new StorageSharedKeyCredential( + customConfig.accountName, + customConfig.accountKey + ) + blobServiceClient = new BlobServiceClient( + `https://${customConfig.accountName}.blob.core.windows.net`, + credential + ) + } else { + throw new Error('Invalid custom blob configuration') + } + containerName = customConfig.containerName + } else { + blobServiceClient = await getBlobServiceClient() + containerName = BLOB_CONFIG.containerName + } + + const containerClient = blobServiceClient.getContainerClient(containerName) + const blockBlobClient = containerClient.getBlockBlobClient(key) + + const downloadBlockBlobResponse = await blockBlobClient.download() + if (!downloadBlockBlobResponse.readableStreamBody) { + throw new Error('Failed to get readable stream from blob download') + } + return downloadBlockBlobResponse.readableStreamBody as Readable +} + /** * Check whether a blob exists (and return its size when it does). * Returns null when the blob is missing. diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index 7ddae7bbf87..cff13eee067 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -1,3 +1,4 @@ +import type { Readable } from 'node:stream' import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, @@ -223,6 +224,24 @@ export async function downloadFromS3( }) } +/** + * Stream an object out of S3 without buffering it. The caller MUST fully consume or + * `destroy()` the returned stream. Used by the large-CSV import worker so a 1M-row file is + * never resident in memory. + */ +export async function downloadFromS3Stream( + key: string, + customConfig?: S3Config +): Promise { + const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region } + const command = new GetObjectCommand({ Bucket: config.bucket, Key: key }) + const response = await getS3Client().send(command) + if (!response.Body) { + throw new Error(`S3 object has no body: ${key}`) + } + return response.Body as Readable +} + /** * Check whether an object exists in S3 (and return its size when it does). * Returns null when the object is missing. diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts new file mode 100644 index 00000000000..bb3e2c141e3 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { authOAuthUtilsMock, inputValidationMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) + +import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' + +const WEBHOOK_ID = 'webhook-uuid-1234' + +function makeRequest(body: string): NextRequest { + return new NextRequest('https://app.example.com/api/webhooks/trigger/abc', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function makeNotificationBody(clientState?: unknown): string { + return JSON.stringify({ + value: [ + { + subscriptionId: 'sub-1', + changeType: 'created', + resource: 'chats/19:abc@thread.v2/messages/1700000000000', + resourceData: { id: '1700000000000' }, + ...(clientState !== undefined ? { clientState } : {}), + }, + ], + }) +} + +async function runVerifyAuth(rawBody: string, providerConfig: Record) { + return microsoftTeamsHandler.verifyAuth!({ + webhook: { id: WEBHOOK_ID }, + workflow: {}, + request: makeRequest(rawBody), + rawBody, + requestId: 'test-req', + providerConfig, + }) +} + +describe('microsoftTeamsHandler verifyAuth (chat subscription clientState)', () => { + const chatSubscriptionConfig = { triggerId: 'microsoftteams_chat_subscription' } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('accepts notifications whose clientState matches the webhook id', async () => { + const res = await runVerifyAuth(makeNotificationBody(WEBHOOK_ID), chatSubscriptionConfig) + expect(res).toBeNull() + }) + + it('rejects notifications with a forged clientState', async () => { + const res = await runVerifyAuth(makeNotificationBody('forged'), chatSubscriptionConfig) + expect(res?.status).toBe(401) + }) + + it('rejects notifications missing clientState', async () => { + const res = await runVerifyAuth(makeNotificationBody(), chatSubscriptionConfig) + expect(res?.status).toBe(401) + }) + + it('rejects non-string clientState values', async () => { + const res = await runVerifyAuth( + makeNotificationBody({ nested: WEBHOOK_ID }), + chatSubscriptionConfig + ) + expect(res?.status).toBe(401) + }) + + it('rejects payloads without a value array', async () => { + const res = await runVerifyAuth(JSON.stringify({ hello: 'world' }), chatSubscriptionConfig) + expect(res?.status).toBe(401) + }) + + it('rejects payloads with an empty value array', async () => { + const res = await runVerifyAuth(JSON.stringify({ value: [] }), chatSubscriptionConfig) + expect(res?.status).toBe(401) + }) + + it('rejects unparseable bodies', async () => { + const res = await runVerifyAuth('not-json', chatSubscriptionConfig) + expect(res?.status).toBe(401) + }) + + it('rejects batches where any notification has a mismatched clientState', async () => { + const rawBody = JSON.stringify({ + value: [ + { subscriptionId: 'sub-1', resourceData: { id: '1' }, clientState: WEBHOOK_ID }, + { subscriptionId: 'sub-2', resourceData: { id: '2' }, clientState: 'forged' }, + ], + }) + const res = await runVerifyAuth(rawBody, chatSubscriptionConfig) + expect(res?.status).toBe(401) + }) + + it('fails closed when the webhook record has no id', async () => { + const res = await microsoftTeamsHandler.verifyAuth!({ + webhook: {}, + workflow: {}, + request: makeRequest(makeNotificationBody('')), + rawBody: makeNotificationBody(''), + requestId: 'test-req', + providerConfig: chatSubscriptionConfig, + }) + expect(res?.status).toBe(401) + }) + + it('does not require clientState for non-subscription trigger types', async () => { + const res = await runVerifyAuth(JSON.stringify({ type: 'message', text: 'hi' }), {}) + expect(res).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index be749f4e990..e073f25f184 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -477,7 +477,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { return null }, - verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) { if (providerConfig.hmacSecret) { const authHeader = request.headers.get('authorization') @@ -496,6 +496,43 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { } } + if (providerConfig.triggerId === 'microsoftteams_chat_subscription') { + const expectedClientState = String(webhook.id ?? '') + if (!expectedClientState) { + logger.warn( + `[${requestId}] Microsoft Teams chat subscription webhook missing id for clientState verification` + ) + return new NextResponse('Unauthorized - Invalid clientState', { status: 401 }) + } + + let notifications: unknown[] = [] + try { + const parsed = JSON.parse(rawBody) as Record + if (Array.isArray(parsed?.value)) { + notifications = parsed.value + } + } catch { + notifications = [] + } + + if (notifications.length === 0) { + logger.warn( + `[${requestId}] Microsoft Teams chat subscription notification missing value array` + ) + return new NextResponse('Unauthorized - Invalid notification payload', { status: 401 }) + } + + for (const notification of notifications) { + const clientState = (notification as Record)?.clientState + if (typeof clientState !== 'string' || !safeCompare(clientState, expectedClientState)) { + logger.warn( + `[${requestId}] Microsoft Teams chat subscription clientState verification failed` + ) + return new NextResponse('Unauthorized - Invalid clientState', { status: 401 }) + } + } + } + return null }, diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts index d32e5d68b40..8e64b3cfca2 100644 --- a/apps/sim/lib/workflows/autolayout/constants.ts +++ b/apps/sim/lib/workflows/autolayout/constants.ts @@ -63,10 +63,15 @@ export const OVERLAP_MARGIN = 30 */ export const MAX_OVERLAP_ITERATIONS = 20 +/** + * Note block type identifier + */ +export const NOTE_BLOCK_TYPE = 'note' + /** * Block types excluded from autolayout */ -export const AUTO_LAYOUT_EXCLUDED_TYPES = new Set(['note']) +export const AUTO_LAYOUT_EXCLUDED_TYPES = new Set([NOTE_BLOCK_TYPE]) /** * Container block types that can have children diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts index 2bbfca7f992..c9a23767c06 100644 --- a/apps/sim/lib/workflows/autolayout/index.ts +++ b/apps/sim/lib/workflows/autolayout/index.ts @@ -12,6 +12,7 @@ import { filterLayoutEligibleBlockIds, getBlocksByParent, prepareContainerDimensions, + resolveNoteOverlaps, } from '@/lib/workflows/autolayout/utils' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -74,6 +75,8 @@ export function applyAutoLayout( layoutContainers(blocksCopy, edges, options) + resolveNoteOverlaps(blocksCopy, verticalSpacing) + logger.info('Auto layout completed successfully', { blockCount: Object.keys(blocksCopy).length, }) diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index e6021885fef..cd8539e84be 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -11,7 +11,9 @@ import { filterLayoutEligibleBlockIds, getBlockMetrics, getBlocksByParent, + hasFinitePosition, prepareContainerDimensions, + resolveNoteOverlaps, shouldSkipAutoLayout, snapPositionToGrid, } from '@/lib/workflows/autolayout/utils' @@ -107,6 +109,10 @@ export function applyTargetedLayout( ) } + // Relocate notes only where this pass introduced an overlap, comparing against + // the original positions so pre-existing note arrangements are preserved. + resolveNoteOverlaps(blocksCopy, verticalSpacing, { previousBlocks: blocks }) + return blocksCopy } @@ -184,7 +190,7 @@ function layoutGroup( const invalidPositions = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false - return !hasPosition(block) + return !hasFinitePosition(block) }) const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions]) const needsLayout = Array.from(needsLayoutSet) @@ -536,13 +542,3 @@ function updateContainerDimensions( measuredHeight: parentBlock.data.height, } } - -/** - * Checks if a block has a valid, finite position. - * Returns false for missing, undefined, NaN, or Infinity coordinates. - */ -function hasPosition(block: BlockState): boolean { - if (!block.position) return false - const { x, y } = block.position - return Number.isFinite(x) && Number.isFinite(y) -} diff --git a/apps/sim/lib/workflows/autolayout/utils.test.ts b/apps/sim/lib/workflows/autolayout/utils.test.ts new file mode 100644 index 00000000000..b8cb51abe74 --- /dev/null +++ b/apps/sim/lib/workflows/autolayout/utils.test.ts @@ -0,0 +1,238 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_VERTICAL_SPACING } from '@/lib/workflows/autolayout/constants' +import { resolveNoteOverlaps } from '@/lib/workflows/autolayout/utils' +import type { BlockState } from '@/stores/workflows/workflow/types' + +vi.mock('@/blocks', () => ({ + getBlock: () => null, +})) + +function createBlock( + id: string, + type: string, + position: { x: number; y: number }, + overrides: Partial = {} +): BlockState { + return { + id, + type, + name: id, + position, + subBlocks: {}, + outputs: {}, + enabled: true, + layout: { measuredWidth: 250, measuredHeight: 120 }, + ...overrides, + } as BlockState +} + +describe('resolveNoteOverlaps', () => { + it('relocates a note that overlaps a laid-out block', () => { + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 160, y: 160 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING) + + // Block is untouched; note is pushed below the block's bottom edge. + expect(blocks.a.position).toEqual({ x: 150, y: 150 }) + expect(blocks.note.position.x).toBe(150) + expect(blocks.note.position.y).toBeGreaterThanOrEqual(150 + 120 + DEFAULT_VERTICAL_SPACING - 1) + }) + + it('leaves a note that does not overlap any block in place', () => { + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 2000, y: 2000 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING) + + expect(blocks.note.position).toEqual({ x: 2000, y: 2000 }) + }) + + it('stacks multiple overlapping notes without overlapping each other', () => { + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note1: createBlock( + 'note1', + 'note', + { x: 150, y: 150 }, + { + height: 100, + layout: { measuredHeight: 100 }, + } + ), + note2: createBlock( + 'note2', + 'note', + { x: 200, y: 200 }, + { + height: 100, + layout: { measuredHeight: 100 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING) + + const n1 = blocks.note1.position + const n2 = blocks.note2.position + // Both relocated, stacked in reading order with no vertical overlap. + expect(n2.y).toBeGreaterThanOrEqual(n1.y + 100) + }) + + it('does nothing when there are no notes', () => { + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + b: createBlock('b', 'agent', { x: 500, y: 150 }), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING) + + expect(blocks.a.position).toEqual({ x: 150, y: 150 }) + expect(blocks.b.position).toEqual({ x: 500, y: 150 }) + }) + + it('never produces non-finite coordinates when a block has a NaN position', () => { + const blocks: Record = { + bad: createBlock('bad', 'agent', { x: Number.NaN, y: Number.NaN }), + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 150, y: 150 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING) + + // The corrupted block is ignored; the note still relocates off block "a" + // using only finite coordinates. + expect(Number.isFinite(blocks.note.position.x)).toBe(true) + expect(Number.isFinite(blocks.note.position.y)).toBe(true) + expect(blocks.note.position.x).toBe(150) + expect(blocks.note.position.y).toBeGreaterThan(150) + }) + + describe('targeted mode (previousBlocks)', () => { + it('relocates a note when a block was moved onto it', () => { + const previousBlocks: Record = { + a: createBlock('a', 'agent', { x: 2000, y: 2000 }), + note: createBlock( + 'note', + 'note', + { x: 150, y: 150 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + // Block "a" has been shifted onto the note by the layout pass. + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 150, y: 150 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING, { previousBlocks }) + + expect(blocks.note.position.x).toBe(150) + expect(blocks.note.position.y).toBeGreaterThan(150) + }) + + it('preserves a pre-existing overlap not introduced by this pass', () => { + // The note already overlapped block "a" before the pass; "a" did not move. + const previousBlocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 160, y: 160 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 160, y: 160 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING, { previousBlocks }) + + expect(blocks.note.position).toEqual({ x: 160, y: 160 }) + }) + + it('relocates when a newly added block (no prior position) lands on a note', () => { + const previousBlocks: Record = { + note: createBlock( + 'note', + 'note', + { x: 150, y: 150 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + const blocks: Record = { + a: createBlock('a', 'agent', { x: 150, y: 150 }), + note: createBlock( + 'note', + 'note', + { x: 150, y: 150 }, + { + height: 120, + layout: { measuredHeight: 120 }, + } + ), + } + + resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING, { previousBlocks }) + + expect(blocks.note.position.y).toBeGreaterThan(150) + }) + }) +}) diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index ab42b48c9e3..f25c1086745 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -4,6 +4,7 @@ import { CONTAINER_PADDING, CONTAINER_PADDING_X, CONTAINER_PADDING_Y, + NOTE_BLOCK_TYPE, ROOT_PADDING_X, ROOT_PADDING_Y, } from '@/lib/workflows/autolayout/constants' @@ -313,6 +314,158 @@ export function boxesOverlap(box1: BoundingBox, box2: BoundingBox, margin = 0): ) } +/** + * Resolves the on-canvas dimensions of a note block. + * Notes are fixed-width and use a deterministic height, but fall back to any + * stored measurement or data override when present. + */ +function getNoteDimensions(block: BlockState): { width: number; height: number } { + const width = Math.max( + resolveNumeric(block.data?.width, 0), + block.layout?.measuredWidth ?? 0, + BLOCK_DIMENSIONS.FIXED_WIDTH + ) + + const defaultHeight = + BLOCK_DIMENSIONS.HEADER_HEIGHT + + BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + + BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT + + const height = Math.max( + resolveNumeric(block.data?.height, 0), + block.layout?.measuredHeight ?? 0, + block.height ?? 0, + defaultHeight + ) + + return { width, height } +} + +/** + * Checks if a block has a valid, finite position. + * Returns false for missing, undefined, NaN, or Infinity coordinates. + */ +export function hasFinitePosition(block: BlockState): boolean { + return Number.isFinite(block.position?.x) && Number.isFinite(block.position?.y) +} + +/** + * Determines whether a note and a block overlapped at their pre-layout + * positions. Used by targeted layout to distinguish overlaps introduced by the + * current pass from arrangements that already existed. + */ +function noteOverlappedBlockBefore( + previousBlocks: Record, + noteId: string, + blockId: string +): boolean { + const previousNote = previousBlocks[noteId] + const previousBlock = previousBlocks[blockId] + if (!previousNote || !previousBlock) return false + + // A block without a finite prior position was not yet placed on the canvas, + // so it could not have overlapped anything before this pass. + if (!hasFinitePosition(previousNote) || !hasFinitePosition(previousBlock)) return false + + // Derive dimensions from the prior blocks so a resize between passes does not + // pair new dimensions with the old position. + const noteBox = createBoundingBox(previousNote.position, getNoteDimensions(previousNote)) + const blockBox = createBoundingBox(previousBlock.position, getBlockMetrics(previousBlock)) + return boxesOverlap(blockBox, noteBox) +} + +export interface ResolveNoteOverlapsOptions { + /** + * Pre-layout block snapshot. When provided, only notes whose overlap with a + * block was *introduced* by the current pass are relocated (i.e. they overlap + * now but did not at these positions). Targeted layout passes this so that + * pre-existing note arrangements are preserved. When omitted (full + * auto-layout), any note overlapping a block is relocated. + */ + previousBlocks?: Record +} + +/** + * Relocates note blocks that overlap the laid-out workflow blocks. + * + * Notes are excluded from the topological layout because they are free-form + * annotations, but repositioned workflow blocks can land on top of them. This + * pass keeps notes that already sit in clear space and stacks any relocated + * notes in a column beneath their parent group's blocks, preserving the notes' + * relative reading order. Notes are grouped by parent so notes inside a + * container are resolved against that container's children only. + * + * Placement is always computed against the full set of blocks and notes in the + * group, so a relocated note never collides with anything regardless of which + * overlaps triggered the relocation. + */ +export function resolveNoteOverlaps( + blocks: Record, + verticalSpacing: number, + options: ResolveNoteOverlapsOptions = {} +): void { + const { previousBlocks } = options + const { root, children } = getBlocksByParent(blocks) + const groups: string[][] = [root, ...Array.from(children.values())] + + for (const groupIds of groups) { + const obstacles: Array<{ id: string; box: BoundingBox }> = [] + const noteIds: string[] = [] + let maxBottom = Number.NEGATIVE_INFINITY + let minX = Number.POSITIVE_INFINITY + + for (const id of groupIds) { + const block = blocks[id] + // Skip non-finite positions so corrupted coordinates never propagate into + // minX / maxBottom (and therefore into relocated note positions). + if (!block || !hasFinitePosition(block)) continue + + if (block.type === NOTE_BLOCK_TYPE) { + noteIds.push(id) + const { height } = getNoteDimensions(block) + maxBottom = Math.max(maxBottom, block.position.y + height) + continue + } + + if (shouldSkipAutoLayout(block)) continue + + const box = createBoundingBox(block.position, getBlockMetrics(block)) + obstacles.push({ id, box }) + maxBottom = Math.max(maxBottom, box.y + box.height) + minX = Math.min(minX, box.x) + } + + if (noteIds.length === 0 || obstacles.length === 0) continue + + noteIds.sort((a, b) => { + const posA = blocks[a].position + const posB = blocks[b].position + return posA.y - posB.y || posA.x - posB.x + }) + + let stackY = maxBottom + verticalSpacing + + for (const id of noteIds) { + const note = blocks[id] + const dimensions = getNoteDimensions(note) + const noteBox = createBoundingBox(note.position, dimensions) + + const needsRelocation = obstacles.some(({ id: blockId, box }) => { + if (!boxesOverlap(box, noteBox)) return false + if (previousBlocks) { + return !noteOverlappedBlockBefore(previousBlocks, id, blockId) + } + return true + }) + + if (!needsRelocation) continue + + note.position = { x: minX, y: stackY } + stackY += dimensions.height + verticalSpacing + } + } +} + /** * Groups blocks by their parent container */ diff --git a/apps/sim/package.json b/apps/sim/package.json index 48037fcbd7f..c9df40e6897 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -70,6 +70,7 @@ "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", "@opentelemetry/exporter-trace-otlp-http": "^0.217.0", "@opentelemetry/resources": "^2.7.0", + "@opentelemetry/sdk-metrics": "^2.7.0", "@opentelemetry/sdk-node": "^0.217.0", "@opentelemetry/sdk-trace-base": "^2.7.0", "@opentelemetry/sdk-trace-node": "^2.7.0", @@ -114,6 +115,7 @@ "better-auth-harmony": "1.3.1", "binary-extensions": "3.1.0", "browser-image-compression": "^2.0.2", + "busboy": "1.6.0", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -130,6 +132,7 @@ "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", + "fractional-indexing": "3.2.0", "framer-motion": "^12.5.0", "free-email-domains": "1.2.25", "google-auth-library": "10.5.0", @@ -212,6 +215,7 @@ "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", "@trigger.dev/build": "4.4.3", + "@types/busboy": "1.5.4", "@types/fluent-ffmpeg": "2.1.28", "@types/html-to-text": "9.0.4", "@types/js-yaml": "4.0.9", diff --git a/apps/sim/public/static/copilot.gif b/apps/sim/public/static/copilot.gif deleted file mode 100644 index c84f3f4db03..00000000000 Binary files a/apps/sim/public/static/copilot.gif and /dev/null differ diff --git a/apps/sim/public/static/files.gif b/apps/sim/public/static/files.gif new file mode 100644 index 00000000000..1da78363ebb Binary files /dev/null and b/apps/sim/public/static/files.gif differ diff --git a/apps/sim/public/static/knowledge.gif b/apps/sim/public/static/knowledge.gif index 82abd00515c..8b720394bf5 100644 Binary files a/apps/sim/public/static/knowledge.gif and b/apps/sim/public/static/knowledge.gif differ diff --git a/apps/sim/public/static/mothership.gif b/apps/sim/public/static/mothership.gif new file mode 100644 index 00000000000..7d35f17c572 Binary files /dev/null and b/apps/sim/public/static/mothership.gif differ diff --git a/apps/sim/public/static/tables.png b/apps/sim/public/static/tables.png new file mode 100644 index 00000000000..1c622ae253e Binary files /dev/null and b/apps/sim/public/static/tables.png differ diff --git a/apps/sim/public/static/workflow.gif b/apps/sim/public/static/workflow.gif index 644bc2e25d3..8dd732a9f41 100644 Binary files a/apps/sim/public/static/workflow.gif and b/apps/sim/public/static/workflow.gif differ diff --git a/apps/sim/scripts/backfill-table-order-keys.ts b/apps/sim/scripts/backfill-table-order-keys.ts new file mode 100644 index 00000000000..d960e990181 --- /dev/null +++ b/apps/sim/scripts/backfill-table-order-keys.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env bun + +/** + * Backfills the `order_key` column on `user_table_rows`. + * + * Row ordering is moving from the contiguous integer `position` to a fractional + * string `order_key` (O(1) insert/delete — no reshift/recompact). This script + * assigns each existing row a key derived from its current `position` order, so + * the new ordering matches today's once the `TABLES_FRACTIONAL_ORDERING` flag is + * flipped on. + * + * Per-table-atomic: each table is keyed inside one transaction holding the same + * per-table advisory lock the app uses for inserts, so a concurrent insert can't + * interleave. Idempotent: tables already fully keyed are skipped; a table with + * any NULL key is fully re-keyed from `position` order (deterministic, so a + * re-run after a partial failure is safe). + * + * Usage: + * DATABASE_URL=... bun run apps/sim/scripts/backfill-table-order-keys.ts + * DATABASE_URL=... bun run apps/sim/scripts/backfill-table-order-keys.ts --dry-run + */ + +import { userTableRows } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { asc, eq, isNull, sql } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { nKeysBetween } from '@/lib/table/order-key' + +/** + * Rows written per `UPDATE … FROM (VALUES …)`. One statement for a whole large table builds an + * enormous VALUES list that overflows the JS call stack while drizzle assembles it (and would + * exceed Postgres's 65535-bound-param limit at ~32k rows, 2 params/row). 5000 keeps ~10k params + * — well under both ceilings — while minimizing round-trips. Chunks share the one per-table + * transaction, so the table is still keyed atomically. + */ +const WRITE_CHUNK_SIZE = 5000 + +export async function runBackfill(): Promise { + const dryRun = process.argv.includes('--dry-run') + const connectionString = process.env.DATABASE_URL ?? process.env.POSTGRES_URL + if (!connectionString) { + console.error('Missing DATABASE_URL or POSTGRES_URL') + process.exit(1) + } + + const client = postgres(connectionString, { + prepare: false, + idle_timeout: 20, + connect_timeout: 30, + max: 5, + onnotice: () => {}, + }) + const db = drizzle(client) + + const stats = { tables: 0, tablesKeyed: 0, rowsKeyed: 0, failed: 0 } + + try { + // Tables that still have at least one un-keyed row. + const pending = await db + .selectDistinct({ tableId: userTableRows.tableId }) + .from(userTableRows) + .where(isNull(userTableRows.orderKey)) + + console.log( + `Backfill starting — ${pending.length} table(s) with NULL order_key${dryRun ? ' [DRY RUN]' : ''}` + ) + + for (const { tableId } of pending) { + stats.tables += 1 + try { + const keyed = await db.transaction(async (trx) => { + // Serialize with concurrent inserts on this table (same lock the app uses). + await trx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))` + ) + const rows = await trx + .select({ id: userTableRows.id }) + .from(userTableRows) + .where(eq(userTableRows.tableId, tableId)) + .orderBy(asc(userTableRows.position), asc(userTableRows.id)) + + if (rows.length === 0) return 0 + const keys = nKeysBetween(null, null, rows.length) + if (dryRun) return rows.length + + // Chunked UPDATE … FROM (VALUES …) mapping id → key (see WRITE_CHUNK_SIZE). + for (let start = 0; start < rows.length; start += WRITE_CHUNK_SIZE) { + const chunk = rows.slice(start, start + WRITE_CHUNK_SIZE) + const values = sql.join( + chunk.map((r, i) => sql`(${r.id}, ${keys[start + i]})`), + sql`, ` + ) + await trx.execute(sql` + UPDATE user_table_rows AS t + SET order_key = v.order_key + FROM (VALUES ${values}) AS v(id, order_key) + WHERE t.id = v.id AND t.table_id = ${tableId} + `) + } + return rows.length + }) + stats.tablesKeyed += 1 + stats.rowsKeyed += keyed + console.log(` ${tableId}: keyed ${keyed} rows`) + } catch (error) { + stats.failed += 1 + console.error(` ${tableId}: FAILED — ${getErrorMessage(error)}`) + } + } + + console.log('Backfill complete.') + console.log(` tables scanned: ${stats.tables}`) + console.log(` tables keyed: ${stats.tablesKeyed}`) + console.log(` rows keyed: ${stats.rowsKeyed}`) + console.log(` failed: ${stats.failed}`) + if (stats.failed > 0) process.exitCode = 1 + } finally { + await client.end({ timeout: 5 }).catch(() => {}) + } +} + +if ((import.meta as { main?: boolean }).main) { + try { + await runBackfill() + } catch (error) { + console.error('Backfill aborted:', getErrorMessage(error)) + process.exitCode = 1 + } +} diff --git a/apps/sim/stores/table/import-tray/store.ts b/apps/sim/stores/table/import-tray/store.ts new file mode 100644 index 00000000000..66be5080894 --- /dev/null +++ b/apps/sim/stores/table/import-tray/store.ts @@ -0,0 +1,109 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +/** + * An in-flight client upload, shown optimistically before its server import row exists or the + * table list has refreshed. Keyed by `uploadId`: a `pending_*` id (creating a new table, no row + * yet) or the target tableId (append/replace into an existing table). + */ +export interface ImportUpload { + uploadId: string + workspaceId: string + title: string + /** Byte-based upload percent from the client XHR. */ + percent?: number +} + +/** + * Client-only state for the import tray. The importing/terminal rows themselves are derived from + * the table list (React Query) — this store holds only what the server doesn't: optimistic uploads, + * which terminal completions to surface this session, canceled ids, and the menu's open state. + */ +interface ImportTrayState { + uploads: Record + /** Terminal (`ready`/`failed`) table ids to surface as a card this session. */ + notified: Record + /** Ids (upload or table) canceled so callbacks/derivation don't resurrect them. */ + canceledIds: Record + menuOpen: boolean + + startUpload: (upload: ImportUpload) => void + setUploadPercent: (uploadId: string, percent: number) => void + endUpload: (uploadId: string) => void + /** Surface a terminal completion as a tray card. */ + notify: (tableId: string) => void + /** Remove a terminal card (manual dismiss or auto-clear). */ + dismiss: (tableId: string) => void + /** Flag an id canceled and drop any optimistic upload for it. */ + cancel: (id: string) => void + isCanceled: (id: string) => boolean + /** Returns whether the id was canceled and clears the flag (one-shot, for the kickoff handler). */ + consumeCanceled: (id: string) => boolean + setMenuOpen: (open: boolean) => void + reset: () => void +} + +const initialState = { + uploads: {} as Record, + notified: {} as Record, + canceledIds: {} as Record, + menuOpen: false, +} + +export const useImportTrayStore = create()( + devtools( + (set, get) => ({ + ...initialState, + + startUpload: (upload) => + set((state) => ({ uploads: { ...state.uploads, [upload.uploadId]: upload } })), + + setUploadPercent: (uploadId, percent) => + set((state) => { + const prev = state.uploads[uploadId] + if (!prev) return state + return { uploads: { ...state.uploads, [uploadId]: { ...prev, percent } } } + }), + + endUpload: (uploadId) => + set((state) => { + if (!state.uploads[uploadId]) return state + const { [uploadId]: _removed, ...rest } = state.uploads + return { uploads: rest } + }), + + notify: (tableId) => set((state) => ({ notified: { ...state.notified, [tableId]: true } })), + + dismiss: (tableId) => + set((state) => { + if (!state.notified[tableId]) return state + const { [tableId]: _removed, ...rest } = state.notified + return { notified: rest } + }), + + cancel: (id) => + set((state) => { + const { [id]: _removed, ...uploads } = state.uploads + return { uploads, canceledIds: { ...state.canceledIds, [id]: true } } + }), + + isCanceled: (id) => Boolean(get().canceledIds[id]), + + consumeCanceled: (id) => { + const was = Boolean(get().canceledIds[id]) + if (was) { + set((state) => { + const { [id]: _removed, ...rest } = state.canceledIds + return { canceledIds: rest } + }) + } + return was + }, + + setMenuOpen: (open) => set({ menuOpen: open }), + + reset: () => set(initialState), + }), + { name: 'import-tray-store' } + ) +) diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 68496d3cc81..cd9daef22c4 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -8,6 +8,8 @@ export interface DeletedRowSnapshot { rowId: string data: Record position: number + /** Fractional order key, when present — restore re-inserts at this exact key. */ + orderKey?: string } export type TableUndoAction = @@ -27,10 +29,21 @@ export type TableUndoAction = newData: Record }> } - | { type: 'create-row'; rowId: string; position: number; data?: Record } + | { + type: 'create-row' + rowId: string + position: number + orderKey?: string + data?: Record + } | { type: 'create-rows' - rows: Array<{ rowId: string; position: number; data: Record }> + rows: Array<{ + rowId: string + position: number + orderKey?: string + data: Record + }> } | { type: 'delete-rows'; rows: DeletedRowSnapshot[] } | { type: 'create-column'; columnName: string; position: number } diff --git a/apps/sim/tools/clickhouse/count-rows.ts b/apps/sim/tools/clickhouse/count-rows.ts new file mode 100644 index 00000000000..0bef7b83b59 --- /dev/null +++ b/apps/sim/tools/clickhouse/count-rows.ts @@ -0,0 +1,100 @@ +import type { ClickHouseCountResponse, ClickHouseCountRowsParams } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const countRowsTool: ToolConfig = { + id: 'clickhouse_count_rows', + name: 'ClickHouse Count Rows', + description: 'Count rows in a ClickHouse table, optionally filtered', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to count rows in', + }, + where: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional WHERE clause condition without the WHERE keyword', + }, + }, + + request: { + url: '/api/tools/clickhouse/count-rows', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + where: params.where, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse count rows failed') + } + + return { + success: true, + output: { + message: data.message || 'Row count retrieved', + count: data.count ?? 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + count: { type: 'number', description: 'Number of rows' }, + }, +} diff --git a/apps/sim/tools/clickhouse/create-database.ts b/apps/sim/tools/clickhouse/create-database.ts new file mode 100644 index 00000000000..422a646fcfd --- /dev/null +++ b/apps/sim/tools/clickhouse/create-database.ts @@ -0,0 +1,97 @@ +import type { + ClickHouseCreateDatabaseParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const createDatabaseTool: ToolConfig< + ClickHouseCreateDatabaseParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_create_database', + name: 'ClickHouse Create Database', + description: 'Create a new database on a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the database to create', + }, + }, + + request: { + url: '/api/tools/clickhouse/create-database', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse create database failed') + } + + return { + success: true, + output: { + message: data.message || 'Database created', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/create-table.ts b/apps/sim/tools/clickhouse/create-table.ts new file mode 100644 index 00000000000..aff3deb4170 --- /dev/null +++ b/apps/sim/tools/clickhouse/create-table.ts @@ -0,0 +1,123 @@ +import type { + ClickHouseCreateTableParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const createTableTool: ToolConfig = { + id: 'clickhouse_create_table', + name: 'ClickHouse Create Table', + description: 'Create a new MergeTree-family table in ClickHouse', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to create', + }, + columns: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of column definitions, each an object with name and type, e.g. [{"name":"id","type":"UInt64"},{"name":"ts","type":"DateTime"}]', + }, + engine: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Table engine (default MergeTree)', + }, + orderBy: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ORDER BY expression, e.g. "id" or "(id, ts)"', + }, + partitionBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional PARTITION BY expression, e.g. toYYYYMM(ts)', + }, + }, + + request: { + url: '/api/tools/clickhouse/create-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + columns: params.columns, + engine: params.engine, + orderBy: params.orderBy, + partitionBy: params.partitionBy, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse create table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table created', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/delete.ts b/apps/sim/tools/clickhouse/delete.ts new file mode 100644 index 00000000000..3455d965ea9 --- /dev/null +++ b/apps/sim/tools/clickhouse/delete.ts @@ -0,0 +1,102 @@ +import type { ClickHouseDeleteParams, ClickHouseDeleteResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteTool: ToolConfig = { + id: 'clickhouse_delete', + name: 'ClickHouse Delete', + description: 'Delete rows from a ClickHouse table via an ALTER TABLE ... DELETE mutation', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to delete data from', + }, + where: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WHERE clause condition (without the WHERE keyword)', + }, + }, + + request: { + url: '/api/tools/clickhouse/delete', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + where: params.where, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse delete failed') + } + + return { + success: true, + output: { + message: data.message || 'Delete mutation submitted successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Deleted rows (empty for ClickHouse mutations)' }, + rowCount: { type: 'number', description: 'Number of rows affected by the mutation' }, + }, +} diff --git a/apps/sim/tools/clickhouse/describe-table.ts b/apps/sim/tools/clickhouse/describe-table.ts new file mode 100644 index 00000000000..ed394defd25 --- /dev/null +++ b/apps/sim/tools/clickhouse/describe-table.ts @@ -0,0 +1,99 @@ +import type { + ClickHouseDescribeTableParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const describeTableTool: ToolConfig = + { + id: 'clickhouse_describe_table', + name: 'ClickHouse Describe Table', + description: 'Describe the columns of a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to describe', + }, + }, + + request: { + url: '/api/tools/clickhouse/describe-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse describe table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table described', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + } diff --git a/apps/sim/tools/clickhouse/drop-database.ts b/apps/sim/tools/clickhouse/drop-database.ts new file mode 100644 index 00000000000..3b644f81d13 --- /dev/null +++ b/apps/sim/tools/clickhouse/drop-database.ts @@ -0,0 +1,95 @@ +import type { + ClickHouseDropDatabaseParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const dropDatabaseTool: ToolConfig = + { + id: 'clickhouse_drop_database', + name: 'ClickHouse Drop Database', + description: 'Drop a database from a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the database to drop', + }, + }, + + request: { + url: '/api/tools/clickhouse/drop-database', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse drop database failed') + } + + return { + success: true, + output: { + message: data.message || 'Database dropped', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, + } diff --git a/apps/sim/tools/clickhouse/drop-partition.ts b/apps/sim/tools/clickhouse/drop-partition.ts new file mode 100644 index 00000000000..dbee7aeefa3 --- /dev/null +++ b/apps/sim/tools/clickhouse/drop-partition.ts @@ -0,0 +1,104 @@ +import type { + ClickHouseDropPartitionParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const dropPartitionTool: ToolConfig< + ClickHouseDropPartitionParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_drop_partition', + name: 'ClickHouse Drop Partition', + description: 'Drop a partition from a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name', + }, + partition: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Partition expression, e.g. '2024-01' or 202401", + }, + }, + + request: { + url: '/api/tools/clickhouse/drop-partition', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + partition: params.partition, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse drop partition failed') + } + + return { + success: true, + output: { + message: data.message || 'Partition dropped', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/drop-table.ts b/apps/sim/tools/clickhouse/drop-table.ts new file mode 100644 index 00000000000..dcf39b04e3e --- /dev/null +++ b/apps/sim/tools/clickhouse/drop-table.ts @@ -0,0 +1,91 @@ +import type { ClickHouseDropTableParams, ClickHouseMessageResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const dropTableTool: ToolConfig = { + id: 'clickhouse_drop_table', + name: 'ClickHouse Drop Table', + description: 'Drop a table from a ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to drop', + }, + }, + + request: { + url: '/api/tools/clickhouse/drop-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse drop table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table dropped', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/execute.ts b/apps/sim/tools/clickhouse/execute.ts new file mode 100644 index 00000000000..a6455d8f874 --- /dev/null +++ b/apps/sim/tools/clickhouse/execute.ts @@ -0,0 +1,95 @@ +import type { ClickHouseExecuteParams, ClickHouseExecuteResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const executeTool: ToolConfig = { + id: 'clickhouse_execute', + name: 'ClickHouse Execute', + description: 'Execute raw SQL (DDL, mutations, or queries) on a ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Raw SQL statement to execute', + }, + }, + + request: { + url: '/api/tools/clickhouse/execute', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + query: params.query, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse execute failed') + } + + return { + success: true, + output: { + message: data.message || 'Statement executed successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the statement' }, + rowCount: { type: 'number', description: 'Number of rows returned or affected' }, + }, +} diff --git a/apps/sim/tools/clickhouse/index.ts b/apps/sim/tools/clickhouse/index.ts new file mode 100644 index 00000000000..699fecc3925 --- /dev/null +++ b/apps/sim/tools/clickhouse/index.ts @@ -0,0 +1,53 @@ +import { countRowsTool } from './count-rows' +import { createDatabaseTool } from './create-database' +import { createTableTool } from './create-table' +import { deleteTool } from './delete' +import { describeTableTool } from './describe-table' +import { dropDatabaseTool } from './drop-database' +import { dropPartitionTool } from './drop-partition' +import { dropTableTool } from './drop-table' +import { executeTool } from './execute' +import { insertTool } from './insert' +import { insertRowsTool } from './insert-rows' +import { introspectTool } from './introspect' +import { killQueryTool } from './kill-query' +import { listClustersTool } from './list-clusters' +import { listDatabasesTool } from './list-databases' +import { listMutationsTool } from './list-mutations' +import { listPartitionsTool } from './list-partitions' +import { listRunningQueriesTool } from './list-running-queries' +import { listTablesTool } from './list-tables' +import { optimizeTableTool } from './optimize-table' +import { queryTool } from './query' +import { renameTableTool } from './rename-table' +import { showCreateTableTool } from './show-create-table' +import { tableStatsTool } from './table-stats' +import { truncateTableTool } from './truncate-table' +import { updateTool } from './update' + +export const clickhouseQueryTool = queryTool +export const clickhouseExecuteTool = executeTool +export const clickhouseInsertTool = insertTool +export const clickhouseInsertRowsTool = insertRowsTool +export const clickhouseUpdateTool = updateTool +export const clickhouseDeleteTool = deleteTool +export const clickhouseIntrospectTool = introspectTool +export const clickhouseListDatabasesTool = listDatabasesTool +export const clickhouseListTablesTool = listTablesTool +export const clickhouseDescribeTableTool = describeTableTool +export const clickhouseShowCreateTableTool = showCreateTableTool +export const clickhouseCountRowsTool = countRowsTool +export const clickhouseListPartitionsTool = listPartitionsTool +export const clickhouseListMutationsTool = listMutationsTool +export const clickhouseListRunningQueriesTool = listRunningQueriesTool +export const clickhouseTableStatsTool = tableStatsTool +export const clickhouseListClustersTool = listClustersTool +export const clickhouseCreateDatabaseTool = createDatabaseTool +export const clickhouseDropDatabaseTool = dropDatabaseTool +export const clickhouseCreateTableTool = createTableTool +export const clickhouseDropTableTool = dropTableTool +export const clickhouseTruncateTableTool = truncateTableTool +export const clickhouseRenameTableTool = renameTableTool +export const clickhouseOptimizeTableTool = optimizeTableTool +export const clickhouseDropPartitionTool = dropPartitionTool +export const clickhouseKillQueryTool = killQueryTool diff --git a/apps/sim/tools/clickhouse/insert-rows.ts b/apps/sim/tools/clickhouse/insert-rows.ts new file mode 100644 index 00000000000..981bb128c37 --- /dev/null +++ b/apps/sim/tools/clickhouse/insert-rows.ts @@ -0,0 +1,102 @@ +import type { ClickHouseInsertRowsParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const insertRowsTool: ToolConfig = { + id: 'clickhouse_insert_rows', + name: 'ClickHouse Insert Rows', + description: 'Insert multiple rows into a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table to insert into', + }, + rows: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Array of row objects to insert, e.g. [{"id":1,"name":"a"},{"id":2,"name":"b"}]', + }, + }, + + request: { + url: '/api/tools/clickhouse/insert-rows', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + rows: params.rows, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse insert rows failed') + } + + return { + success: true, + output: { + message: data.message || 'Rows inserted', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Inserted rows (empty for ClickHouse inserts)' }, + rowCount: { type: 'number', description: 'Number of rows inserted' }, + }, +} diff --git a/apps/sim/tools/clickhouse/insert.ts b/apps/sim/tools/clickhouse/insert.ts new file mode 100644 index 00000000000..5c5ed2c60ca --- /dev/null +++ b/apps/sim/tools/clickhouse/insert.ts @@ -0,0 +1,102 @@ +import type { ClickHouseInsertParams, ClickHouseInsertResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const insertTool: ToolConfig = { + id: 'clickhouse_insert', + name: 'ClickHouse Insert', + description: 'Insert a row into a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to insert data into', + }, + data: { + type: 'object', + required: true, + visibility: 'user-or-llm', + description: 'Data object to insert (key-value pairs mapping column names to values)', + }, + }, + + request: { + url: '/api/tools/clickhouse/insert', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + data: params.data, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse insert failed') + } + + return { + success: true, + output: { + message: data.message || 'Data inserted successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Inserted rows (empty for ClickHouse inserts)' }, + rowCount: { type: 'number', description: 'Number of rows inserted' }, + }, +} diff --git a/apps/sim/tools/clickhouse/introspect.ts b/apps/sim/tools/clickhouse/introspect.ts new file mode 100644 index 00000000000..e7f44dca020 --- /dev/null +++ b/apps/sim/tools/clickhouse/introspect.ts @@ -0,0 +1,99 @@ +import { + CLICKHOUSE_TABLE_OUTPUT_PROPERTIES, + type ClickHouseIntrospectParams, + type ClickHouseIntrospectResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const introspectTool: ToolConfig = + { + id: 'clickhouse_introspect', + name: 'ClickHouse Introspect', + description: + 'Introspect a ClickHouse database to retrieve table structures, columns, and engines', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to introspect', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/introspect', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse introspection failed') + } + + return { + success: true, + output: { + message: data.message || 'Schema introspection completed successfully', + tables: data.tables || [], + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + tables: { + type: 'array', + description: 'Array of table schemas with columns and engines', + items: { + type: 'object', + properties: CLICKHOUSE_TABLE_OUTPUT_PROPERTIES, + }, + }, + }, + } diff --git a/apps/sim/tools/clickhouse/kill-query.ts b/apps/sim/tools/clickhouse/kill-query.ts new file mode 100644 index 00000000000..ffbaef1a2b8 --- /dev/null +++ b/apps/sim/tools/clickhouse/kill-query.ts @@ -0,0 +1,95 @@ +import type { ClickHouseKillQueryParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const killQueryTool: ToolConfig = { + id: 'clickhouse_kill_query', + name: 'ClickHouse Kill Query', + description: 'Kill a running query by its query ID', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + queryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The query_id of the running query to kill', + }, + }, + + request: { + url: '/api/tools/clickhouse/kill-query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + queryId: params.queryId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse kill query failed') + } + + return { + success: true, + output: { + message: data.message || 'Kill command executed', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Kill status rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-clusters.ts b/apps/sim/tools/clickhouse/list-clusters.ts new file mode 100644 index 00000000000..1b8e188f0d6 --- /dev/null +++ b/apps/sim/tools/clickhouse/list-clusters.ts @@ -0,0 +1,88 @@ +import type { ClickHouseListClustersParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listClustersTool: ToolConfig = { + id: 'clickhouse_list_clusters', + name: 'ClickHouse List Clusters', + description: 'List configured clusters, shards, and replicas', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-clusters', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list clusters failed') + } + + return { + success: true, + output: { + message: data.message || 'Clusters retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of cluster node rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-databases.ts b/apps/sim/tools/clickhouse/list-databases.ts new file mode 100644 index 00000000000..2fcfb92cd3e --- /dev/null +++ b/apps/sim/tools/clickhouse/list-databases.ts @@ -0,0 +1,92 @@ +import type { + ClickHouseListDatabasesParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listDatabasesTool: ToolConfig = + { + id: 'clickhouse_list_databases', + name: 'ClickHouse List Databases', + description: 'List all databases on a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-databases', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list databases failed') + } + + return { + success: true, + output: { + message: data.message || 'Databases retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'List of databases with engine and comment' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + } diff --git a/apps/sim/tools/clickhouse/list-mutations.ts b/apps/sim/tools/clickhouse/list-mutations.ts new file mode 100644 index 00000000000..0fac681667a --- /dev/null +++ b/apps/sim/tools/clickhouse/list-mutations.ts @@ -0,0 +1,106 @@ +import type { + ClickHouseListMutationsParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listMutationsTool: ToolConfig = + { + id: 'clickhouse_list_mutations', + name: 'ClickHouse List Mutations', + description: 'List mutations (async ALTER UPDATE/DELETE) for the connected database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional table name to filter mutations', + }, + onlyRunning: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only show mutations that are still running', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-mutations', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + onlyRunning: params.onlyRunning, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list mutations failed') + } + + return { + success: true, + output: { + message: data.message || 'Mutations retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of mutation rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + } diff --git a/apps/sim/tools/clickhouse/list-partitions.ts b/apps/sim/tools/clickhouse/list-partitions.ts new file mode 100644 index 00000000000..e2b92fd44ea --- /dev/null +++ b/apps/sim/tools/clickhouse/list-partitions.ts @@ -0,0 +1,101 @@ +import type { + ClickHouseListPartitionsParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listPartitionsTool: ToolConfig< + ClickHouseListPartitionsParams, + ClickHouseRowsResponse +> = { + id: 'clickhouse_list_partitions', + name: 'ClickHouse List Partitions', + description: 'List active partitions for a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to inspect partitions for', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-partitions', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list partitions failed') + } + + return { + success: true, + output: { + message: data.message || 'Partitions retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-running-queries.ts b/apps/sim/tools/clickhouse/list-running-queries.ts new file mode 100644 index 00000000000..cce6bbf21f3 --- /dev/null +++ b/apps/sim/tools/clickhouse/list-running-queries.ts @@ -0,0 +1,94 @@ +import type { + ClickHouseListRunningQueriesParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listRunningQueriesTool: ToolConfig< + ClickHouseListRunningQueriesParams, + ClickHouseRowsResponse +> = { + id: 'clickhouse_list_running_queries', + name: 'ClickHouse List Running Queries', + description: 'List currently running queries on a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-running-queries', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list running queries failed') + } + + return { + success: true, + output: { + message: data.message || 'Running queries retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-tables.ts b/apps/sim/tools/clickhouse/list-tables.ts new file mode 100644 index 00000000000..b80a7f63cd0 --- /dev/null +++ b/apps/sim/tools/clickhouse/list-tables.ts @@ -0,0 +1,88 @@ +import type { ClickHouseListTablesParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listTablesTool: ToolConfig = { + id: 'clickhouse_list_tables', + name: 'ClickHouse List Tables', + description: 'List tables in the connected ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-tables', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list tables failed') + } + + return { + success: true, + output: { + message: data.message || 'Tables retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/optimize-table.ts b/apps/sim/tools/clickhouse/optimize-table.ts new file mode 100644 index 00000000000..13632d61154 --- /dev/null +++ b/apps/sim/tools/clickhouse/optimize-table.ts @@ -0,0 +1,104 @@ +import type { + ClickHouseMessageResponse, + ClickHouseOptimizeTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const optimizeTableTool: ToolConfig< + ClickHouseOptimizeTableParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_optimize_table', + name: 'ClickHouse Optimize Table', + description: 'Trigger a merge of table parts via OPTIMIZE TABLE', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table to optimize', + }, + final: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Force a merge to a single part using FINAL', + }, + }, + + request: { + url: '/api/tools/clickhouse/optimize-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + final: params.final, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse optimize table failed') + } + + return { + success: true, + output: { + message: data.message || 'Optimize submitted', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/query.ts b/apps/sim/tools/clickhouse/query.ts new file mode 100644 index 00000000000..fb1e5b568b3 --- /dev/null +++ b/apps/sim/tools/clickhouse/query.ts @@ -0,0 +1,95 @@ +import type { ClickHouseQueryParams, ClickHouseQueryResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const queryTool: ToolConfig = { + id: 'clickhouse_query', + name: 'ClickHouse Query', + description: 'Execute a SELECT query on a ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SQL SELECT query to execute', + }, + }, + + request: { + url: '/api/tools/clickhouse/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + query: params.query, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse query failed') + } + + return { + success: true, + output: { + message: data.message || 'Query executed successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/rename-table.ts b/apps/sim/tools/clickhouse/rename-table.ts new file mode 100644 index 00000000000..11ddce83644 --- /dev/null +++ b/apps/sim/tools/clickhouse/rename-table.ts @@ -0,0 +1,101 @@ +import type { + ClickHouseMessageResponse, + ClickHouseRenameTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const renameTableTool: ToolConfig = { + id: 'clickhouse_rename_table', + name: 'ClickHouse Rename Table', + description: 'Rename a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Current table name', + }, + newTable: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New table name', + }, + }, + + request: { + url: '/api/tools/clickhouse/rename-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + newTable: params.newTable, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse rename table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table renamed', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/show-create-table.ts b/apps/sim/tools/clickhouse/show-create-table.ts new file mode 100644 index 00000000000..fe8ec40fd76 --- /dev/null +++ b/apps/sim/tools/clickhouse/show-create-table.ts @@ -0,0 +1,99 @@ +import type { + ClickHouseDdlResponse, + ClickHouseShowCreateTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const showCreateTableTool: ToolConfig< + ClickHouseShowCreateTableParams, + ClickHouseDdlResponse +> = { + id: 'clickhouse_show_create_table', + name: 'ClickHouse Show Create Table', + description: 'Get the CREATE TABLE statement (DDL) for a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to get the CREATE statement for', + }, + }, + + request: { + url: '/api/tools/clickhouse/show-create-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse show create table failed') + } + + return { + success: true, + output: { + message: data.message || 'CREATE statement retrieved', + ddl: data.ddl ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + ddl: { type: 'string', description: 'The CREATE TABLE statement' }, + }, +} diff --git a/apps/sim/tools/clickhouse/table-stats.ts b/apps/sim/tools/clickhouse/table-stats.ts new file mode 100644 index 00000000000..2c24f53ee7b --- /dev/null +++ b/apps/sim/tools/clickhouse/table-stats.ts @@ -0,0 +1,95 @@ +import type { ClickHouseRowsResponse, ClickHouseTableStatsParams } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const tableStatsTool: ToolConfig = { + id: 'clickhouse_table_stats', + name: 'ClickHouse Table Stats', + description: 'Get row counts and on-disk size for tables in the connected database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional table name to get stats for', + }, + }, + + request: { + url: '/api/tools/clickhouse/table-stats', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse table stats failed') + } + + return { + success: true, + output: { + message: data.message || 'Table stats retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of table stats rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/truncate-table.ts b/apps/sim/tools/clickhouse/truncate-table.ts new file mode 100644 index 00000000000..cf653823fa9 --- /dev/null +++ b/apps/sim/tools/clickhouse/truncate-table.ts @@ -0,0 +1,97 @@ +import type { + ClickHouseMessageResponse, + ClickHouseTruncateTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const truncateTableTool: ToolConfig< + ClickHouseTruncateTableParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_truncate_table', + name: 'ClickHouse Truncate Table', + description: 'Remove all rows from a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to truncate', + }, + }, + + request: { + url: '/api/tools/clickhouse/truncate-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse truncate table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table truncated', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/types.ts b/apps/sim/tools/clickhouse/types.ts new file mode 100644 index 00000000000..99258d04f7e --- /dev/null +++ b/apps/sim/tools/clickhouse/types.ts @@ -0,0 +1,213 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** + * Output property definitions for ClickHouse introspection and query responses. + * @see https://clickhouse.com/docs/sql-reference/statements/system + */ + +/** + * Output definition for table column objects from introspection. + * @see https://clickhouse.com/docs/operations/system-tables/columns + */ +export const CLICKHOUSE_COLUMN_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'ClickHouse data type (e.g., UInt32, String, DateTime)' }, + defaultKind: { + type: 'string', + description: 'Kind of default expression (DEFAULT, MATERIALIZED, ALIAS)', + optional: true, + }, + defaultExpression: { + type: 'string', + description: 'Default value expression for the column', + optional: true, + }, + isInPrimaryKey: { type: 'boolean', description: 'Whether the column is part of the primary key' }, + isInSortingKey: { type: 'boolean', description: 'Whether the column is part of the sorting key' }, +} as const satisfies Record + +/** + * Output definition for table schema objects from introspection. + * @see https://clickhouse.com/docs/operations/system-tables/tables + */ +export const CLICKHOUSE_TABLE_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Table name' }, + database: { type: 'string', description: 'Database the table belongs to' }, + engine: { type: 'string', description: 'Table engine (e.g., MergeTree, Log)' }, + totalRows: { + type: 'number', + description: 'Approximate total number of rows in the table', + optional: true, + }, + columns: { + type: 'array', + description: 'Table columns', + items: { + type: 'object', + properties: CLICKHOUSE_COLUMN_OUTPUT_PROPERTIES, + }, + }, +} as const satisfies Record + +export interface ClickHouseConnectionConfig { + host: string + port: number + database: string + username: string + password: string + secure: boolean +} + +export interface ClickHouseQueryParams extends ClickHouseConnectionConfig { + query: string +} + +export interface ClickHouseExecuteParams extends ClickHouseConnectionConfig { + query: string +} + +export interface ClickHouseInsertParams extends ClickHouseConnectionConfig { + table: string + data: Record +} + +export interface ClickHouseUpdateParams extends ClickHouseConnectionConfig { + table: string + data: Record + where: string +} + +export interface ClickHouseDeleteParams extends ClickHouseConnectionConfig { + table: string + where: string +} + +export interface ClickHouseIntrospectParams extends ClickHouseConnectionConfig {} + +export interface ClickHouseRowsResponse extends ToolResponse { + output: { + message: string + rows: unknown[] + rowCount: number + } + error?: string +} + +export interface ClickHouseMessageResponse extends ToolResponse { + output: { + message: string + } + error?: string +} + +export interface ClickHouseCountResponse extends ToolResponse { + output: { + message: string + count: number + } + error?: string +} + +export interface ClickHouseDdlResponse extends ToolResponse { + output: { + message: string + ddl: string + } + error?: string +} + +export interface ClickHouseListDatabasesParams extends ClickHouseConnectionConfig {} +export interface ClickHouseListTablesParams extends ClickHouseConnectionConfig {} +export interface ClickHouseDescribeTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseShowCreateTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseCountRowsParams extends ClickHouseConnectionConfig { + table: string + where?: string +} +export interface ClickHouseListPartitionsParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseListMutationsParams extends ClickHouseConnectionConfig { + table?: string + onlyRunning?: boolean +} +export interface ClickHouseListRunningQueriesParams extends ClickHouseConnectionConfig {} +export interface ClickHouseTableStatsParams extends ClickHouseConnectionConfig { + table?: string +} +export interface ClickHouseListClustersParams extends ClickHouseConnectionConfig {} +export interface ClickHouseCreateDatabaseParams extends ClickHouseConnectionConfig { + name: string +} +export interface ClickHouseDropDatabaseParams extends ClickHouseConnectionConfig { + name: string +} +export interface ClickHouseCreateTableParams extends ClickHouseConnectionConfig { + table: string + columns: Array<{ name: string; type: string }> + engine: string + orderBy: string + partitionBy?: string +} +export interface ClickHouseDropTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseTruncateTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseRenameTableParams extends ClickHouseConnectionConfig { + table: string + newTable: string +} +export interface ClickHouseOptimizeTableParams extends ClickHouseConnectionConfig { + table: string + final?: boolean +} +export interface ClickHouseDropPartitionParams extends ClickHouseConnectionConfig { + table: string + partition: string +} +export interface ClickHouseKillQueryParams extends ClickHouseConnectionConfig { + queryId: string +} +export interface ClickHouseInsertRowsParams extends ClickHouseConnectionConfig { + table: string + rows: Array> +} + +export interface ClickHouseQueryResponse extends ClickHouseRowsResponse {} +export interface ClickHouseExecuteResponse extends ClickHouseRowsResponse {} +export interface ClickHouseInsertResponse extends ClickHouseRowsResponse {} +export interface ClickHouseUpdateResponse extends ClickHouseRowsResponse {} +export interface ClickHouseDeleteResponse extends ClickHouseRowsResponse {} + +interface ClickHouseTableColumn { + name: string + type: string + defaultKind?: string + defaultExpression?: string + isInPrimaryKey: boolean + isInSortingKey: boolean +} + +interface ClickHouseTableSchema { + name: string + database: string + engine: string + totalRows?: number + columns: ClickHouseTableColumn[] +} + +export interface ClickHouseIntrospectResponse extends ToolResponse { + output: { + message: string + tables: ClickHouseTableSchema[] + } + error?: string +} + +export interface ClickHouseResponse extends ClickHouseRowsResponse {} diff --git a/apps/sim/tools/clickhouse/update.ts b/apps/sim/tools/clickhouse/update.ts new file mode 100644 index 00000000000..79d766cf776 --- /dev/null +++ b/apps/sim/tools/clickhouse/update.ts @@ -0,0 +1,109 @@ +import type { ClickHouseUpdateParams, ClickHouseUpdateResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const updateTool: ToolConfig = { + id: 'clickhouse_update', + name: 'ClickHouse Update', + description: 'Update rows in a ClickHouse table via an ALTER TABLE ... UPDATE mutation', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to update data in', + }, + data: { + type: 'object', + required: true, + visibility: 'user-or-llm', + description: 'Data object with fields to update (key-value pairs)', + }, + where: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WHERE clause condition (without the WHERE keyword)', + }, + }, + + request: { + url: '/api/tools/clickhouse/update', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + data: params.data, + where: params.where, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse update failed') + } + + return { + success: true, + output: { + message: data.message || 'Update mutation submitted successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Updated rows (empty for ClickHouse mutations)' }, + rowCount: { type: 'number', description: 'Number of rows written by the mutation' }, + }, +} diff --git a/apps/sim/tools/dagster/delete_run.ts b/apps/sim/tools/dagster/delete_run.ts index 202bebecff4..e6975b4c21d 100644 --- a/apps/sim/tools/dagster/delete_run.ts +++ b/apps/sim/tools/dagster/delete_run.ts @@ -1,5 +1,10 @@ import type { DagsterDeleteRunParams, DagsterDeleteRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface DeleteRunResult { @@ -57,13 +62,9 @@ export const deleteRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: DELETE_RUN_MUTATION, variables: { runId: params.runId }, diff --git a/apps/sim/tools/dagster/get_asset.ts b/apps/sim/tools/dagster/get_asset.ts new file mode 100644 index 00000000000..cfa836a85d9 --- /dev/null +++ b/apps/sim/tools/dagster/get_asset.ts @@ -0,0 +1,167 @@ +import type { DagsterGetAssetParams, DagsterGetAssetResponse } from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +/** Fields selected on `assetOrError` when the union resolves to `Asset`. */ +interface DagsterGetAssetGraphqlAsset { + key: { path: string[] } + definition: { + groupName: string | null + description: string | null + jobNames: string[] | null + computeKind: string | null + isPartitioned: boolean | null + } | null + assetMaterializations: Array<{ + runId: string + timestamp: string + partition: string | null + stepKey: string | null + }> | null +} + +const GET_ASSET_QUERY = ` + query GetAsset($assetKey: AssetKeyInput!) { + assetOrError(assetKey: $assetKey) { + ... on Asset { + key { + path + } + definition { + groupName + description + jobNames + computeKind + isPartitioned + } + assetMaterializations(limit: 1) { + runId + timestamp + partition + stepKey + } + } + ... on AssetNotFoundError { + __typename + message + } + } + } +` + +export const getAssetTool: ToolConfig = { + id: 'dagster_get_asset', + name: 'Dagster Get Asset', + description: 'Get an asset definition and its latest materialization by asset key.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + assetKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Slash-delimited asset key, e.g. "my_asset" or "raw/events"', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => ({ + query: GET_ASSET_QUERY, + variables: { assetKey: { path: parseAssetKeyPath(params.assetKey) } }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ assetOrError?: unknown }>(response) + + const raw = data.data?.assetOrError + if (!raw || typeof raw !== 'object') throw new Error('Unexpected response from Dagster') + + if (!('key' in raw)) { + const errResult = raw as { message?: string } + throw new Error(errResult.message ?? 'Asset not found') + } + + const asset = raw as DagsterGetAssetGraphqlAsset + const latest = asset.assetMaterializations?.[0] ?? null + + return { + success: true, + output: { + assetKey: asset.key.path.join('/'), + path: asset.key.path, + groupName: asset.definition?.groupName ?? null, + description: asset.definition?.description ?? null, + jobNames: asset.definition?.jobNames ?? null, + computeKind: asset.definition?.computeKind ?? null, + isPartitioned: asset.definition?.isPartitioned ?? null, + latestMaterialization: latest + ? { + runId: latest.runId, + timestamp: latest.timestamp, + partition: latest.partition ?? null, + stepKey: latest.stepKey ?? null, + } + : null, + }, + } + }, + + outputs: { + assetKey: { type: 'string', description: 'Slash-joined asset key' }, + path: { type: 'json', description: 'Asset key path segments' }, + groupName: { + type: 'string', + description: 'Asset group the definition belongs to', + optional: true, + }, + description: { type: 'string', description: 'Asset description', optional: true }, + jobNames: { + type: 'json', + description: 'Names of jobs that can materialize this asset', + optional: true, + }, + computeKind: { + type: 'string', + description: 'Compute kind tag (e.g., python, dbt, spark)', + optional: true, + }, + isPartitioned: { + type: 'boolean', + description: 'Whether the asset is partitioned', + optional: true, + }, + latestMaterialization: { + type: 'json', + description: 'Most recent materialization (runId, timestamp, partition, stepKey)', + optional: true, + properties: { + runId: { type: 'string', description: 'Run that produced the materialization' }, + timestamp: { type: 'string', description: 'Materialization timestamp (epoch ms string)' }, + partition: { type: 'string', description: 'Partition key, if partitioned', optional: true }, + stepKey: { type: 'string', description: 'Step key that emitted it', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/dagster/get_run.ts b/apps/sim/tools/dagster/get_run.ts index ca22ae2dfba..b19a3caa41a 100644 --- a/apps/sim/tools/dagster/get_run.ts +++ b/apps/sim/tools/dagster/get_run.ts @@ -1,5 +1,10 @@ import type { DagsterGetRunParams, DagsterGetRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' /** Fields selected on `runOrError` when the union resolves to `Run`. */ @@ -7,8 +12,15 @@ interface DagsterGetRunGraphqlRun { runId: string jobName: string | null status: string + mode: string | null startTime: number | null endTime: number | null + creationTime: number | null + updateTime: number | null + parentRunId: string | null + rootRunId: string | null + canTerminate: boolean + assetSelection: Array<{ path: string[] }> | null runConfigYaml: string | null tags: Array<{ key: string; value: string }> | null } @@ -20,8 +32,17 @@ const GET_RUN_QUERY = ` runId jobName status + mode startTime endTime + creationTime + updateTime + parentRunId + rootRunId + canTerminate + assetSelection { + path + } runConfigYaml tags { key @@ -69,13 +90,9 @@ export const getRunTool: ToolConfig }, request: { - url: (params) => `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: GET_RUN_QUERY, variables: { runId: params.runId }, @@ -102,8 +119,17 @@ export const getRunTool: ToolConfig runId: run.runId, jobName: run.jobName ?? null, status: run.status, + mode: run.mode ?? null, startTime: run.startTime ?? null, endTime: run.endTime ?? null, + creationTime: run.creationTime ?? null, + updateTime: run.updateTime ?? null, + parentRunId: run.parentRunId ?? null, + rootRunId: run.rootRunId ?? null, + canTerminate: run.canTerminate ?? false, + assetSelection: run.assetSelection + ? run.assetSelection.map((key) => key.path.join('/')) + : null, runConfigYaml: run.runConfigYaml ?? null, tags: run.tags ?? null, }, @@ -125,6 +151,11 @@ export const getRunTool: ToolConfig description: 'Run status (QUEUED, NOT_STARTED, STARTING, MANAGED, STARTED, SUCCESS, FAILURE, CANCELING, CANCELED)', }, + mode: { + type: 'string', + description: 'Execution mode of the run', + optional: true, + }, startTime: { type: 'number', description: 'Run start time as Unix timestamp', @@ -135,6 +166,35 @@ export const getRunTool: ToolConfig description: 'Run end time as Unix timestamp', optional: true, }, + creationTime: { + type: 'number', + description: 'Time the run was created as Unix timestamp', + optional: true, + }, + updateTime: { + type: 'number', + description: 'Time the run was last updated as Unix timestamp', + optional: true, + }, + parentRunId: { + type: 'string', + description: 'ID of the immediate parent run (for re-executions)', + optional: true, + }, + rootRunId: { + type: 'string', + description: 'ID of the root run in the re-execution group', + optional: true, + }, + canTerminate: { + type: 'boolean', + description: 'Whether the run can currently be terminated', + }, + assetSelection: { + type: 'json', + description: 'Asset keys targeted by the run, as slash-joined strings', + optional: true, + }, runConfigYaml: { type: 'string', description: 'Run configuration as YAML', diff --git a/apps/sim/tools/dagster/get_run_logs.ts b/apps/sim/tools/dagster/get_run_logs.ts index accbc24038f..aef20e14807 100644 --- a/apps/sim/tools/dagster/get_run_logs.ts +++ b/apps/sim/tools/dagster/get_run_logs.ts @@ -1,5 +1,9 @@ import type { DagsterGetRunLogsParams, DagsterGetRunLogsResponse } from '@/tools/dagster/types' -import { parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface DagsterRunEvent { @@ -87,13 +91,9 @@ export const getRunLogsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const variables: Record = { runId: params.runId } if (params.afterCursor) variables.afterCursor = params.afterCursor diff --git a/apps/sim/tools/dagster/index.ts b/apps/sim/tools/dagster/index.ts index d189f4a4ae0..697eab984e6 100644 --- a/apps/sim/tools/dagster/index.ts +++ b/apps/sim/tools/dagster/index.ts @@ -1,17 +1,22 @@ import { deleteRunTool } from '@/tools/dagster/delete_run' +import { getAssetTool } from '@/tools/dagster/get_asset' import { getRunTool } from '@/tools/dagster/get_run' import { getRunLogsTool } from '@/tools/dagster/get_run_logs' import { launchRunTool } from '@/tools/dagster/launch_run' +import { listAssetsTool } from '@/tools/dagster/list_assets' import { listJobsTool } from '@/tools/dagster/list_jobs' import { listRunsTool } from '@/tools/dagster/list_runs' import { listSchedulesTool } from '@/tools/dagster/list_schedules' import { listSensorsTool } from '@/tools/dagster/list_sensors' +import { materializeAssetsTool } from '@/tools/dagster/materialize_assets' import { reexecuteRunTool } from '@/tools/dagster/reexecute_run' +import { reportAssetMaterializationTool } from '@/tools/dagster/report_asset_materialization' import { startScheduleTool } from '@/tools/dagster/start_schedule' import { startSensorTool } from '@/tools/dagster/start_sensor' import { stopScheduleTool } from '@/tools/dagster/stop_schedule' import { stopSensorTool } from '@/tools/dagster/stop_sensor' import { terminateRunTool } from '@/tools/dagster/terminate_run' +import { wipeAssetTool } from '@/tools/dagster/wipe_asset' export const dagsterLaunchRunTool = launchRunTool export const dagsterGetRunTool = getRunTool @@ -27,5 +32,10 @@ export const dagsterStopScheduleTool = stopScheduleTool export const dagsterListSensorsTool = listSensorsTool export const dagsterStartSensorTool = startSensorTool export const dagsterStopSensorTool = stopSensorTool +export const dagsterListAssetsTool = listAssetsTool +export const dagsterGetAssetTool = getAssetTool +export const dagsterMaterializeAssetsTool = materializeAssetsTool +export const dagsterReportAssetMaterializationTool = reportAssetMaterializationTool +export const dagsterWipeAssetTool = wipeAssetTool export * from './types' diff --git a/apps/sim/tools/dagster/launch_run.ts b/apps/sim/tools/dagster/launch_run.ts index c71cb17d1d3..e58f5ea3d7c 100644 --- a/apps/sim/tools/dagster/launch_run.ts +++ b/apps/sim/tools/dagster/launch_run.ts @@ -1,5 +1,10 @@ import type { DagsterLaunchRunParams, DagsterLaunchRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface LaunchRunResult { @@ -143,13 +148,9 @@ export const launchRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const variables: Record = { repositoryLocationName: params.repositoryLocationName, diff --git a/apps/sim/tools/dagster/list_assets.ts b/apps/sim/tools/dagster/list_assets.ts new file mode 100644 index 00000000000..8b1db2ddfb5 --- /dev/null +++ b/apps/sim/tools/dagster/list_assets.ts @@ -0,0 +1,147 @@ +import type { DagsterListAssetsParams, DagsterListAssetsResponse } from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +/** Default page size applied when the caller omits `limit`, so paging stays bounded and `hasMore` is meaningful. */ +const DEFAULT_LIST_ASSETS_LIMIT = 100 + +/** Shape of each asset node in the `assetsOrError` → `AssetConnection.nodes` selection set. */ +interface DagsterAssetGraphqlNode { + key: { path: string[] } +} + +const LIST_ASSETS_QUERY = ` + query ListAssets($cursor: String, $limit: Int, $prefix: [String!]) { + assetsOrError(cursor: $cursor, limit: $limit, prefix: $prefix) { + ... on AssetConnection { + nodes { + key { + path + } + } + cursor + } + ... on PythonError { + __typename + message + } + } + } +` + +export const listAssetsTool: ToolConfig = { + id: 'dagster_list_assets', + name: 'Dagster List Assets', + description: 'List assets tracked by a Dagster instance, optionally filtered by key prefix.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + prefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Slash-delimited asset key prefix to filter by, e.g. "raw" or "raw/events" (optional)', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Asset key cursor from a previous response, for pagination (optional)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of assets to return per page (default 100)', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => { + const pageSize = params.limit ?? DEFAULT_LIST_ASSETS_LIMIT + // Request one extra row so `hasMore` is exact even when the final page is exactly `pageSize` long. + const variables: Record = { limit: pageSize + 1 } + if (params.prefix) variables.prefix = parseAssetKeyPath(params.prefix) + if (params.cursor) variables.cursor = params.cursor + return { query: LIST_ASSETS_QUERY, variables } + }, + }, + + transformResponse: async (response: Response, params?: DagsterListAssetsParams) => { + const data = await parseDagsterGraphqlResponse<{ assetsOrError?: unknown }>(response) + + const result = data.data?.assetsOrError as + | { nodes?: DagsterAssetGraphqlNode[]; cursor?: string | null; message?: string } + | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (!Array.isArray(result.nodes)) { + throw new Error(dagsterUnionErrorMessage(result, 'List assets failed')) + } + + const pageSize = params?.limit ?? DEFAULT_LIST_ASSETS_LIMIT + const hasMore = result.nodes.length > pageSize + const pageNodes = hasMore ? result.nodes.slice(0, pageSize) : result.nodes + + const assets = pageNodes.map((node) => ({ + assetKey: node.key.path.join('/'), + path: node.key.path, + })) + + // Asset cursors are the JSON-serialized key path; Dagster normalizes JS/Python whitespace on the + // way back in, so we derive the cursor from the last RETURNED asset (not the extra probe row). + const lastPath = pageNodes.length > 0 ? pageNodes[pageNodes.length - 1].key.path : null + + return { + success: true, + output: { + assets, + cursor: lastPath ? JSON.stringify(lastPath) : null, + hasMore, + }, + } + }, + + outputs: { + assets: { + type: 'json', + description: 'Array of assets (assetKey, path)', + properties: { + assetKey: { type: 'string', description: 'Slash-joined asset key' }, + path: { type: 'json', description: 'Asset key path segments' }, + }, + }, + cursor: { + type: 'string', + description: 'Cursor to pass on the next call to fetch more assets', + optional: true, + }, + hasMore: { + type: 'boolean', + description: 'Whether more assets are likely available beyond this page', + }, + }, +} diff --git a/apps/sim/tools/dagster/list_jobs.ts b/apps/sim/tools/dagster/list_jobs.ts index 87df1cdae47..c4461f6add4 100644 --- a/apps/sim/tools/dagster/list_jobs.ts +++ b/apps/sim/tools/dagster/list_jobs.ts @@ -1,5 +1,10 @@ import type { DagsterBaseParams, DagsterListJobsResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' const LIST_JOBS_QUERY = ` @@ -48,13 +53,9 @@ export const listJobsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: () => ({ query: LIST_JOBS_QUERY, variables: {}, diff --git a/apps/sim/tools/dagster/list_runs.ts b/apps/sim/tools/dagster/list_runs.ts index 49bb06f927c..273fa2e5c34 100644 --- a/apps/sim/tools/dagster/list_runs.ts +++ b/apps/sim/tools/dagster/list_runs.ts @@ -1,7 +1,15 @@ import type { DagsterListRunsParams, DagsterListRunsResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' +/** Default page size applied when the caller omits `limit`, so paging stays bounded and `hasMore` is meaningful. */ +const DEFAULT_LIST_RUNS_LIMIT = 20 + /** Shape of each run in the `runsOrError` → `Runs.results` GraphQL selection set. */ interface DagsterListRunsGraphqlRow { runId: string @@ -14,8 +22,8 @@ interface DagsterListRunsGraphqlRow { function buildListRunsQuery(hasFilter: boolean) { return ` - query ListRuns($limit: Int${hasFilter ? ', $filter: RunsFilter' : ''}) { - runsOrError(limit: $limit${hasFilter ? ', filter: $filter' : ''}) { + query ListRuns($limit: Int, $cursor: String${hasFilter ? ', $filter: RunsFilter' : ''}) { + runsOrError(limit: $limit, cursor: $cursor${hasFilter ? ', filter: $filter' : ''}) { ... on Runs { results { runId @@ -45,7 +53,8 @@ function buildListRunsQuery(hasFilter: boolean) { export const listRunsTool: ToolConfig = { id: 'dagster_list_runs', name: 'Dagster List Runs', - description: 'List recent Dagster runs, optionally filtered by job name.', + description: + 'List Dagster runs with optional filters by job name, status, and creation-time range, plus cursor pagination.', version: '1.0.0', params: { @@ -74,6 +83,25 @@ export const listRunsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const filter: Record = {} if (params.jobName) filter.pipelineName = params.jobName @@ -99,9 +123,14 @@ export const listRunsTool: ToolConfig s.trim()) .filter(Boolean) } + if (params.createdAfter != null) filter.createdAfter = params.createdAfter + if (params.createdBefore != null) filter.createdBefore = params.createdBefore const hasFilter = Object.keys(filter).length > 0 - const variables: Record = { limit: params.limit || 20 } + const pageSize = params.limit || DEFAULT_LIST_RUNS_LIMIT + // Request one extra row so `hasMore` is exact even when the final page is exactly `pageSize` long. + const variables: Record = { limit: pageSize + 1 } + if (params.cursor) variables.cursor = params.cursor if (hasFilter) variables.filter = filter return { @@ -111,7 +140,7 @@ export const listRunsTool: ToolConfig { + transformResponse: async (response: Response, params?: DagsterListRunsParams) => { const data = await parseDagsterGraphqlResponse<{ runsOrError?: unknown }>(response) const result = data.data?.runsOrError as @@ -123,7 +152,11 @@ export const listRunsTool: ToolConfig ({ + const pageSize = params?.limit || DEFAULT_LIST_RUNS_LIMIT + const hasMore = result.results.length > pageSize + const pageRows = hasMore ? result.results.slice(0, pageSize) : result.results + + const runs = pageRows.map((r: DagsterListRunsGraphqlRow) => ({ runId: r.runId, jobName: r.jobName ?? null, status: r.status, @@ -134,7 +167,11 @@ export const listRunsTool: ToolConfig 0 ? runs[runs.length - 1].runId : null, + hasMore, + }, } }, @@ -151,5 +188,14 @@ export const listRunsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const hasStatus = Boolean(params.scheduleStatus) const variables: Record = { diff --git a/apps/sim/tools/dagster/list_sensors.ts b/apps/sim/tools/dagster/list_sensors.ts index 34372a6bdad..e5f0df34bcc 100644 --- a/apps/sim/tools/dagster/list_sensors.ts +++ b/apps/sim/tools/dagster/list_sensors.ts @@ -1,5 +1,10 @@ import type { DagsterListSensorsParams, DagsterListSensorsResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface DagsterSensorGraphql { @@ -81,13 +86,9 @@ export const listSensorsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const hasStatus = Boolean(params.sensorStatus) const variables: Record = { @@ -136,7 +137,7 @@ export const listSensorsTool: ToolConfig +} + +function buildMaterializeMutation(hasTags: boolean) { + const varDefs = [ + '$repositoryLocationName: String!', + '$repositoryName: String!', + '$jobName: String!', + '$assetSelection: [AssetKeyInput!]', + ] + if (hasTags) varDefs.push('$tags: [ExecutionTag!]') + + const execParams = [ + `selector: { + repositoryLocationName: $repositoryLocationName + repositoryName: $repositoryName + jobName: $jobName + assetSelection: $assetSelection + }`, + ] + if (hasTags) execParams.push('executionMetadata: { tags: $tags }') + + return ` + mutation MaterializeAssets(${varDefs.join(', ')}) { + launchRun( + executionParams: { + ${execParams.join('\n ')} + } + ) { + type: __typename + ... on LaunchRunSuccess { + run { + runId + } + } + ... on RunConfigValidationInvalid { + errors { + message + } + } + ... on PipelineNotFoundError { + message + } + ... on InvalidSubsetError { + message + } + ... on UnauthorizedError { + message + } + ... on ConflictingExecutionParamsError { + message + } + ... on PresetNotFoundError { + message + } + ... on RunConflict { + message + } + ... on PythonError { + message + } + } + } + ` +} + +export const materializeAssetsTool: ToolConfig< + DagsterMaterializeAssetsParams, + DagsterMaterializeAssetsResponse +> = { + id: 'dagster_materialize_assets', + name: 'Dagster Materialize Assets', + description: 'Materialize selected assets by launching their asset job with an asset selection.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + repositoryLocationName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Repository location (code location) name', + }, + repositoryName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Repository name within the code location', + }, + jobName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Asset job that contains the assets, e.g. "__ASSET_JOB" or a named asset job', + }, + assetSelection: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma- or newline-separated asset keys to materialize, each slash-delimited (e.g. "raw/events, summary")', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tags as a JSON array of {key, value} objects (optional)', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => { + const assetSelection = parseAssetSelection(params.assetSelection) + if (assetSelection.length === 0) { + throw new Error('assetSelection must contain at least one asset key') + } + + const variables: Record = { + repositoryLocationName: params.repositoryLocationName, + repositoryName: params.repositoryName, + jobName: params.jobName, + assetSelection, + } + + let hasTags = false + if (params.tags) { + try { + variables.tags = JSON.parse(params.tags) + hasTags = true + } catch { + throw new Error('Invalid JSON in tags') + } + } + + return { query: buildMaterializeMutation(hasTags), variables } + }, + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ launchRun?: unknown }>(response) + + const result = data.data?.launchRun as MaterializeAssetsResult | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (result.type === 'LaunchRunSuccess' && result.run) { + return { + success: true, + output: { runId: result.run.runId }, + } + } + + if (result.type === 'RunConfigValidationInvalid' && result.errors?.length) { + throw new Error( + `RunConfigValidationInvalid: ${result.errors.map((e) => e.message).join('; ')}` + ) + } + + throw new Error( + `${result.type}: ${dagsterUnionErrorMessage(result, 'Materialize assets failed')}` + ) + }, + + outputs: { + runId: { + type: 'string', + description: 'The globally unique ID of the launched materialization run', + }, + }, +} diff --git a/apps/sim/tools/dagster/reexecute_run.ts b/apps/sim/tools/dagster/reexecute_run.ts index f3ce6d3a366..5815cb5776c 100644 --- a/apps/sim/tools/dagster/reexecute_run.ts +++ b/apps/sim/tools/dagster/reexecute_run.ts @@ -1,5 +1,10 @@ import type { DagsterReexecuteRunParams, DagsterReexecuteRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface ReexecuteRunResult { @@ -106,13 +111,9 @@ export const reexecuteRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: REEXECUTE_RUN_MUTATION, variables: { diff --git a/apps/sim/tools/dagster/report_asset_materialization.ts b/apps/sim/tools/dagster/report_asset_materialization.ts new file mode 100644 index 00000000000..3362fa27376 --- /dev/null +++ b/apps/sim/tools/dagster/report_asset_materialization.ts @@ -0,0 +1,140 @@ +import type { + DagsterReportAssetMaterializationParams, + DagsterReportAssetMaterializationResponse, +} from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +interface ReportAssetEventResult { + type: string + assetKey?: { path: string[] } + message?: string +} + +const REPORT_ASSET_EVENT_MUTATION = ` + mutation ReportRunlessAssetEvents($eventParams: ReportRunlessAssetEventsParams!) { + reportRunlessAssetEvents(eventParams: $eventParams) { + type: __typename + ... on ReportRunlessAssetEventsSuccess { + assetKey { + path + } + } + ... on UnauthorizedError { + message + } + ... on PythonError { + message + } + } + } +` + +export const reportAssetMaterializationTool: ToolConfig< + DagsterReportAssetMaterializationParams, + DagsterReportAssetMaterializationResponse +> = { + id: 'dagster_report_asset_materialization', + name: 'Dagster Report Asset Materialization', + description: 'Report an external (runless) materialization or observation for an asset.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + assetKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Slash-delimited asset key to report against, e.g. "my_asset" or "raw/events"', + }, + eventType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Event type to report: ASSET_MATERIALIZATION (default) or ASSET_OBSERVATION', + }, + partitionKeys: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated partition keys to report against (optional)', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Human-readable description for the reported event (optional)', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => { + const eventParams: Record = { + eventType: params.eventType || 'ASSET_MATERIALIZATION', + assetKey: { path: parseAssetKeyPath(params.assetKey) }, + } + if (params.partitionKeys) { + eventParams.partitionKeys = params.partitionKeys + .split(',') + .map((key) => key.trim()) + .filter(Boolean) + } + if (params.description) eventParams.description = params.description + + return { query: REPORT_ASSET_EVENT_MUTATION, variables: { eventParams } } + }, + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ reportRunlessAssetEvents?: unknown }>(response) + + const result = data.data?.reportRunlessAssetEvents as ReportAssetEventResult | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (result.type === 'ReportRunlessAssetEventsSuccess' && result.assetKey) { + return { + success: true, + output: { + success: true, + assetKey: result.assetKey.path.join('/'), + }, + } + } + + throw new Error( + `${result.type}: ${dagsterUnionErrorMessage(result, 'Report asset event failed')}` + ) + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the event was reported successfully', + }, + assetKey: { + type: 'string', + description: 'Slash-joined asset key the event was reported against', + }, + }, +} diff --git a/apps/sim/tools/dagster/start_schedule.ts b/apps/sim/tools/dagster/start_schedule.ts index 83ea7fdd1e3..168b94fdd0f 100644 --- a/apps/sim/tools/dagster/start_schedule.ts +++ b/apps/sim/tools/dagster/start_schedule.ts @@ -2,7 +2,12 @@ import type { DagsterScheduleMutationResponse, DagsterStartScheduleParams, } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface ScheduleMutationResult { @@ -84,13 +89,9 @@ export const startScheduleTool: ToolConfig< }, request: { - url: (params) => `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: START_SCHEDULE_MUTATION, variables: { diff --git a/apps/sim/tools/dagster/start_sensor.ts b/apps/sim/tools/dagster/start_sensor.ts index 66a07541dbd..c478515f933 100644 --- a/apps/sim/tools/dagster/start_sensor.ts +++ b/apps/sim/tools/dagster/start_sensor.ts @@ -1,5 +1,10 @@ import type { DagsterSensorMutationResponse, DagsterStartSensorParams } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface SensorMutationResult { @@ -79,13 +84,9 @@ export const startSensorTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: START_SENSOR_MUTATION, variables: { diff --git a/apps/sim/tools/dagster/stop_schedule.ts b/apps/sim/tools/dagster/stop_schedule.ts index d6e89848ef2..20fd2fa6534 100644 --- a/apps/sim/tools/dagster/stop_schedule.ts +++ b/apps/sim/tools/dagster/stop_schedule.ts @@ -2,7 +2,12 @@ import type { DagsterScheduleMutationResponse, DagsterStopScheduleParams, } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface ScheduleMutationResult { @@ -73,13 +78,9 @@ export const stopScheduleTool: ToolConfig< }, request: { - url: (params) => `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: STOP_SCHEDULE_MUTATION, variables: { id: params.instigationStateId }, diff --git a/apps/sim/tools/dagster/stop_sensor.ts b/apps/sim/tools/dagster/stop_sensor.ts index cf31c26ac1e..47afc075197 100644 --- a/apps/sim/tools/dagster/stop_sensor.ts +++ b/apps/sim/tools/dagster/stop_sensor.ts @@ -1,5 +1,10 @@ import type { DagsterSensorMutationResponse, DagsterStopSensorParams } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface StopSensorResult { @@ -63,13 +68,9 @@ export const stopSensorTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: STOP_SENSOR_MUTATION, variables: { id: params.instigationStateId }, diff --git a/apps/sim/tools/dagster/terminate_run.ts b/apps/sim/tools/dagster/terminate_run.ts index 30f5ccb0473..8d49375e993 100644 --- a/apps/sim/tools/dagster/terminate_run.ts +++ b/apps/sim/tools/dagster/terminate_run.ts @@ -1,5 +1,10 @@ import type { DagsterTerminateRunParams, DagsterTerminateRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' /** Fields returned from `terminateRun` for all union members. */ @@ -67,13 +72,9 @@ export const terminateRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: TERMINATE_RUN_MUTATION, variables: { runId: params.runId }, diff --git a/apps/sim/tools/dagster/types.ts b/apps/sim/tools/dagster/types.ts index 89fa8f6c2d5..1ed08af3186 100644 --- a/apps/sim/tools/dagster/types.ts +++ b/apps/sim/tools/dagster/types.ts @@ -28,8 +28,15 @@ export interface DagsterGetRunResponse extends ToolResponse { runId: string jobName: string | null status: string + mode: string | null startTime: number | null endTime: number | null + creationTime: number | null + updateTime: number | null + parentRunId: string | null + rootRunId: string | null + canTerminate: boolean + assetSelection: string[] | null runConfigYaml: string | null tags: Array<{ key: string; value: string }> | null } @@ -38,6 +45,9 @@ export interface DagsterGetRunResponse extends ToolResponse { export interface DagsterListRunsParams extends DagsterBaseParams { jobName?: string statuses?: string + createdAfter?: number + createdBefore?: number + cursor?: string limit?: number } @@ -51,6 +61,8 @@ export interface DagsterListRunsResponse extends ToolResponse { startTime: number | null endTime: number | null }> + cursor: string | null + hasMore: boolean } } @@ -189,6 +201,81 @@ export interface DagsterStopSensorParams extends DagsterBaseParams { instigationStateId: string } +export interface DagsterListAssetsParams extends DagsterBaseParams { + prefix?: string + cursor?: string + limit?: number +} + +export interface DagsterListAssetsResponse extends ToolResponse { + output: { + assets: Array<{ assetKey: string; path: string[] }> + cursor: string | null + hasMore: boolean + } +} + +export interface DagsterGetAssetParams extends DagsterBaseParams { + assetKey: string +} + +export interface DagsterGetAssetResponse extends ToolResponse { + output: { + assetKey: string + path: string[] + groupName: string | null + description: string | null + jobNames: string[] | null + computeKind: string | null + isPartitioned: boolean | null + latestMaterialization: { + runId: string + timestamp: string + partition: string | null + stepKey: string | null + } | null + } +} + +export interface DagsterMaterializeAssetsParams extends DagsterBaseParams { + repositoryLocationName: string + repositoryName: string + jobName: string + assetSelection: string + tags?: string +} + +export interface DagsterMaterializeAssetsResponse extends ToolResponse { + output: { + runId: string + } +} + +export interface DagsterReportAssetMaterializationParams extends DagsterBaseParams { + assetKey: string + eventType?: string + partitionKeys?: string + description?: string +} + +export interface DagsterReportAssetMaterializationResponse extends ToolResponse { + output: { + success: boolean + assetKey: string + } +} + +export interface DagsterWipeAssetParams extends DagsterBaseParams { + assetKey: string +} + +export interface DagsterWipeAssetResponse extends ToolResponse { + output: { + success: boolean + assetKey: string + } +} + export type DagsterResponse = | DagsterLaunchRunResponse | DagsterGetRunResponse @@ -202,3 +289,8 @@ export type DagsterResponse = | DagsterScheduleMutationResponse | DagsterListSensorsResponse | DagsterSensorMutationResponse + | DagsterListAssetsResponse + | DagsterGetAssetResponse + | DagsterMaterializeAssetsResponse + | DagsterReportAssetMaterializationResponse + | DagsterWipeAssetResponse diff --git a/apps/sim/tools/dagster/utils.ts b/apps/sim/tools/dagster/utils.ts index b41fc135339..13869ddde96 100644 --- a/apps/sim/tools/dagster/utils.ts +++ b/apps/sim/tools/dagster/utils.ts @@ -1,3 +1,44 @@ +/** + * Builds the GraphQL endpoint URL from a Dagster host, tolerating surrounding whitespace and a + * trailing slash (e.g. `https://myorg.dagster.cloud/prod` → `https://myorg.dagster.cloud/prod/graphql`). + */ +export function dagsterGraphqlUrl(host: string): string { + return `${host.trim().replace(/\/$/, '')}/graphql` +} + +/** + * Builds the request headers for a Dagster GraphQL call, attaching the Dagster+ API token when one + * is provided (omitted for OSS / self-hosted instances). + */ +export function dagsterRequestHeaders(params: { apiKey?: string }): Record { + const headers: Record = { 'Content-Type': 'application/json' } + if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey.trim() + return headers +} + +/** + * Splits a slash-delimited asset key string into a Dagster asset key path + * (e.g. `prefix/my_asset` → `['prefix', 'my_asset']`). + */ +export function parseAssetKeyPath(input: string): string[] { + return input + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) +} + +/** + * Parses a comma- or newline-separated list of slash-delimited asset keys into the + * `[AssetKeyInput!]` shape expected by Dagster (`{ path: string[] }[]`). + */ +export function parseAssetSelection(input: string): Array<{ path: string[] }> { + return input + .split(/[\n,]/) + .map((key) => key.trim()) + .filter(Boolean) + .map((key) => ({ path: parseAssetKeyPath(key) })) +} + /** * Parses a Dagster GraphQL JSON body and throws if the HTTP status is not OK or the payload * contains top-level GraphQL errors. diff --git a/apps/sim/tools/dagster/wipe_asset.ts b/apps/sim/tools/dagster/wipe_asset.ts new file mode 100644 index 00000000000..e35a5c25212 --- /dev/null +++ b/apps/sim/tools/dagster/wipe_asset.ts @@ -0,0 +1,115 @@ +import type { DagsterWipeAssetParams, DagsterWipeAssetResponse } from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +interface WipeAssetResult { + type: string + assetPartitionRanges?: Array<{ assetKey: { path: string[] } }> + message?: string +} + +const WIPE_ASSET_MUTATION = ` + mutation WipeAsset($assetPartitionRanges: [PartitionsByAssetSelector!]!) { + wipeAssets(assetPartitionRanges: $assetPartitionRanges) { + type: __typename + ... on AssetWipeSuccess { + assetPartitionRanges { + assetKey { + path + } + } + } + ... on AssetNotFoundError { + message + } + ... on UnauthorizedError { + message + } + ... on UnsupportedOperationError { + message + } + ... on PythonError { + message + } + } + } +` + +export const wipeAssetTool: ToolConfig = { + id: 'dagster_wipe_asset', + name: 'Dagster Wipe Asset', + description: + 'DESTRUCTIVE: permanently wipes ALL materialization history (every partition) for an asset. This cannot be undone.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + assetKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Slash-delimited asset key to wipe, e.g. "my_asset" or "raw/events"', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => ({ + query: WIPE_ASSET_MUTATION, + variables: { + assetPartitionRanges: [{ assetKey: { path: parseAssetKeyPath(params.assetKey) } }], + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ wipeAssets?: unknown }>(response) + + const result = data.data?.wipeAssets as WipeAssetResult | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (result.type === 'AssetWipeSuccess') { + const wipedKey = result.assetPartitionRanges?.[0]?.assetKey.path.join('/') ?? '' + return { + success: true, + output: { + success: true, + assetKey: wipedKey, + }, + } + } + + throw new Error(`${result.type}: ${dagsterUnionErrorMessage(result, 'Wipe asset failed')}`) + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the asset was wiped successfully', + }, + assetKey: { + type: 'string', + description: 'Slash-joined asset key that was wiped', + }, + }, +} diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3c83d634f01..0cabe09af66 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -5,7 +5,7 @@ import { randomFloat } from '@sim/utils/random' import { getBYOKKey } from '@/lib/api-key/byok' import { generateInternalToken } from '@/lib/auth/internal' import { isHosted } from '@/lib/core/config/feature-flags' -import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { DEFAULT_EXECUTION_TIMEOUT_MS, getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter' import { secureFetchWithPinnedIP, @@ -22,6 +22,7 @@ import { isUserFile } from '@/lib/core/utils/user-file' import { isSameOrigin } from '@/lib/core/utils/validation' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { parseMcpToolId } from '@/lib/mcp/utils' +import { hostedKeyMetrics } from '@/lib/monitoring/metrics' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-check' import { isCustomTool, isMcpTool } from '@/executor/constants' @@ -334,11 +335,11 @@ async function reacquireHostedKey( params: Record, executionContext: ExecutionContext | undefined, requestId: string -): Promise { - if (!tool.hosting) return false +): Promise { + if (!tool.hosting) return null const { envKeyPrefix, apiKeyParam, byokProviderId, rateLimit } = tool.hosting const { workspaceId } = resolveToolScope(params, executionContext) - if (!workspaceId) return false + if (!workspaceId) return null const provider = byokProviderId || tool.id const acquireResult = await getHostedKeyRateLimiter().acquireKey( @@ -353,14 +354,14 @@ async function reacquireHostedKey( logger.warn( `[${requestId}] Re-acquire of hosted key for ${tool.id} failed: ${acquireResult.error ?? 'unknown'}` ) - return false + return null } params[apiKeyParam] = acquireResult.key logger.info( `[${requestId}] Re-acquired hosted key for ${tool.id} (${acquireResult.envVarName}) after upstream throttling` ) - return true + return acquireResult.envVarName ?? 'unknown' } /** @@ -383,10 +384,27 @@ function isRateLimitError(error: unknown): boolean { return false } +/** + * Map a thrown tool error to a hosted-key failure reason for metrics. Mirrors + * `isRateLimitError`: some providers signal quota/rate-limit via 401/403 with a + * descriptive message, so those count as `rate_limited`, not `auth`. + */ +function classifyHostedKeyFailure(error: unknown): 'rate_limited' | 'auth' | 'other' { + const status = (error as { status?: number } | null)?.status + if (status === 429 || status === 503) return 'rate_limited' + if (status === 401 || status === 403) { + const message = ((error as { message?: string } | null)?.message ?? '').toLowerCase() + if (message.includes('quota') || message.includes('rate limit')) return 'rate_limited' + return 'auth' + } + return 'other' +} + /** Context for retry with rate limit tracking */ interface RetryContext { requestId: string toolId: string + provider: string envVarName: string executionContext?: ExecutionContext /** @@ -413,8 +431,14 @@ async function executeWithRetry( maxRetries = 3, baseDelayMs = 1000 ): Promise { - const { requestId, toolId, envVarName, executionContext, reacquireAfterRetriesExhausted } = - context + const { + requestId, + toolId, + provider, + envVarName, + executionContext, + reacquireAfterRetriesExhausted, + } = context let lastError: unknown for (let attempt = 0; attempt <= maxRetries; attempt++) { @@ -445,6 +469,7 @@ async function executeWithRetry( PlatformEvents.hostedKeyUserThrottled({ toolId, reason: 'upstream_retries_exhausted', + provider, userId: executionContext?.userId, workspaceId: executionContext?.workspaceId, workflowId: executionContext?.workflowId, @@ -619,7 +644,8 @@ async function applyHostedKeyCostToResult( tool: ToolConfig, params: Record, executionContext: ExecutionContext | undefined, - requestId: string + requestId: string, + envVarName: string | undefined ): Promise { await reportCustomDimensionUsage(tool, params, finalResult.output, executionContext, requestId) @@ -630,6 +656,12 @@ async function applyHostedKeyCostToResult( executionContext, requestId ) + + const provider = tool.hosting?.byokProviderId || tool.id + const key = envVarName ?? 'unknown' + hostedKeyMetrics.recordUsed({ provider, tool: tool.id, key }) + hostedKeyMetrics.recordCostCharged(hostedKeyCost, { provider, tool: tool.id }) + if (hostedKeyCost > 0) { finalResult.output = { ...finalResult.output, @@ -879,11 +911,17 @@ export async function executeTool( options: ExecuteToolOptions = {} ): Promise { const { skipPostProcess = false, executionContext, signal } = options + // Fall back to the workflow execution's abort signal so plan-based execution timeouts + // and cancellation propagate to tool fetches when the caller passes no explicit signal. + const effectiveSignal = signal ?? executionContext?.abortSignal // Capture start time for precise timing const startTime = new Date() const startTimeISO = startTime.toISOString() const requestId = generateRequestId() + // Hoisted so the outer catch can attribute a thrown failure to the chosen key. + let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined + try { let tool: ToolConfig | undefined @@ -972,7 +1010,7 @@ export async function executeTool( executionContext, requestId, startTimeISO, - signal + effectiveSignal ) } else { // For built-in tools, use the synchronous version @@ -1005,6 +1043,14 @@ export async function executeTool( requestId ) + if (hostedKeyInfo.isUsingHostedKey) { + hostedKeyForMetrics = { + provider: tool.hosting?.byokProviderId || tool.id, + tool: tool.id, + key: hostedKeyInfo.envVarName ?? 'unknown', + } + } + // If we have a credential parameter, fetch the access token if (contextParams.oauthCredential) { contextParams.credential = contextParams.oauthCredential @@ -1149,8 +1195,11 @@ export async function executeTool( tool, contextParams, executionContext, - requestId + requestId, + hostedKeyInfo.envVarName ) + } else if (hostedKeyForMetrics) { + hostedKeyMetrics.recordFailed({ ...hostedKeyForMetrics, reason: 'other' }) } const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) @@ -1169,23 +1218,30 @@ export async function executeTool( // Execute the tool request directly (internal routes use regular fetch, external use SSRF-protected fetch) // Wrap with retry logic for hosted keys to handle rate limiting due to higher usage const result = hostedKeyInfo.isUsingHostedKey - ? await executeWithRetry(() => executeToolRequest(toolId, tool, contextParams, signal), { - requestId, - toolId, - envVarName: hostedKeyInfo.envVarName!, - executionContext, - reacquireAfterRetriesExhausted: async () => { - const reacquired = await reacquireHostedKey( - tool, - contextParams, - executionContext, - requestId - ) - if (!reacquired) return null - return () => executeToolRequest(toolId, tool, contextParams) - }, - }) - : await executeToolRequest(toolId, tool, contextParams, signal) + ? await executeWithRetry( + () => executeToolRequest(toolId, tool, contextParams, effectiveSignal), + { + requestId, + toolId, + provider: tool.hosting?.byokProviderId || tool.id, + envVarName: hostedKeyInfo.envVarName!, + executionContext, + reacquireAfterRetriesExhausted: async () => { + const reacquiredEnvVar = await reacquireHostedKey( + tool, + contextParams, + executionContext, + requestId + ) + if (!reacquiredEnvVar) return null + // Re-point metric labels at the freshly acquired key. + hostedKeyInfo.envVarName = reacquiredEnvVar + if (hostedKeyForMetrics) hostedKeyForMetrics.key = reacquiredEnvVar + return () => executeToolRequest(toolId, tool, contextParams, effectiveSignal) + }, + } + ) + : await executeToolRequest(toolId, tool, contextParams, effectiveSignal) // Apply post-processing if available and not skipped let finalResult = result @@ -1214,8 +1270,11 @@ export async function executeTool( tool, contextParams, executionContext, - requestId + requestId, + hostedKeyInfo.envVarName ) + } else if (hostedKeyForMetrics) { + hostedKeyMetrics.recordFailed({ ...hostedKeyForMetrics, reason: 'other' }) } const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) @@ -1235,6 +1294,13 @@ export async function executeTool( stack: error instanceof Error ? error.stack : undefined, }) + if (hostedKeyForMetrics) { + hostedKeyMetrics.recordFailed({ + ...hostedKeyForMetrics, + reason: classifyHostedKeyFailure(error), + }) + } + // Default error handling let errorMessage = 'Unknown error occurred' let errorDetails = {} @@ -1576,7 +1642,11 @@ async function executeToolRequest( try { if (isInternalRoute) { const controller = new AbortController() - const timeout = requestParams.timeout || DEFAULT_EXECUTION_TIMEOUT_MS + // With a caller/execution abort signal present, the plan-based timeout bounds the call and + // this only acts as a ceiling; without one, keep the tighter default as the hang safety net. + const timeout = + requestParams.timeout || + (signal ? getMaxExecutionTimeout() : DEFAULT_EXECUTION_TIMEOUT_MS) const timeoutId = setTimeout( () => controller.abort(`timeout:internal_tool_fetch:${timeout}ms`), timeout diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index ff6a5ce0b86..83ef9e9ebcd 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -343,6 +343,34 @@ import { clerkRevokeSessionTool, clerkUpdateUserTool, } from '@/tools/clerk' +import { + clickhouseCountRowsTool, + clickhouseCreateDatabaseTool, + clickhouseCreateTableTool, + clickhouseDeleteTool, + clickhouseDescribeTableTool, + clickhouseDropDatabaseTool, + clickhouseDropPartitionTool, + clickhouseDropTableTool, + clickhouseExecuteTool, + clickhouseInsertRowsTool, + clickhouseInsertTool, + clickhouseIntrospectTool, + clickhouseKillQueryTool, + clickhouseListClustersTool, + clickhouseListDatabasesTool, + clickhouseListMutationsTool, + clickhouseListPartitionsTool, + clickhouseListRunningQueriesTool, + clickhouseListTablesTool, + clickhouseOptimizeTableTool, + clickhouseQueryTool, + clickhouseRenameTableTool, + clickhouseShowCreateTableTool, + clickhouseTableStatsTool, + clickhouseTruncateTableTool, + clickhouseUpdateTool, +} from '@/tools/clickhouse' import { cloudflareCreateDnsRecordTool, cloudflareCreateZoneTool, @@ -460,19 +488,24 @@ import { } from '@/tools/cursor' import { dagsterDeleteRunTool, + dagsterGetAssetTool, dagsterGetRunLogsTool, dagsterGetRunTool, dagsterLaunchRunTool, + dagsterListAssetsTool, dagsterListJobsTool, dagsterListRunsTool, dagsterListSchedulesTool, dagsterListSensorsTool, + dagsterMaterializeAssetsTool, dagsterReexecuteRunTool, + dagsterReportAssetMaterializationTool, dagsterStartScheduleTool, dagsterStartSensorTool, dagsterStopScheduleTool, dagsterStopSensorTool, dagsterTerminateRunTool, + dagsterWipeAssetTool, } from '@/tools/dagster' import { databricksCancelRunTool, @@ -2974,7 +3007,14 @@ import { } from '@/tools/telegram' import { textractParserTool, textractParserV2Tool } from '@/tools/textract' import { thinkingTool } from '@/tools/thinking' -import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird' +import { + tinybirdAppendDatasourceTool, + tinybirdDeleteDatasourceRowsTool, + tinybirdEventsTool, + tinybirdQueryPipeTool, + tinybirdQueryTool, + tinybirdTruncateDatasourceTool, +} from '@/tools/tinybird' import { trelloAddCommentTool, trelloCreateCardTool, @@ -4124,19 +4164,24 @@ export const tools: Record = { devin_archive_session: devinArchiveSessionTool, devin_terminate_session: devinTerminateSessionTool, dagster_delete_run: dagsterDeleteRunTool, + dagster_get_asset: dagsterGetAssetTool, dagster_get_run: dagsterGetRunTool, dagster_get_run_logs: dagsterGetRunLogsTool, dagster_launch_run: dagsterLaunchRunTool, + dagster_list_assets: dagsterListAssetsTool, dagster_list_jobs: dagsterListJobsTool, dagster_list_runs: dagsterListRunsTool, dagster_list_schedules: dagsterListSchedulesTool, dagster_list_sensors: dagsterListSensorsTool, + dagster_materialize_assets: dagsterMaterializeAssetsTool, dagster_reexecute_run: dagsterReexecuteRunTool, + dagster_report_asset_materialization: dagsterReportAssetMaterializationTool, dagster_start_schedule: dagsterStartScheduleTool, dagster_start_sensor: dagsterStartSensorTool, dagster_stop_schedule: dagsterStopScheduleTool, dagster_stop_sensor: dagsterStopSensorTool, dagster_terminate_run: dagsterTerminateRunTool, + dagster_wipe_asset: dagsterWipeAssetTool, databricks_cancel_run: databricksCancelRunTool, databricks_execute_sql: databricksExecuteSqlTool, databricks_get_run: databricksGetRunTool, @@ -5106,6 +5151,10 @@ export const tools: Record = { thinking_tool: thinkingTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool, + tinybird_query_pipe: tinybirdQueryPipeTool, + tinybird_append_datasource: tinybirdAppendDatasourceTool, + tinybird_truncate_datasource: tinybirdTruncateDatasourceTool, + tinybird_delete_datasource_rows: tinybirdDeleteDatasourceRowsTool, stagehand_extract: stagehandExtractTool, stagehand_agent: stagehandAgentTool, mem0_add_memories: mem0AddMemoriesTool, @@ -5205,6 +5254,32 @@ export const tools: Record = { telegram_send_video: telegramSendVideoTool, telegram_send_document: telegramSendDocumentTool, clay_populate: clayPopulateTool, + clickhouse_query: clickhouseQueryTool, + clickhouse_insert: clickhouseInsertTool, + clickhouse_insert_rows: clickhouseInsertRowsTool, + clickhouse_update: clickhouseUpdateTool, + clickhouse_delete: clickhouseDeleteTool, + clickhouse_execute: clickhouseExecuteTool, + clickhouse_introspect: clickhouseIntrospectTool, + clickhouse_list_databases: clickhouseListDatabasesTool, + clickhouse_list_tables: clickhouseListTablesTool, + clickhouse_describe_table: clickhouseDescribeTableTool, + clickhouse_show_create_table: clickhouseShowCreateTableTool, + clickhouse_count_rows: clickhouseCountRowsTool, + clickhouse_list_partitions: clickhouseListPartitionsTool, + clickhouse_list_mutations: clickhouseListMutationsTool, + clickhouse_list_running_queries: clickhouseListRunningQueriesTool, + clickhouse_table_stats: clickhouseTableStatsTool, + clickhouse_list_clusters: clickhouseListClustersTool, + clickhouse_create_database: clickhouseCreateDatabaseTool, + clickhouse_drop_database: clickhouseDropDatabaseTool, + clickhouse_create_table: clickhouseCreateTableTool, + clickhouse_drop_table: clickhouseDropTableTool, + clickhouse_truncate_table: clickhouseTruncateTableTool, + clickhouse_rename_table: clickhouseRenameTableTool, + clickhouse_optimize_table: clickhouseOptimizeTableTool, + clickhouse_drop_partition: clickhouseDropPartitionTool, + clickhouse_kill_query: clickhouseKillQueryTool, clerk_list_users: clerkListUsersTool, clerk_get_user: clerkGetUserTool, clerk_create_user: clerkCreateUserTool, diff --git a/apps/sim/tools/tinybird/append_datasource.ts b/apps/sim/tools/tinybird/append_datasource.ts new file mode 100644 index 00000000000..3e4e11a40c1 --- /dev/null +++ b/apps/sim/tools/tinybird/append_datasource.ts @@ -0,0 +1,143 @@ +import { createLogger } from '@sim/logger' +import type { + TinybirdAppendDatasourceParams, + TinybirdAppendDatasourceResponse, +} from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-append-datasource') + +/** + * Tinybird Append Data Source Tool + * + * Appends data to an existing Data Source from a remote file URL using the + * Data Sources API (`mode=append`). This is asynchronous and returns an import + * job that can be polled for completion. + */ +export const appendDatasourceTool: ToolConfig< + TinybirdAppendDatasourceParams, + TinybirdAppendDatasourceResponse +> = { + id: 'tinybird_append_datasource', + name: 'Tinybird Append Data Source', + description: + 'Append data to a Tinybird Data Source from a remote file URL (CSV, NDJSON, Parquet).', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + datasource: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the existing Data Source to append to. Example: "events_raw"', + }, + url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Publicly accessible URL of the file to append. Example: "https://example.com/data.csv"', + }, + format: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Format of the source file: "csv" (default), "ndjson", or "parquet"', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with DATASOURCES:CREATE scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + return `${baseUrl}/v0/datasources` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${params.token.trim()}`, + }), + body: (params) => { + const searchParams = new URLSearchParams() + searchParams.set('mode', 'append') + searchParams.set('name', params.datasource.trim()) + searchParams.set('url', params.url.trim()) + if (params.format) { + searchParams.set('format', params.format) + } + return searchParams.toString() + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('Started Tinybird append-from-URL import', { + importId: data.import_id ?? data.job?.import_id, + status: data.status ?? data.job?.status, + }) + + return { + success: true, + output: { + id: data.id ?? null, + import_id: data.import_id ?? data.job?.import_id ?? null, + job_id: data.job_id ?? data.job?.job_id ?? data.job?.id ?? null, + job_url: data.job_url ?? data.job?.job_url ?? null, + status: data.status ?? data.job?.status ?? null, + job: data.job ?? null, + datasource: data.datasource ?? data.job?.datasource ?? null, + }, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Identifier of the append operation', + optional: true, + }, + import_id: { + type: 'string', + description: 'Import identifier for the append job', + optional: true, + }, + job_id: { + type: 'string', + description: 'Job identifier used to poll import status', + optional: true, + }, + job_url: { + type: 'string', + description: 'URL to query the import job status', + optional: true, + }, + status: { + type: 'string', + description: 'Initial job status (e.g., "waiting")', + optional: true, + }, + job: { + type: 'json', + description: 'Full import job details (kind, id, status, created_at, datasource, ...)', + optional: true, + }, + datasource: { + type: 'json', + description: 'Target Data Source metadata (id, name, ...)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tinybird/delete_datasource_rows.ts b/apps/sim/tools/tinybird/delete_datasource_rows.ts new file mode 100644 index 00000000000..29243cdbc5d --- /dev/null +++ b/apps/sim/tools/tinybird/delete_datasource_rows.ts @@ -0,0 +1,136 @@ +import { createLogger } from '@sim/logger' +import type { + TinybirdDeleteDatasourceRowsParams, + TinybirdDeleteDatasourceRowsResponse, +} from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-delete-datasource-rows') + +/** + * Tinybird Delete Data Source Rows Tool + * + * Deletes rows from a Data Source that match a SQL condition. This is asynchronous + * and returns a delete job that can be polled for completion. Set `dry_run` to test + * the condition without deleting any data. + */ +export const deleteDatasourceRowsTool: ToolConfig< + TinybirdDeleteDatasourceRowsParams, + TinybirdDeleteDatasourceRowsResponse +> = { + id: 'tinybird_delete_datasource_rows', + name: 'Tinybird Delete Data Source Rows', + description: 'Delete rows from a Tinybird Data Source matching a SQL condition.', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + datasource: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the Data Source to delete rows from. Example: "events_raw"', + }, + delete_condition: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'SQL WHERE-clause condition selecting the rows to delete. Example: "country = \'ES\'" or "event_date < \'2024-01-01\'"', + }, + dry_run: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: + 'When true, returns how many rows would be deleted without deleting them. Defaults to false.', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with DATASOURCES:CREATE scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + return `${baseUrl}/v0/datasources/${encodeURIComponent(params.datasource.trim())}/delete` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${params.token.trim()}`, + }), + body: (params) => { + const searchParams = new URLSearchParams() + searchParams.set('delete_condition', params.delete_condition.trim()) + if (params.dry_run) { + searchParams.set('dry_run', 'true') + } + return searchParams.toString() + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('Started Tinybird delete-by-condition job', { + deleteId: data.delete_id, + status: data.status ?? data.job?.status, + }) + + return { + success: true, + output: { + id: data.id ?? null, + job_id: data.job_id ?? data.job?.job_id ?? data.job?.id ?? null, + delete_id: data.delete_id ?? null, + job_url: data.job_url ?? data.job?.job_url ?? null, + status: data.status ?? data.job?.status ?? null, + job: data.job ?? null, + }, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Identifier of the delete operation', + optional: true, + }, + job_id: { + type: 'string', + description: 'Job identifier used to poll delete status', + optional: true, + }, + delete_id: { + type: 'string', + description: 'Deletion identifier', + optional: true, + }, + job_url: { + type: 'string', + description: 'URL to query the delete job status', + optional: true, + }, + status: { + type: 'string', + description: 'Current job status (e.g., "waiting", "done")', + optional: true, + }, + job: { + type: 'json', + description: + 'Full delete job details (kind, id, status, delete_condition, rows_affected, ...)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tinybird/events.ts b/apps/sim/tools/tinybird/events.ts index ec18c99649d..0a85dab88f8 100644 --- a/apps/sim/tools/tinybird/events.ts +++ b/apps/sim/tools/tinybird/events.ts @@ -64,9 +64,14 @@ export const eventsTool: ToolConfig { - const baseUrl = params.base_url.endsWith('/') ? params.base_url.slice(0, -1) : params.base_url + const baseUrl = params.base_url.trim().replace(/\/+$/, '') const url = new URL(`${baseUrl}/v0/events`) - url.searchParams.set('name', params.datasource) + url.searchParams.set('name', params.datasource.trim()) + // Tinybird selects JSON parsing via the `format=json` query parameter, not the + // Content-Type header. Default (omitted) is NDJSON. + if (params.format === 'json') { + url.searchParams.set('format', 'json') + } if (params.wait) { url.searchParams.set('wait', 'true') } @@ -75,7 +80,7 @@ export const eventsTool: ToolConfig { const headers: Record = { - Authorization: `Bearer ${params.token}`, + Authorization: `Bearer ${params.token.trim()}`, } if (params.compression === 'gzip') { diff --git a/apps/sim/tools/tinybird/index.ts b/apps/sim/tools/tinybird/index.ts index 5eb7e6af0b4..17ca52f6b94 100644 --- a/apps/sim/tools/tinybird/index.ts +++ b/apps/sim/tools/tinybird/index.ts @@ -1,5 +1,13 @@ +import { appendDatasourceTool } from '@/tools/tinybird/append_datasource' +import { deleteDatasourceRowsTool } from '@/tools/tinybird/delete_datasource_rows' import { eventsTool } from '@/tools/tinybird/events' import { queryTool } from '@/tools/tinybird/query' +import { queryPipeTool } from '@/tools/tinybird/query_pipe' +import { truncateDatasourceTool } from '@/tools/tinybird/truncate_datasource' export const tinybirdEventsTool = eventsTool export const tinybirdQueryTool = queryTool +export const tinybirdQueryPipeTool = queryPipeTool +export const tinybirdAppendDatasourceTool = appendDatasourceTool +export const tinybirdTruncateDatasourceTool = truncateDatasourceTool +export const tinybirdDeleteDatasourceRowsTool = deleteDatasourceRowsTool diff --git a/apps/sim/tools/tinybird/query.ts b/apps/sim/tools/tinybird/query.ts index 5a64a36fc63..526ca5c1970 100644 --- a/apps/sim/tools/tinybird/query.ts +++ b/apps/sim/tools/tinybird/query.ts @@ -52,19 +52,19 @@ export const queryTool: ToolConfig = request: { url: (params) => { - const baseUrl = params.base_url.endsWith('/') ? params.base_url.slice(0, -1) : params.base_url + const baseUrl = params.base_url.trim().replace(/\/+$/, '') return `${baseUrl}/v0/sql` }, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Bearer ${params.token}`, + Authorization: `Bearer ${params.token.trim()}`, }), body: (params) => { const searchParams = new URLSearchParams() searchParams.set('q', params.query) if (params.pipeline) { - searchParams.set('pipeline', params.pipeline) + searchParams.set('pipeline', params.pipeline.trim()) } return searchParams.toString() }, @@ -88,8 +88,10 @@ export const queryTool: ToolConfig = return { success: true, output: { - data: data.data || [], - rows: data.rows || 0, + data: data.data ?? [], + meta: data.meta ?? undefined, + rows: data.rows ?? 0, + rows_before_limit_at_least: data.rows_before_limit_at_least ?? undefined, statistics: data.statistics ? { elapsed: data.statistics.elapsed, @@ -126,10 +128,28 @@ export const queryTool: ToolConfig = description: 'Query result data. For FORMAT JSON: array of objects. For other formats (CSV, TSV, etc.): raw text string.', }, + meta: { + type: 'array', + description: 'Column metadata for the result set (only available with FORMAT JSON)', + optional: true, + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Column data type' }, + }, + }, + }, rows: { type: 'number', description: 'Number of rows returned (only available with FORMAT JSON)', }, + rows_before_limit_at_least: { + type: 'number', + description: + 'Minimum number of rows there would be without a LIMIT clause (only available with FORMAT JSON)', + optional: true, + }, statistics: { type: 'json', description: diff --git a/apps/sim/tools/tinybird/query_pipe.ts b/apps/sim/tools/tinybird/query_pipe.ts new file mode 100644 index 00000000000..0ba60ad302c --- /dev/null +++ b/apps/sim/tools/tinybird/query_pipe.ts @@ -0,0 +1,174 @@ +import { createLogger } from '@sim/logger' +import type { TinybirdQueryPipeParams, TinybirdQueryPipeResponse } from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-query-pipe') + +/** + * Parses the dynamic-parameters input, which may arrive as a JSON object or a + * JSON string from a code/json subBlock. An omitted or empty value means "no + * parameters"; a non-empty value that is not a valid JSON object throws, so a + * mistyped input fails loudly instead of silently dropping the filters. + */ +function parsePipeParameters( + input: TinybirdQueryPipeParams['parameters'] +): Record { + if (input === undefined || input === null) return {} + if (typeof input === 'object') return input as Record + + const trimmed = input.trim() + if (!trimmed) return {} + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + throw new Error( + 'Invalid Pipe parameters: expected a JSON object of key/value pairs (e.g. {"limit": 10})' + ) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Invalid Pipe parameters: expected a JSON object, not a primitive or array') + } + return parsed as Record +} + +/** + * Tinybird Query Pipe Tool + * + * Calls a published Tinybird Pipe API Endpoint by name using the `.json` format, + * which is an alias for `SELECT * FROM {pipe}`. Templated Pipe parameters are passed + * as query-string arguments, and an optional `q` lets you run SQL on top of the result + * (using `_` to reference the Pipe). + */ +export const queryPipeTool: ToolConfig = { + id: 'tinybird_query_pipe', + name: 'Tinybird Query Pipe', + description: + 'Call a published Tinybird Pipe API Endpoint by name, passing dynamic parameters and receiving structured JSON results.', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + pipe: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the published Pipe API Endpoint to call. Example: "top_pages"', + }, + parameters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Dynamic Pipe parameters as a JSON object, sent as query-string arguments. Example: {"start_date": "2024-01-01", "limit": 10}', + }, + q: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Optional SQL to run on top of the Pipe result. Use "_" to reference the Pipe. Example: "SELECT count() FROM _"', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with PIPE:READ scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + const url = new URL(`${baseUrl}/v0/pipes/${encodeURIComponent(params.pipe.trim())}.json`) + if (params.q) { + url.searchParams.set('q', params.q) + } + const dynamic = parsePipeParameters(params.parameters) + for (const [key, value] of Object.entries(dynamic)) { + // Don't let a dynamic parameter clobber the reserved `q` set above + if (key === 'q') continue + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)) + } + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.token.trim()}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('Successfully called Tinybird Pipe endpoint', { + rows: data.rows, + elapsed: data.statistics?.elapsed, + }) + + return { + success: true, + output: { + data: data.data ?? [], + meta: data.meta ?? undefined, + rows: data.rows ?? undefined, + rows_before_limit_at_least: data.rows_before_limit_at_least ?? undefined, + statistics: data.statistics + ? { + elapsed: data.statistics.elapsed, + rows_read: data.statistics.rows_read, + bytes_read: data.statistics.bytes_read, + } + : undefined, + }, + } + }, + + outputs: { + data: { + type: 'json', + description: 'Pipe result data as an array of row objects', + }, + meta: { + type: 'array', + description: 'Column metadata for the result set', + optional: true, + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Column data type' }, + }, + }, + }, + rows: { + type: 'number', + description: 'Number of rows returned', + optional: true, + }, + rows_before_limit_at_least: { + type: 'number', + description: 'Minimum number of rows there would be without a LIMIT clause', + optional: true, + }, + statistics: { + type: 'json', + description: 'Query execution statistics - elapsed time, rows read, bytes read', + optional: true, + properties: { + elapsed: { type: 'number', description: 'Query execution time in seconds' }, + rows_read: { type: 'number', description: 'Number of rows processed' }, + bytes_read: { type: 'number', description: 'Number of bytes processed' }, + }, + }, + }, +} diff --git a/apps/sim/tools/tinybird/truncate_datasource.ts b/apps/sim/tools/tinybird/truncate_datasource.ts new file mode 100644 index 00000000000..c630c3c696a --- /dev/null +++ b/apps/sim/tools/tinybird/truncate_datasource.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import type { + TinybirdTruncateDatasourceParams, + TinybirdTruncateDatasourceResponse, +} from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-truncate-datasource') + +/** + * Tinybird Truncate Data Source Tool + * + * Deletes all rows from a Data Source. Dependent Materialized Views are not + * truncated in cascade. The endpoint returns a minimal (often empty) body on success. + */ +export const truncateDatasourceTool: ToolConfig< + TinybirdTruncateDatasourceParams, + TinybirdTruncateDatasourceResponse +> = { + id: 'tinybird_truncate_datasource', + name: 'Tinybird Truncate Data Source', + description: 'Delete all rows from a Tinybird Data Source.', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + datasource: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the Data Source to truncate. Example: "events_raw"', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with DATASOURCES:CREATE scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + return `${baseUrl}/v0/datasources/${encodeURIComponent(params.datasource.trim())}/truncate` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.token.trim()}`, + }), + }, + + transformResponse: async (response: Response) => { + const text = await response.text() + let result: Record | null = null + if (text) { + try { + result = JSON.parse(text) + } catch { + result = null + } + } + + logger.info('Successfully truncated Tinybird Data Source') + + return { + success: true, + output: { + truncated: true, + result, + }, + } + }, + + outputs: { + truncated: { + type: 'boolean', + description: 'Whether the Data Source was truncated successfully', + }, + result: { + type: 'json', + description: 'Raw response body from the truncate endpoint, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tinybird/types.ts b/apps/sim/tools/tinybird/types.ts index 0c1d613339e..07699f8ccfd 100644 --- a/apps/sim/tools/tinybird/types.ts +++ b/apps/sim/tools/tinybird/types.ts @@ -44,16 +44,119 @@ export interface TinybirdQueryParams extends TinybirdBaseParams { export interface TinybirdQueryResponse extends ToolResponse { output: { data: unknown[] | string + meta?: Array<{ name: string; type: string }> rows?: number - statistics?: { - elapsed: number - rows_read: number - bytes_read: number - } + rows_before_limit_at_least?: number + statistics?: TinybirdQueryStatistics + } +} + +/** + * Query execution statistics returned by the Query API and Pipe endpoints + */ +interface TinybirdQueryStatistics { + elapsed: number + rows_read: number + bytes_read: number +} + +/** + * Parameters for calling a published Pipe API Endpoint by name + */ +export interface TinybirdQueryPipeParams extends TinybirdBaseParams { + base_url: string + pipe: string + parameters?: Record | string + q?: string +} + +/** + * Response from calling a published Pipe API Endpoint (`.json` format) + */ +export interface TinybirdQueryPipeResponse extends ToolResponse { + output: { + data: unknown[] + meta?: Array<{ name: string; type: string }> + rows?: number + rows_before_limit_at_least?: number + statistics?: TinybirdQueryStatistics + } +} + +/** + * Parameters for appending data to a Data Source from a URL + */ +export interface TinybirdAppendDatasourceParams extends TinybirdBaseParams { + base_url: string + datasource: string + url: string + format?: 'csv' | 'ndjson' | 'parquet' +} + +/** + * Response from an append-from-URL import job + */ +export interface TinybirdAppendDatasourceResponse extends ToolResponse { + output: { + id: string | null + import_id: string | null + job_id: string | null + job_url: string | null + status: string | null + job: Record | null + datasource: Record | null + } +} + +/** + * Parameters for truncating (deleting all rows from) a Data Source + */ +export interface TinybirdTruncateDatasourceParams extends TinybirdBaseParams { + base_url: string + datasource: string +} + +/** + * Response from truncating a Data Source + */ +export interface TinybirdTruncateDatasourceResponse extends ToolResponse { + output: { + truncated: boolean + result: Record | null + } +} + +/** + * Parameters for deleting rows from a Data Source by condition + */ +export interface TinybirdDeleteDatasourceRowsParams extends TinybirdBaseParams { + base_url: string + datasource: string + delete_condition: string + dry_run?: boolean +} + +/** + * Response from a delete-by-condition job + */ +export interface TinybirdDeleteDatasourceRowsResponse extends ToolResponse { + output: { + id: string | null + job_id: string | null + delete_id: string | null + job_url: string | null + status: string | null + job: Record | null } } /** * Union type for all possible Tinybird responses */ -export type TinybirdResponse = TinybirdEventsResponse | TinybirdQueryResponse +export type TinybirdResponse = + | TinybirdEventsResponse + | TinybirdQueryResponse + | TinybirdQueryPipeResponse + | TinybirdAppendDatasourceResponse + | TinybirdTruncateDatasourceResponse + | TinybirdDeleteDatasourceRowsResponse diff --git a/apps/sim/triggers/table/poller.ts b/apps/sim/triggers/table/poller.ts index a611c297d79..6fe6ad17f81 100644 --- a/apps/sim/triggers/table/poller.ts +++ b/apps/sim/triggers/table/poller.ts @@ -146,10 +146,6 @@ export const tableNewRowTrigger: TriggerConfig = { type: 'json', description: 'Column names from the table schema', }, - rowNumber: { - type: 'number', - description: 'The position of the row in the table', - }, tableId: { type: 'string', description: 'The table ID', diff --git a/bun.lock b/bun.lock index 42b1506b659..2a293215c7e 100644 --- a/bun.lock +++ b/bun.lock @@ -124,6 +124,7 @@ "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", "@opentelemetry/exporter-trace-otlp-http": "^0.217.0", "@opentelemetry/resources": "^2.7.0", + "@opentelemetry/sdk-metrics": "^2.7.0", "@opentelemetry/sdk-node": "^0.217.0", "@opentelemetry/sdk-trace-base": "^2.7.0", "@opentelemetry/sdk-trace-node": "^2.7.0", @@ -168,6 +169,7 @@ "better-auth-harmony": "1.3.1", "binary-extensions": "3.1.0", "browser-image-compression": "^2.0.2", + "busboy": "1.6.0", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -184,6 +186,7 @@ "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", + "fractional-indexing": "3.2.0", "framer-motion": "^12.5.0", "free-email-domains": "1.2.25", "google-auth-library": "10.5.0", @@ -266,6 +269,7 @@ "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", "@trigger.dev/build": "4.4.3", + "@types/busboy": "1.5.4", "@types/fluent-ffmpeg": "2.1.28", "@types/html-to-text": "9.0.4", "@types/js-yaml": "4.0.9", @@ -1662,6 +1666,8 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], + "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/cookie": ["@types/cookie@0.4.1", "", {}, "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="], @@ -1984,6 +1990,8 @@ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], @@ -2492,6 +2500,8 @@ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + "fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "free-email-domains": ["free-email-domains@1.2.25", "", {}, "sha512-Uf2rJUjo/agIgQzt6od9XcHrR6rfIMD6TwsNVSJVJCHzjPWWsqjCb+EaQ2VVVY9M55+JB3V0k6ru5sHTGx/ZfA=="], @@ -3640,6 +3650,8 @@ "streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="], + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], @@ -4132,6 +4144,8 @@ "@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@types/busboy/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/cors/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/fluent-ffmpeg/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4592,6 +4606,8 @@ "@trigger.dev/core/socket.io-client/engine.io-client": ["engine.io-client@6.5.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ=="], + "@types/busboy/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/cors/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], diff --git a/packages/db/migrations/0224_table_import_columns.sql b/packages/db/migrations/0224_table_import_columns.sql new file mode 100644 index 00000000000..a0796056d83 --- /dev/null +++ b/packages/db/migrations/0224_table_import_columns.sql @@ -0,0 +1,92 @@ +ALTER TABLE "user_table_definitions" ADD COLUMN "import_status" text;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "import_id" text;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "import_error" text;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "import_rows_processed" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "import_started_at" timestamp;--> statement-breakpoint + +-- ============================================================ +-- Statement-level row-count maintenance for user_table_rows. +-- +-- Replaces the per-row BEFORE INSERT / AFTER DELETE triggers from migration 0158 +-- (whose increment function body was rewritten race-free in 0198, but which still +-- fired FOR EACH ROW). Per-row firing serialized a row-level lock on the single +-- user_table_definitions row once per inserted/deleted row -- the dominant cost and +-- contention point for bulk operations (e.g. a 1M-row import = 1M lock cycles). +-- +-- The statement-level versions use transition tables to bump row_count by the +-- per-table count of affected rows in ONE UPDATE per statement, preserving the +-- atomic cap check. Transition tables require AFTER triggers, so the insert trigger +-- moves BEFORE -> AFTER: rows are inserted, then the count is bumped with the cap +-- check; an over-cap batch RAISEs and rolls back the whole statement. +-- ============================================================ + +CREATE OR REPLACE FUNCTION increment_user_table_row_count_stmt() +RETURNS TRIGGER AS $$ +DECLARE + over_cap text; +BEGIN + -- Per-table counts within this statement; one capped UPDATE per affected table. + -- A table_id present in the inserted rows always exists (FK), so any table the + -- UPDATE did not touch was rejected by the `row_count + n <= max_rows` guard. + WITH counts AS ( + SELECT table_id, count(*)::int AS n + FROM new_rows + GROUP BY table_id + ), + updated AS ( + UPDATE user_table_definitions d + SET row_count = d.row_count + c.n, + updated_at = now() + FROM counts c + WHERE d.id = c.table_id + AND d.row_count + c.n <= d.max_rows + RETURNING d.id + ) + SELECT string_agg(c.table_id, ', ') + INTO over_cap + FROM counts c + WHERE c.table_id NOT IN (SELECT id FROM updated); + + IF over_cap IS NOT NULL THEN + RAISE EXCEPTION 'Maximum row limit reached for table(s) %', over_cap + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION decrement_user_table_row_count_stmt() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE user_table_definitions d + SET row_count = GREATEST(d.row_count - c.n, 0), + updated_at = now() + FROM ( + SELECT table_id, count(*)::int AS n + FROM old_rows + GROUP BY table_id + ) c + WHERE d.id = c.table_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +DROP TRIGGER IF EXISTS user_table_rows_insert_trigger ON user_table_rows;--> statement-breakpoint +DROP TRIGGER IF EXISTS user_table_rows_delete_trigger ON user_table_rows;--> statement-breakpoint + +CREATE TRIGGER user_table_rows_insert_stmt_trigger + AFTER INSERT ON user_table_rows + REFERENCING NEW TABLE AS new_rows + FOR EACH STATEMENT + EXECUTE FUNCTION increment_user_table_row_count_stmt(); +--> statement-breakpoint + +CREATE TRIGGER user_table_rows_delete_stmt_trigger + AFTER DELETE ON user_table_rows + REFERENCING OLD TABLE AS old_rows + FOR EACH STATEMENT + EXECUTE FUNCTION decrement_user_table_row_count_stmt(); diff --git a/packages/db/migrations/0225_greedy_ares.sql b/packages/db/migrations/0225_greedy_ares.sql new file mode 100644 index 00000000000..58e9eefe371 --- /dev/null +++ b/packages/db/migrations/0225_greedy_ares.sql @@ -0,0 +1 @@ +ALTER TABLE "copilot_chats" DROP COLUMN "messages"; \ No newline at end of file diff --git a/packages/db/migrations/0226_sparkling_sheva_callister.sql b/packages/db/migrations/0226_sparkling_sheva_callister.sql new file mode 100644 index 00000000000..fba426cbb7e --- /dev/null +++ b/packages/db/migrations/0226_sparkling_sheva_callister.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_table_rows" ADD COLUMN "order_key" text;--> statement-breakpoint +CREATE INDEX "user_table_rows_table_order_key_idx" ON "user_table_rows" USING btree ("table_id","order_key","id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0224_snapshot.json b/packages/db/migrations/meta/0224_snapshot.json new file mode 100644 index 00000000000..f400ae3d2f3 --- /dev/null +++ b/packages/db/migrations/meta/0224_snapshot.json @@ -0,0 +1,17223 @@ +{ + "id": "7d34002f-2e0d-4b4b-9d06-c06334853a07", + "prevId": "09c4bd24-1cac-447d-a9bf-355ebb058cea", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "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": 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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_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": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_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": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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": "'[]'" + }, + "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 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "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": { + "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": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_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": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {} + } + }, + "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.form": { + "name": "form", + "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": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_partial_idx": { + "name": "form_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"form\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_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()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_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": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "workspace_id": { + "name": "workspace_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_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_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.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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.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.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_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 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "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": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "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": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "import_status": { + "name": "import_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_error": { + "name": "import_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_rows_processed": { + "name": "import_rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "import_started_at": { + "name": "import_started_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_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_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "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_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": 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": {} + } + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "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_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": {} + } + }, + "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" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "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 + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_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": {} + } + }, + "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_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_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": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_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_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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": "'{}'" + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_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 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "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": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "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_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_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_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "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": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_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 + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "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.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_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.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.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_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/0225_snapshot.json b/packages/db/migrations/meta/0225_snapshot.json new file mode 100644 index 00000000000..ded3c7cd717 --- /dev/null +++ b/packages/db/migrations/meta/0225_snapshot.json @@ -0,0 +1,17216 @@ +{ + "id": "5aa005b7-64b6-4960-9fb0-c0e39d6c7a3f", + "prevId": "7d34002f-2e0d-4b4b-9d06-c06334853a07", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "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": 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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_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": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_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": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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": "'[]'" + }, + "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 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "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": { + "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": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_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": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {} + } + }, + "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.form": { + "name": "form", + "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": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_partial_idx": { + "name": "form_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"form\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_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()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_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": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "workspace_id": { + "name": "workspace_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_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_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.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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.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.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_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 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "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": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "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": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "import_status": { + "name": "import_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_error": { + "name": "import_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_rows_processed": { + "name": "import_rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "import_started_at": { + "name": "import_started_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_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_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "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_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": 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": {} + } + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "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_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": {} + } + }, + "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" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "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 + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_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": {} + } + }, + "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_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_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": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_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_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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": "'{}'" + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_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 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "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": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "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_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_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_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "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": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_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 + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "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.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_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.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.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_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/0226_snapshot.json b/packages/db/migrations/meta/0226_snapshot.json new file mode 100644 index 00000000000..6ac5f138534 --- /dev/null +++ b/packages/db/migrations/meta/0226_snapshot.json @@ -0,0 +1,17249 @@ +{ + "id": "22b8a971-eb8a-4fb5-9eaa-35b23799fcc8", + "prevId": "5aa005b7-64b6-4960-9fb0-c0e39d6c7a3f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "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": 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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_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": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_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": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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": "'[]'" + }, + "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 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "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": { + "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": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_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": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {} + } + }, + "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.form": { + "name": "form", + "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": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_partial_idx": { + "name": "form_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"form\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_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()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_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": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "workspace_id": { + "name": "workspace_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_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_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.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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.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.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_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 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "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": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "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": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "import_status": { + "name": "import_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_error": { + "name": "import_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_rows_processed": { + "name": "import_rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "import_started_at": { + "name": "import_started_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_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_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": 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": {} + } + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "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_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": {} + } + }, + "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" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "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 + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_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": {} + } + }, + "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_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_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": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_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_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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": "'{}'" + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_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 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "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": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "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_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_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_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "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": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_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 + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "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.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_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.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.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_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 f6867cb67aa..e8d9f5c4c7a 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1562,6 +1562,27 @@ "when": 1780420540516, "tag": "0223_lowly_shocker", "breakpoints": true + }, + { + "idx": 224, + "version": "7", + "when": 1780472914844, + "tag": "0224_table_import_columns", + "breakpoints": true + }, + { + "idx": 225, + "version": "7", + "when": 1780607184870, + "tag": "0225_greedy_ares", + "breakpoints": true + }, + { + "idx": 226, + "version": "7", + "when": 1780621979250, + "tag": "0226_sparkling_sheva_callister", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 00b84c30494..a65775122c0 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1944,7 +1944,6 @@ export const copilotChats = pgTable( workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), type: chatTypeEnum('type').notNull().default('copilot'), title: text('title'), - messages: jsonb('messages').notNull().default('[]'), model: text('model').notNull().default('claude-3-7-sonnet-latest'), conversationId: text('conversation_id'), previewYaml: text('preview_yaml'), @@ -3270,6 +3269,16 @@ export const userTableDefinitions = pgTable( maxRows: integer('max_rows').notNull().default(10000), rowCount: integer('row_count').notNull().default(0), archivedAt: timestamp('archived_at'), + /** + * Async-import state. NULL = a normal table (never imported in the background). + * `'importing'` hides rows until the load completes; `'ready'` reveals them; + * `'failed'` surfaces a partial import. See `apps/sim/lib/table/import-runner.ts`. + */ + importStatus: text('import_status'), + importId: text('import_id'), + importError: text('import_error'), + importRowsProcessed: integer('import_rows_processed').notNull().default(0), + importStartedAt: timestamp('import_started_at'), createdBy: text('created_by') .notNull() .references(() => user.id, { onDelete: 'cascade' }), @@ -3304,6 +3313,12 @@ export const userTableRows = pgTable( .references(() => workspace.id, { onDelete: 'cascade' }), data: jsonb('data').notNull(), position: integer('position').notNull().default(0), + /** + * Fractional order key (base-62 string). Authoritative row order when the + * `TABLES_FRACTIONAL_ORDERING` flag is on; nullable during the backfill + * window. Ordered with `id` as a deterministic tiebreaker. + */ + orderKey: text('order_key'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), @@ -3316,6 +3331,11 @@ export const userTableRows = pgTable( table.tableId ), tablePositionIdx: index('user_table_rows_table_position_idx').on(table.tableId, table.position), + tableOrderKeyIdx: index('user_table_rows_table_order_key_idx').on( + table.tableId, + table.orderKey, + table.id + ), }) ) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 83cf378eb3a..497a352bd4e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 762, - zodRoutes: 762, + totalRoutes: 791, + zodRoutes: 791, nonZodRoutes: 0, } as const