diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index 0fccb938b11..2958623e156 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -12,8 +12,8 @@ You are an expert at adding knowledge base connectors to Sim. A connector syncs When the user asks you to create a connector: 1. Use Context7 or WebFetch to read the service's API documentation 2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory and config -4. Register it in the connector registry +3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it +4. Register it in BOTH the server registry and the client-safe meta registry ## Hard Rule: No Guessed Response Or Document Schemas @@ -32,13 +32,19 @@ If the source schema is unknown, do one of these instead: ## Directory Structure +Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. + Create files in `apps/sim/connectors/{service}/`: ``` connectors/{service}/ -├── index.ts # Barrel export -└── {service}.ts # ConnectorConfig definition +├── index.ts # Barrel export (re-exports the runtime connector) +├── meta.ts # ConnectorMeta — client-safe declarative metadata +└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions ``` +- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. +- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). + ## Authentication Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): @@ -55,19 +61,17 @@ For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The ### API key mode For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. -## ConnectorConfig Structure +## Connector Structure (meta.ts + runtime) + +The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. -### OAuth connector example +### `meta.ts` — client-safe metadata ```typescript -import { createLogger } from '@sim/logger' import { {Service}Icon } from '@/components/icons' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') +import type { ConnectorMeta } from '@/connectors/types' -export const {service}Connector: ConnectorConfig = { +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -85,6 +89,26 @@ export const {service}Connector: ConnectorConfig = { // Supports 'short-input' and 'dropdown' types ], + // Optional: tag definitions are metadata too — declare them here + // tagDefinitions: [ ... ], +} +``` + +Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. + +### `{service}.ts` — runtime (OAuth example) + +```typescript +import { createLogger } from '@sim/logger' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' + +const logger = createLogger('{Service}Connector') + +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, + listDocuments: async (accessToken, sourceConfig, cursor) => { // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) // Or full documents with content (if list API returns content inline) @@ -111,8 +135,11 @@ Only map fields in `listDocuments`, `getDocument`, `validateConfig`, and `mapTag ### API key connector example +The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. + ```typescript -export const {service}Connector: ConnectorConfig = { +// meta.ts +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -126,6 +153,11 @@ export const {service}Connector: ConnectorConfig = { }, configFields: [ /* ... */ ], +} + +// {service}.ts +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, @@ -499,7 +531,9 @@ If the service already has an icon in `apps/sim/components/icons.tsx` (from a to ## Registering -Add one line to `apps/sim/connectors/registry.ts`: +Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. + +1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): ```typescript import { {service}Connector } from '@/connectors/{service}' @@ -510,6 +544,19 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { } ``` +2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): + +```typescript +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' + +export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { + // ... existing connector metas ... + {service}: {service}ConnectorMeta, +} +``` + +`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. + ## Reference Implementations - **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination @@ -520,7 +567,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Checklist -- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig +- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports +- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions - [ ] Created `connectors/{service}/index.ts` barrel export - [ ] **Auth configured correctly:** - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` @@ -542,4 +590,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) - [ ] All optional config fields validated in `validateConfig` - [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered in `connectors/registry.ts` +- [ ] Registered the full connector in `connectors/registry.server.ts` +- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.agents/skills/db-migrate/SKILL.md b/.agents/skills/db-migrate/SKILL.md new file mode 100644 index 00000000000..d00f375f9ab --- /dev/null +++ b/.agents/skills/db-migrate/SKILL.md @@ -0,0 +1,89 @@ +--- +name: db-migrate +description: Author or review a Drizzle DB migration for zero-downtime safety — expand/contract phasing, backward-compatibility with the deployed app version, and writing the `-- migration-safe` acknowledgment the check:migrations lint requires. Use when adding/editing files under `packages/db/migrations/` or changing `packages/db/schema.ts`. +--- + +# DB Migrate Skill + +You make schema changes that survive a deploy without downtime. The `check:migrations` lint (`scripts/check-migrations-safety.ts`) is the deterministic gate; you are the judgment that decides whether a flagged change is actually safe and writes the annotation that satisfies it. + +## The window (why this matters) + +A deploy runs the migration, then rolls out the new app image via blue/green. The two are **not atomic and cannot be** — during cutover the old task set keeps serving against the **already-migrated** schema. So: + +> Every migration must be backward-compatible with the app version that is *already deployed*. + +If a migration drops a column the old code still reads, renames one, or adds a `NOT NULL` the old inserts don't populate, the old code throws until traffic fully shifts — the downtime we're guarding against. You can't fix this by reordering the pipeline; the only fix is discipline. + +## Expand / contract + +Split every breaking change across **two deploys**: + +1. **Expand** (this PR): additive, backward-compatible schema + code that tolerates *both* the old and new shape. +2. **Contract** (a later PR, after expand is fully deployed): remove the old thing, now that nothing reads it. + +Never put expand and contract in the same PR. If this PR both removes the code that used a column *and* drops the column, the old code is still live during cutover — split it. + +### Per-operation playbook + +| You want to | Do (deploy 1 = expand) | Do (deploy 2 = contract) | +|---|---|---| +| Add a required column | `ADD COLUMN` nullable or `DEFAULT`; code writes it | backfill, then `SET NOT NULL` | +| Rename a column/table | add the new name; code dual-writes / reads new-then-old | drop the old name | +| Drop a column/table | stop all reads/writes in code; ship it | `DROP` (annotate) | +| Change a column type | add a new column of the new type; dual-write | backfill, swap reads, drop old | +| Add FK / CHECK | `ADD CONSTRAINT ... NOT VALID` | `VALIDATE CONSTRAINT` separately | +| Index an existing table | `COMMIT;` breakpoint → `SET lock_timeout = 0` → `CREATE INDEX CONCURRENTLY IF NOT EXISTS` (see `packages/db/scripts/migrate.ts`) | — | +| Drop an index | `COMMIT;` breakpoint → `DROP INDEX CONCURRENTLY` — plain `DROP INDEX` takes ACCESS EXCLUSIVE on the table | — | +| Backfill data | batched + idempotent `UPDATE` (keyset/`WHERE`, bounded) | — | + +A `CREATE INDEX`, `ADD COLUMN`, or `ADD CONSTRAINT` against a table **created in the same migration** is always safe (no rows, no live traffic) — the lint already suppresses those. + +## Tracking the contract (don't let it rot) + +The contract half is deferred to a later deploy — and that is exactly when it gets forgotten, leaving dead columns, orphaned tables, and `NOT NULL`s that never land. Every deferred contract must become a durable, greppable TODO. + +When an expand defers a drop, leave a **`contract-pending`** marker on the legacy column/table in `packages/db/schema.ts` — that is the file you will be editing when you finally do the drop, so the reminder lives where the work happens: + +```ts +// contract-pending(after #5035 is fully deployed): drop once permission-check.ts stops reading it +workspaceId: text('workspace_id'), +``` + +Format: `contract-pending(): `. The precondition names the PR/release that removes the last reader and **must be fully deployed** before the contract ships. + +- **The TODO list is a grep** — always accurate, never drifts: `grep -rn "contract-pending" packages/db apps/sim`. Run it when starting migration work to see what is owed. +- For anything with a real owner or schedule, also open a tracking issue and put its number in the marker. +- **Close the loop in the contract PR:** the contract migration's `-- migration-safe:` annotation references the expand, and you **delete the `contract-pending` marker** in the same PR: + ```sql + -- migration-safe: contract of #5035 — workspace_id readers removed there, deployed 2026-06-10 + ALTER TABLE "permission_group" DROP COLUMN "workspace_id"; + ``` +- An expand merged **without** a marker for the drop it defers, or a contract merged **without** removing its marker, is a bug — flag it in review. + +## The judgment the lint can't do + +The lint flags risky *shapes*; it cannot know whether a given drop is *safe right now*. For each flagged statement, do the work it can't: + +1. **Is the dependency gone?** Grep the app for the table/column: search `apps/sim` and `packages` for the column name, the Drizzle field (camelCase), and the table object. If any live read/write remains, it is **not** safe — fix the code first. +2. **Did the expand already ship?** The removal of that read/write must be in a deploy that is *already out*, not this same PR. If it's in this PR, split: land the code change now, do the destructive migration in a follow-up after it deploys. +3. **Backfills:** confirm the `UPDATE`/`DELETE` is batched (bounded `WHERE`/keyset, not a single whole-table statement), idempotent (safe to replay — a failed migration re-runs unjournaled files from the top), and safe under concurrent writes from the still-live old app. + +## Workflow + +1. Edit `packages/db/schema.ts`, then `cd packages/db && bunx drizzle-kit generate` to produce the SQL. If this is an expand that defers a drop, leave a `contract-pending` marker on the legacy column (see "Tracking the contract"). If this is the contract, delete the marker it resolves. +2. Hand-edit the generated SQL where the playbook requires it: `CONCURRENTLY` + `COMMIT;` breakpoint for indexes on existing tables, `NOT VALID` for constraints, batching for backfills. +3. Run `bun run check:migrations` (base defaults to `origin/staging`). + - **Hard errors** (`add-not-null-no-default`, `rename`, `index-not-concurrent`, `constraint-not-valid`, …): rewrite into expand/contract. Do **not** try to annotate them away — the lint won't accept it. + - **Annotate tier** (`drop-table`, `drop-column`, `drop-default`, `set-not-null`, `alter-type`, `drop-index`): only after you've confirmed steps 1–3 above, add a comment on the line directly above the statement: + ```sql + -- migration-safe: `secret` read removed in v0.6.1 (#1234), shipped two deploys ago + ALTER TABLE "webhook" DROP COLUMN "secret"; + ``` + The reason must be specific and name the PR/version that removed the dependency. An empty reason fails the lint. + - **Warnings** (`data-backfill`): non-blocking, but confirm the batching/idempotency before merging. +4. Verify locally: `cd packages/db && bun run db:migrate` against a dev DB. + +## Hard rule + +Never annotate a destructive statement just to make the lint pass. The annotation is a claim that you verified the old code no longer depends on it. If you can't make that claim truthfully, the change belongs in a later deploy — tell the user to split it. diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 85fefb30ea6..db61568d6c0 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -1,6 +1,6 @@ --- name: ship -description: Commit, push, and open a PR to staging in one shot +description: Commit, push, and open a PR to staging in one shot — runs the cleanup pass and, when migrations changed, the db-migrate safety review first --- # Ship Command @@ -16,12 +16,17 @@ When the user runs `/ship`: - Types: `fix`, `feat`, `improvement`, `chore` - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) - Keep it concise -3. **Run pre-ship checks** from the repo root before staging: +3. **Run the cleanup pass** — only if the diff modifies UI code (any `.tsx` file, or anything under `apps/sim/components/`, `apps/sim/hooks/`, or `apps/sim/stores/`): `/cleanup` + - The six code-quality skills (effects, memo, callbacks, state, React Query, emcn) only apply to React code, so skip this step entirely when no UI was touched. When it runs, it applies fixes so they land in this commit. +4. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: + - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). + - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. +5. **Run pre-ship checks** from the repo root before staging: - `bun run lint` to fix formatting issues - `bun run check:api-validation:strict` to catch boundary contract failures before CI -4. **Stage and commit** the changes with the generated message -5. **Push to origin** using the current branch name -6. **Create a PR** to staging with a description in the user's voice +6. **Stage and commit** the changes with the generated message +7. **Push to origin** using the current branch name +8. **Create a PR** to staging with a description in the user's voice ## Commit Message Format @@ -77,7 +82,7 @@ gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" - Short, direct bullet points - No unnecessary explanation -- "Tested manually" is acceptable for testing section; include lint and boundary validation results when run +- "Tested manually" is acceptable for testing section; include lint, boundary validation, and (when migrations changed) `check:migrations` results when run - Checkboxes filled in appropriately - No screenshots section unless UI changes diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md index 1a0024ace07..31d4f4ac4ef 100644 --- a/.agents/skills/validate-connector/SKILL.md +++ b/.agents/skills/validate-connector/SKILL.md @@ -21,10 +21,12 @@ When the user asks you to validate a connector: Read **every** file for the connector — do not skip any: ``` -apps/sim/connectors/{service}/{service}.ts # Connector implementation +apps/sim/connectors/{service}/meta.ts # ConnectorMeta — client-safe metadata (icon, name, auth, configFields, tagDefinitions) +apps/sim/connectors/{service}/{service}.ts # Connector implementation — spreads the meta + runtime functions apps/sim/connectors/{service}/index.ts # Barrel export -apps/sim/connectors/registry.ts # Connector registry entry -apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc. +apps/sim/connectors/registry.server.ts # Server-only full registry entry (CONNECTOR_REGISTRY; full connector) +apps/sim/connectors/registry.ts # Client-safe meta registry entry (CONNECTOR_META_REGISTRY) +apps/sim/connectors/types.ts # ConnectorMeta / ConnectorConfig interfaces, ExternalDocument, etc. apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.) apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS @@ -262,10 +264,18 @@ Connectors where the list API already returns content inline (e.g., Slack messag - [ ] Logs sync progress at `info` level - [ ] Logs errors at `warn` or `error` level with context +### Meta / Runtime Split +- [ ] `connectors/{service}/meta.ts` exports `{service}ConnectorMeta: ConnectorMeta` (id, name, description, version, icon, auth, configFields, and any `tagDefinitions` / `supportsIncrementalSync`) +- [ ] `meta.ts` imports ONLY the icon from `@/components/icons`, `ConnectorMeta` (type-only), and pure-data constants — NO server/runtime imports (`@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`, etc.); any such import in `meta.ts` is **critical** (breaks the client bundle) +- [ ] `connectors/{service}/{service}.ts` spreads `...{service}ConnectorMeta` as the first property and adds the runtime functions (`listDocuments`, `getDocument`, `validateConfig`, `mapTags?`) +- [ ] Metadata fields (id, name, auth, configFields, etc.) live ONLY in `meta.ts`, not duplicated in `{service}.ts` + ### Registry - [ ] Connector is exported from `connectors/{service}/index.ts` -- [ ] Connector is registered in `connectors/registry.ts` -- [ ] Registry key matches the connector's `id` field +- [ ] Full connector is registered in `connectors/registry.server.ts` (server-only registry, `CONNECTOR_REGISTRY`) +- [ ] Meta is registered in `connectors/registry.ts` (client-safe registry, `CONNECTOR_META_REGISTRY`), importing `@/connectors/{service}/meta` +- [ ] Both registries use the same key and it matches the connector's `id` field +- [ ] Both registries keep the same alphabetical-by-id ordering ## Step 11: Report and Fix @@ -284,6 +294,8 @@ Group findings by severity: - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping - Per-document content download in `listDocuments` without `contentDeferred: true` — causes sync timeouts for large document sets - `contentHash` mismatch between `listDocuments` stub and `getDocument` return — causes unnecessary re-processing every sync +- Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build +- Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -323,7 +335,7 @@ After fixing, confirm: ## Checklist Summary -- [ ] Read connector implementation, types, utils, registry, and OAuth config +- [ ] Read connector meta.ts, implementation, types, utils, both registries, and OAuth config - [ ] Pulled and read official API documentation for the service - [ ] Validated every API endpoint URL, method, headers, and body against API docs - [ ] Validated input sanitization: no query/filter injection, URL fields normalized @@ -342,7 +354,8 @@ After fixing, confirm: - [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched - [ ] Validated error handling: graceful failures, no unhandled rejections - [ ] Validated logging: createLogger, no console.log -- [ ] Validated registry: correct export, correct key +- [ ] Validated meta/runtime split: `meta.ts` holds metadata with no server/runtime imports, `{service}.ts` spreads the meta + adds runtime functions +- [ ] Validated registry: exported from index.ts, full connector in `registry.server.ts`, meta in `registry.ts`, matching keys and alphabetical-by-id ordering in both - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/.claude/commands/add-connector.md b/.claude/commands/add-connector.md index 8d667ebb7a2..9b7a5650d09 100644 --- a/.claude/commands/add-connector.md +++ b/.claude/commands/add-connector.md @@ -12,18 +12,24 @@ You are an expert at adding knowledge base connectors to Sim. A connector syncs When the user asks you to create a connector: 1. Use Context7 or WebFetch to read the service's API documentation 2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory and config -4. Register it in the connector registry +3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it +4. Register it in BOTH the server registry and the client-safe meta registry ## Directory Structure +Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. + Create files in `apps/sim/connectors/{service}/`: ``` connectors/{service}/ -├── index.ts # Barrel export -└── {service}.ts # ConnectorConfig definition +├── index.ts # Barrel export (re-exports the runtime connector) +├── meta.ts # ConnectorMeta — client-safe declarative metadata +└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions ``` +- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. +- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). + ## Authentication Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): @@ -40,19 +46,17 @@ For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The ### API key mode For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. -## ConnectorConfig Structure +## Connector Structure (meta.ts + runtime) + +The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. -### OAuth connector example +### `meta.ts` — client-safe metadata ```typescript -import { createLogger } from '@sim/logger' import { {Service}Icon } from '@/components/icons' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') +import type { ConnectorMeta } from '@/connectors/types' -export const {service}Connector: ConnectorConfig = { +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -70,6 +74,26 @@ export const {service}Connector: ConnectorConfig = { // Supports 'short-input', 'dropdown', and 'selector' types — see ConfigField Types below ], + // Optional: tag definitions are metadata too — declare them here + // tagDefinitions: [ ... ], +} +``` + +Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. + +### `{service}.ts` — runtime (OAuth example) + +```typescript +import { createLogger } from '@sim/logger' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' + +const logger = createLogger('{Service}Connector') + +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, + listDocuments: async (accessToken, sourceConfig, cursor) => { // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) // Or full documents with content (if list API returns content inline) @@ -94,8 +118,11 @@ export const {service}Connector: ConnectorConfig = { ### API key connector example +The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. + ```typescript -export const {service}Connector: ConnectorConfig = { +// meta.ts +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -109,6 +136,11 @@ export const {service}Connector: ConnectorConfig = { }, configFields: [ /* ... */ ], +} + +// {service}.ts +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, @@ -512,7 +544,9 @@ If the service already has an icon in `apps/sim/components/icons.tsx` (from a to ## Registering -Add one line to `apps/sim/connectors/registry.ts`: +Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. + +1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): ```typescript import { {service}Connector } from '@/connectors/{service}' @@ -523,6 +557,19 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { } ``` +2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): + +```typescript +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' + +export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { + // ... existing connector metas ... + {service}: {service}ConnectorMeta, +} +``` + +`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. + ## Reference Implementations - **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination @@ -533,7 +580,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Checklist -- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig +- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports +- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions - [ ] Created `connectors/{service}/index.ts` barrel export - [ ] **Auth configured correctly:** - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` @@ -556,4 +604,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) - [ ] All optional config fields validated in `validateConfig` - [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered in `connectors/registry.ts` +- [ ] Registered the full connector in `connectors/registry.server.ts` +- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md new file mode 100644 index 00000000000..74670ede566 --- /dev/null +++ b/.claude/commands/add-feature-flag.md @@ -0,0 +1,72 @@ +--- +description: Add a runtime gated feature flag (AppConfig-backed on prod, secret fallback off-prod), gated by org id, user id, or admin +argument-hint: +--- + +# Add Feature Flag Skill + +You add a **runtime, gated feature flag** to Sim — one that can be turned on for specific orgs, users, or admins and changed on prod with no redeploy (AWS AppConfig). When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only). + +## When to use this vs `env-flags.ts` + +- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill. +- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.** + +If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. + +## The flag model + +A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches: + +```ts +interface FeatureFlagRule { + enabled?: boolean // global default for everyone + orgIds?: string[] // allowlisted organization ids + userIds?: string[] // allowlisted user ids + admins?: boolean // platform admins (user.role === 'admin') +} +``` + +Critically, **none of this is expressible in code** — gating (especially `admins`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret. + +## Steps + +1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally): + + ```ts + const FEATURE_FLAGS = { + '': { + description: '', + fallback: '', + }, + } + ``` + + `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. + +2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: + + ```ts + import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + + if (await isFeatureEnabled('', { userId, orgId })) { + // gated behavior + } + ``` + + - Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read. + - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. + - **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig. + +3. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag under `flags` in the hosted `feature-flags` document — including any `orgIds`/`userIds`/`admins` gating — and start a `sim--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. + +4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path. + +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. + +## Notes + +- Flag keys are `kebab-case`. +- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. +- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `admins` is the deciding clause. diff --git a/.claude/commands/validate-connector.md b/.claude/commands/validate-connector.md index 6bf8dab4d53..eca711b4ddd 100644 --- a/.claude/commands/validate-connector.md +++ b/.claude/commands/validate-connector.md @@ -21,10 +21,12 @@ When the user asks you to validate a connector: Read **every** file for the connector — do not skip any: ``` -apps/sim/connectors/{service}/{service}.ts # Connector implementation +apps/sim/connectors/{service}/meta.ts # ConnectorMeta — client-safe metadata (icon, name, auth, configFields, tagDefinitions) +apps/sim/connectors/{service}/{service}.ts # Connector implementation — spreads the meta + runtime functions apps/sim/connectors/{service}/index.ts # Barrel export -apps/sim/connectors/registry.ts # Connector registry entry -apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc. +apps/sim/connectors/registry.server.ts # Server-only full registry entry (CONNECTOR_REGISTRY; full connector) +apps/sim/connectors/registry.ts # Client-safe meta registry entry (CONNECTOR_META_REGISTRY) +apps/sim/connectors/types.ts # ConnectorMeta / ConnectorConfig interfaces, ExternalDocument, etc. apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.) apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS @@ -240,10 +242,18 @@ This is the most common connector bug class — verify it explicitly against `sy - [ ] Logs sync progress at `info` level - [ ] Logs errors at `warn` or `error` level with context +### Meta / Runtime Split +- [ ] `connectors/{service}/meta.ts` exports `{service}ConnectorMeta: ConnectorMeta` (id, name, description, version, icon, auth, configFields, and any `tagDefinitions` / `supportsIncrementalSync`) +- [ ] `meta.ts` imports ONLY the icon from `@/components/icons`, `ConnectorMeta` (type-only), and pure-data constants — NO server/runtime imports (`@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`, etc.); any such import in `meta.ts` is **critical** (breaks the client bundle) +- [ ] `connectors/{service}/{service}.ts` spreads `...{service}ConnectorMeta` as the first property and adds the runtime functions (`listDocuments`, `getDocument`, `validateConfig`, `mapTags?`) +- [ ] Metadata fields (id, name, auth, configFields, etc.) live ONLY in `meta.ts`, not duplicated in `{service}.ts` + ### Registry - [ ] Connector is exported from `connectors/{service}/index.ts` -- [ ] Connector is registered in `connectors/registry.ts` -- [ ] Registry key matches the connector's `id` field +- [ ] Full connector is registered in `connectors/registry.server.ts` (server-only registry, `CONNECTOR_REGISTRY`) +- [ ] Meta is registered in `connectors/registry.ts` (client-safe registry, `CONNECTOR_META_REGISTRY`), importing `@/connectors/{service}/meta` +- [ ] Both registries use the same key and it matches the connector's `id` field +- [ ] Both registries keep the same alphabetical-by-id ordering ## Step 11: Report and Fix @@ -260,6 +270,8 @@ Group findings by severity: - Missing error handling that would crash the sync - `requiredScopes` not a subset of OAuth provider scopes - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping +- Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build +- Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -299,7 +311,7 @@ After fixing, confirm: ## Checklist Summary -- [ ] Read connector implementation, types, utils, registry, and OAuth config +- [ ] Read connector meta.ts, implementation, types, utils, both registries, and OAuth config - [ ] Pulled and read official API documentation for the service - [ ] Validated every API endpoint URL, method, headers, and body against API docs - [ ] Validated input sanitization: no query/filter injection, URL fields normalized @@ -317,7 +329,8 @@ After fixing, confirm: - [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched - [ ] Validated error handling: graceful failures, no unhandled rejections - [ ] Validated logging: createLogger, no console.log -- [ ] Validated registry: correct export, correct key +- [ ] Validated meta/runtime split: `meta.ts` holds metadata with no server/runtime imports, `{service}.ts` spreads the meta + adds runtime functions +- [ ] Validated registry: exported from index.ts, full connector in `registry.server.ts`, meta in `registry.ts`, matching keys and alphabetical-by-id ordering in both - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/.cursor/commands/add-connector.md b/.cursor/commands/add-connector.md index 9d29f5aab62..63ed2cec4ae 100644 --- a/.cursor/commands/add-connector.md +++ b/.cursor/commands/add-connector.md @@ -7,18 +7,24 @@ You are an expert at adding knowledge base connectors to Sim. A connector syncs When the user asks you to create a connector: 1. Use Context7 or WebFetch to read the service's API documentation 2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory and config -4. Register it in the connector registry +3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it +4. Register it in BOTH the server registry and the client-safe meta registry ## Directory Structure +Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. + Create files in `apps/sim/connectors/{service}/`: ``` connectors/{service}/ -├── index.ts # Barrel export -└── {service}.ts # ConnectorConfig definition +├── index.ts # Barrel export (re-exports the runtime connector) +├── meta.ts # ConnectorMeta — client-safe declarative metadata +└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions ``` +- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. +- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). + ## Authentication Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): @@ -35,19 +41,17 @@ For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The ### API key mode For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. -## ConnectorConfig Structure +## Connector Structure (meta.ts + runtime) + +The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. -### OAuth connector example +### `meta.ts` — client-safe metadata ```typescript -import { createLogger } from '@sim/logger' import { {Service}Icon } from '@/components/icons' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') +import type { ConnectorMeta } from '@/connectors/types' -export const {service}Connector: ConnectorConfig = { +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -65,6 +69,26 @@ export const {service}Connector: ConnectorConfig = { // Supports 'short-input' and 'dropdown' types ], + // Optional: tag definitions are metadata too — declare them here + // tagDefinitions: [ ... ], +} +``` + +Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. + +### `{service}.ts` — runtime (OAuth example) + +```typescript +import { createLogger } from '@sim/logger' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' + +const logger = createLogger('{Service}Connector') + +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, + listDocuments: async (accessToken, sourceConfig, cursor) => { // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) // Or full documents with content (if list API returns content inline) @@ -89,8 +113,11 @@ export const {service}Connector: ConnectorConfig = { ### API key connector example +The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. + ```typescript -export const {service}Connector: ConnectorConfig = { +// meta.ts +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -104,6 +131,11 @@ export const {service}Connector: ConnectorConfig = { }, configFields: [ /* ... */ ], +} + +// {service}.ts +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, @@ -477,7 +509,9 @@ If the service already has an icon in `apps/sim/components/icons.tsx` (from a to ## Registering -Add one line to `apps/sim/connectors/registry.ts`: +Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. + +1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): ```typescript import { {service}Connector } from '@/connectors/{service}' @@ -488,6 +522,19 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { } ``` +2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): + +```typescript +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' + +export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { + // ... existing connector metas ... + {service}: {service}ConnectorMeta, +} +``` + +`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. + ## Reference Implementations - **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination @@ -498,7 +545,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Checklist -- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig +- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports +- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions - [ ] Created `connectors/{service}/index.ts` barrel export - [ ] **Auth configured correctly:** - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` @@ -520,4 +568,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) - [ ] All optional config fields validated in `validateConfig` - [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered in `connectors/registry.ts` +- [ ] Registered the full connector in `connectors/registry.server.ts` +- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.cursor/commands/add-feature-flag.md b/.cursor/commands/add-feature-flag.md new file mode 100644 index 00000000000..fc3dba41e46 --- /dev/null +++ b/.cursor/commands/add-feature-flag.md @@ -0,0 +1,67 @@ +# Add Feature Flag Skill + +You add a **runtime, gated feature flag** to Sim — one that can be turned on for specific orgs, users, or admins and changed on prod with no redeploy (AWS AppConfig). When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only). + +## When to use this vs `env-flags.ts` + +- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill. +- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.** + +If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. + +## The flag model + +A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches: + +```ts +interface FeatureFlagRule { + enabled?: boolean // global default for everyone + orgIds?: string[] // allowlisted organization ids + userIds?: string[] // allowlisted user ids + admins?: boolean // platform admins (user.role === 'admin') +} +``` + +Critically, **none of this is expressible in code** — gating (especially `admins`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret. + +## Steps + +1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally): + + ```ts + const FEATURE_FLAGS = { + '': { + description: '', + fallback: '', + }, + } + ``` + + `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. + +2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: + + ```ts + import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + + if (await isFeatureEnabled('', { userId, orgId })) { + // gated behavior + } + ``` + + - Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read. + - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. + - **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig. + +3. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag under `flags` in the hosted `feature-flags` document — including any `orgIds`/`userIds`/`admins` gating — and start a `sim--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. + +4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path. + +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. + +## Notes + +- Flag keys are `kebab-case`. +- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. +- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `admins` is the deciding clause. diff --git a/.cursor/commands/validate-connector.md b/.cursor/commands/validate-connector.md index e1e319e2c0b..bfc4e2520a6 100644 --- a/.cursor/commands/validate-connector.md +++ b/.cursor/commands/validate-connector.md @@ -16,10 +16,12 @@ When the user asks you to validate a connector: Read **every** file for the connector — do not skip any: ``` -apps/sim/connectors/{service}/{service}.ts # Connector implementation +apps/sim/connectors/{service}/meta.ts # ConnectorMeta — client-safe metadata (icon, name, auth, configFields, tagDefinitions) +apps/sim/connectors/{service}/{service}.ts # Connector implementation — spreads the meta + runtime functions apps/sim/connectors/{service}/index.ts # Barrel export -apps/sim/connectors/registry.ts # Connector registry entry -apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc. +apps/sim/connectors/registry.server.ts # Server-only full registry entry (CONNECTOR_REGISTRY; full connector) +apps/sim/connectors/registry.ts # Client-safe meta registry entry (CONNECTOR_META_REGISTRY) +apps/sim/connectors/types.ts # ConnectorMeta / ConnectorConfig interfaces, ExternalDocument, etc. apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.) apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS @@ -228,10 +230,18 @@ For each API endpoint the connector calls: - [ ] Logs sync progress at `info` level - [ ] Logs errors at `warn` or `error` level with context +### Meta / Runtime Split +- [ ] `connectors/{service}/meta.ts` exports `{service}ConnectorMeta: ConnectorMeta` (id, name, description, version, icon, auth, configFields, and any `tagDefinitions` / `supportsIncrementalSync`) +- [ ] `meta.ts` imports ONLY the icon from `@/components/icons`, `ConnectorMeta` (type-only), and pure-data constants — NO server/runtime imports (`@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`, etc.); any such import in `meta.ts` is **critical** (breaks the client bundle) +- [ ] `connectors/{service}/{service}.ts` spreads `...{service}ConnectorMeta` as the first property and adds the runtime functions (`listDocuments`, `getDocument`, `validateConfig`, `mapTags?`) +- [ ] Metadata fields (id, name, auth, configFields, etc.) live ONLY in `meta.ts`, not duplicated in `{service}.ts` + ### Registry - [ ] Connector is exported from `connectors/{service}/index.ts` -- [ ] Connector is registered in `connectors/registry.ts` -- [ ] Registry key matches the connector's `id` field +- [ ] Full connector is registered in `connectors/registry.server.ts` (server-only registry, `CONNECTOR_REGISTRY`) +- [ ] Meta is registered in `connectors/registry.ts` (client-safe registry, `CONNECTOR_META_REGISTRY`), importing `@/connectors/{service}/meta` +- [ ] Both registries use the same key and it matches the connector's `id` field +- [ ] Both registries keep the same alphabetical-by-id ordering ## Step 11: Report and Fix @@ -248,6 +258,8 @@ Group findings by severity: - Missing error handling that would crash the sync - `requiredScopes` not a subset of OAuth provider scopes - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping +- Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build +- Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -286,7 +298,7 @@ After fixing, confirm: ## Checklist Summary -- [ ] Read connector implementation, types, utils, registry, and OAuth config +- [ ] Read connector meta.ts, implementation, types, utils, both registries, and OAuth config - [ ] Pulled and read official API documentation for the service - [ ] Validated every API endpoint URL, method, headers, and body against API docs - [ ] Validated input sanitization: no query/filter injection, URL fields normalized @@ -304,7 +316,8 @@ After fixing, confirm: - [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched - [ ] Validated error handling: graceful failures, no unhandled rejections - [ ] Validated logging: createLogger, no console.log -- [ ] Validated registry: correct export, correct key +- [ ] Validated meta/runtime split: `meta.ts` holds metadata with no server/runtime imports, `{service}.ts` spreads the meta + adds runtime functions +- [ ] Validated registry: exported from index.ts, full connector in `registry.server.ts`, meta in `registry.ts`, matching keys and alphabetical-by-id ordering in both - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index ffb2f857016..ca3ceb1e946 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -150,7 +150,7 @@ vi.useFakeTimers() | `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | | `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | | `@/lib/core/config/env` | `envMock`, `createEnvMock(overrides)` | `vi.mock('@/lib/core/config/env', () => envMock)` | -| `@/lib/core/config/feature-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)` | +| `@/lib/core/config/env-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/env-flags', () => featureFlagsMock)` | | `@/lib/core/config/redis` | `redisConfigMock`, `redisConfigMockFns` | `vi.mock('@/lib/core/config/redis', () => redisConfigMock)` | | `@/lib/core/security/encryption` | `encryptionMock`, `encryptionMockFns` | `vi.mock('@/lib/core/security/encryption', () => encryptionMock)` | | `@/lib/core/security/input-validation.server` | `inputValidationMock`, `inputValidationMockFns` | `vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)` | diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml new file mode 100644 index 00000000000..f164c822123 --- /dev/null +++ b/.github/workflows/companion-pr-check.yml @@ -0,0 +1,207 @@ +name: companion-pr-check + +# Soft, NON-BLOCKING warning: when a PR targeting staging/main declares a +# cross-repo "Companion:" PR, surface whether that companion is merged yet, so +# copilot and sim stay in lockstep (a change in one often needs the other). +# +# Declare in a PR description (repeatable; shorthand OR full URL both parse): +# Companion: simstudioai/sim#1234 +# Companion: https://github.com/simstudioai/sim/pull/1234 +# +# Requires a CROSS_REPO_TOKEN secret (fine-grained PAT with pull-requests:read on +# BOTH repos) to read the other repo's PR state. Without it the check still +# surfaces the declared link but reports "couldn't verify". + +on: + # PR-driven only: runs on the one PR being opened/edited/synced — no periodic + # bulk scan. We assume companions are declared on BOTH sides, so the per-PR + # trigger keeps each side's status fresh; to refresh after a companion merges, + # re-edit the PR (or run this workflow manually via the Actions tab). + pull_request: + types: [opened, edited, reopened, synchronize] + branches: [staging, main] + workflow_dispatch: {} + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + companion: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }} + with: + script: | + const STICKY = ''; + const { owner, repo } = context.repo; + // Directional label: copilot/mothership PRs get "requires-sim-merge", + // sim PRs get "requires-mothership-merge". Applied whenever the PR + // declares a companion; removed when it declares none. + const otherSide = repo === 'sim' ? 'mothership/copilot' : 'sim'; + const LABEL = repo === 'sim' ? 'requires-mothership-merge' : 'requires-sim-merge'; + const LABEL_DESC = `Has a companion PR on the ${otherSide} side — merge in lockstep`; + const crossToken = process.env.CROSS_REPO_TOKEN; + // Read the OTHER repo's PR via a plain REST fetch with the PAT in the + // header — keeps the PAT strictly READ-ONLY and avoids re-instantiating + // Octokit inside github-script (which can't require('@actions/github')). + // Commenting/labeling uses the default GITHUB_TOKEN via `github`. + async function crossGetPR(c) { + const res = await fetch(`https://api.github.com/repos/${c.owner}/${c.repo}/pulls/${c.number}`, { + headers: { + authorization: `Bearer ${crossToken}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'companion-pr-check', + }, + }); + if (!res.ok) { const e = new Error(`HTTP ${res.status}`); e.status = res.status; throw e; } + return res.json(); + } + + // Two ways to declare a companion (either works; both feed this warning): + // 1) a trailer anywhere: Companion: owner/repo#N (or a full PR URL) + // 2) refs in a task list under a "## Companion..." heading — which ALSO + // renders a native live badge + progress bar on the PR (the "both" path): + // ## Companion PRs + // - [ ] owner/repo#N + // Regexes are local + matchAll, so there's no shared lastIndex state to leak + // between calls (stateless by construction). + function parseCompanions(body) { + body = body || ''; + const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi; + const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g; + const out = []; + const seen = new Set(); + const add = (o, r, n) => { + const ref = `${o}/${r}#${n}`; + if (seen.has(ref)) return; + seen.add(ref); + out.push({ owner: o, repo: r, number: Number(n), ref }); + }; + // (1) "Companion:" trailers anywhere in the body. + for (const m of body.matchAll(TRAILER)) add(m[1], m[2], m[3]); + // (2) refs in a task list under a "## Companion..." heading, until the next heading. + let inSection = false; + for (const line of body.split(/\r?\n/)) { + if (/^#{1,6}\s/.test(line)) { inSection = /^#{1,6}\s*companion/i.test(line); continue; } + if (!inSection) continue; + for (const mm of line.matchAll(REF)) add(mm[1], mm[2], mm[3]); + } + return out; + } + + async function findSticky(prNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + return comments.find((c) => (c.body || '').includes(STICKY)); + } + async function upsert(prNumber, body) { + const ex = await findSticky(prNumber); + if (ex) await github.rest.issues.updateComment({ owner, repo, comment_id: ex.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + } + async function clear(prNumber) { + const ex = await findSticky(prNumber); + if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); + // Drop the label too, so a PR edited to remove all companions doesn't + // keep a stale badge. 404 (not present) is expected; surface anything else. + try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: LABEL }); } + catch (e) { if (e.status !== 404) core.warning(`companion: removeLabel ${LABEL} on #${prNumber} failed (${e.status || e.message})`); } + } + async function ensureLabel() { + try { await github.rest.issues.getLabel({ owner, repo, name: LABEL }); return; } + catch (e) { if (e.status && e.status !== 404) { core.warning(`companion: getLabel ${LABEL} failed (${e.status})`); return; } } + // 404 → label doesn't exist yet, create it. 422 = another run beat us (fine). + try { await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'd93f0b', description: LABEL_DESC }); } + catch (e) { if (e.status !== 422) core.warning(`companion: createLabel ${LABEL} failed (${e.status || e.message})`); } + } + + // staging PRs are a single feature → just this PR's body ("the one"). + // main (prod) release PRs bundle MANY feature PRs → aggregate the + // companions declared on each squashed feature PR too, so "does any + // commit in this release have a companion?" is answered. + async function collectCompanions(pr) { + const companions = parseCompanions(pr.body); + const seen = new Set(companions.map((c) => c.ref)); + if (pr.base.ref === 'main') { + let commits = []; + try { + commits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + } catch {} + const featurePRs = new Set(); + const SQUASH = /\(#(\d+)\)/g; // squash-merge refs like "...(#306)" + for (const c of commits) { + const msg = (c.commit && c.commit.message) || ''; + let m; + SQUASH.lastIndex = 0; + while ((m = SQUASH.exec(msg)) !== null) featurePRs.add(Number(m[1])); + } + for (const n of featurePRs) { + if (n === pr.number) continue; + try { + const { data: fpr } = await github.rest.pulls.get({ owner, repo, pull_number: n }); + for (const c of parseCompanions(fpr.body)) { + if (!seen.has(c.ref)) { seen.add(c.ref); companions.push(c); } + } + } catch {} + } + } + return companions; + } + + async function checkPR(pr) { + const companions = await collectCompanions(pr); + if (companions.length === 0) { await clear(pr.number); return; } + await ensureLabel(); + try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); } + catch (e) { core.warning(`companion: addLabels ${LABEL} on #${pr.number} failed (${e.status || e.message})`); } + + const base = pr.base.ref; + const lines = []; + let warn = false; + for (const c of companions) { + if (!crossToken) { + lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`); + warn = true; + continue; + } + try { + const cp = await crossGetPR(c); + const title = (cp.title || '').slice(0, 80); + if (cp.merged) { + const tierOk = cp.base.ref === base; + lines.push(`- ${tierOk ? '✅' : '⚠️'} [\`${c.ref}\`](${cp.html_url}) — merged into \`${cp.base.ref}\`${tierOk ? '' : ` (this PR targets \`${base}\`)`} — ${title}`); + if (!tierOk) warn = true; + } else { + lines.push(`- ❌ [\`${c.ref}\`](${cp.html_url}) — **${String(cp.state).toUpperCase()}, not merged** (targets \`${cp.base.ref}\`) — ${title}`); + warn = true; + } + } catch (e) { + lines.push(`- ❓ \`${c.ref}\` — couldn't read (${e.status || e.message}); check CROSS_REPO_TOKEN scope`); + warn = true; + } + } + const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check'; + const scope = base === 'main' ? ' (aggregated across the feature PRs in this release)' : ''; + const note = warn + ? `One or more companion PRs aren't merged into \`${base}\` yet${scope}. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.` + : `All declared companion PRs are merged into \`${base}\`${scope}.`; + await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`); + } + + if (context.eventName === 'pull_request') { + await checkPR(context.payload.pull_request); + } else { + // workflow_dispatch only: manual full re-scan of open staging/main PRs. + for (const b of ['staging', 'main']) { + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 }); + for (const pr of prs) await checkPR(pr); + } + } diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 2f1df75a380..a272f713f9a 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -55,12 +55,12 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Validate feature flags + - name: Validate env flags run: | - FILE="apps/sim/lib/core/config/feature-flags.ts" + FILE="apps/sim/lib/core/config/env-flags.ts" ERRORS="" - echo "Checking for hardcoded boolean feature flags..." + echo "Checking for hardcoded boolean env flags..." # Use perl for multiline matching to catch both: # export const isHosted = true @@ -69,17 +69,17 @@ jobs: HARDCODED=$(perl -0777 -ne 'while (/export const (is[A-Za-z]+)\s*=\s*\n?\s*(true|false)\b/g) { print " $1 = $2\n" }' "$FILE") if [ -n "$HARDCODED" ]; then - ERRORS="${ERRORS}\n❌ Feature flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nFeature flags should derive their values from environment variables.\n" + ERRORS="${ERRORS}\n❌ Env flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nEnv flags should derive their values from environment variables.\n" fi - echo "Checking feature flag naming conventions..." + echo "Checking env flag naming conventions..." # Check that all export const (except functions) start with 'is' # This finds exports like "export const someFlag" that don't start with "is" or "get" BAD_NAMES=$(grep -E "^export const [a-z]" "$FILE" | grep -vE "^export const (is|get)" | sed 's/export const \([a-zA-Z]*\).*/ \1/') if [ -n "$BAD_NAMES" ]; then - ERRORS="${ERRORS}\n❌ Feature flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n" + ERRORS="${ERRORS}\n❌ Env flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n" fi if [ -n "$ERRORS" ]; then @@ -88,7 +88,7 @@ jobs: exit 1 fi - echo "✅ All feature flags are properly configured" + echo "✅ All env flags are properly configured" - name: Check block registry invariants run: | @@ -118,6 +118,16 @@ jobs: - name: Verify realtime prune graph run: bun run check:realtime-prune + - name: Migration safety (zero-downtime) audit + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_REF="origin/${{ github.base_ref }}" + git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true + else + BASE_REF="HEAD~1" + fi + bun run check:migrations "$BASE_REF" + - name: Type-check realtime server run: bunx turbo run type-check --filter=@sim/realtime diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2d6b6eb2c03..cd31b9713cf 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1960,10 +1960,10 @@ export function WhatsAppIcon(props: SVGProps) { export function SquareIcon(props: SVGProps) { return ( - + ) @@ -2936,12 +2936,11 @@ export function ClickHouseIcon(props: SVGProps) { export function MicrosoftIcon(props: SVGProps) { return ( - - - - - - + + + + + ) } diff --git a/apps/docs/content/docs/en/integrations/google_calendar.mdx b/apps/docs/content/docs/en/integrations/google_calendar.mdx index 4b6011767f8..648d6896e1a 100644 --- a/apps/docs/content/docs/en/integrations/google_calendar.mdx +++ b/apps/docs/content/docs/en/integrations/google_calendar.mdx @@ -48,10 +48,12 @@ Create a new event in Google Calendar. Returns API-aligned fields only. | `summary` | string | Yes | Event title/summary | | `description` | string | No | Event description | | `location` | string | No | Event location | -| `startDateTime` | string | Yes | Start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter | -| `endDateTime` | string | Yes | End date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter | +| `startDateTime` | string | Yes | Start time. Use a datetime with timezone offset \(2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event | +| `endDateTime` | string | Yes | End time. Use a datetime with timezone offset \(2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event | | `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. Defaults to America/Los_Angeles if not provided. | | `attendees` | array | No | Array of attendee email addresses | +| `recurrence` | string | No | Recurrence rule\(s\) in RFC 5545 format \(e.g., RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\). Separate multiple rules with newlines. | +| `addGoogleMeet` | boolean | No | Attach a Google Meet video conference link to the event | | `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | #### Output @@ -60,10 +62,12 @@ Create a new event in Google Calendar. Returns API-aligned fields only. | --------- | ---- | ----------- | | `id` | string | Event ID | | `htmlLink` | string | Event link | +| `hangoutLink` | string | Google Meet link | | `status` | string | Event status | | `summary` | string | Event title | | `description` | string | Event description | | `location` | string | Event location | +| `recurrence` | json | Recurrence rules | | `start` | json | Event start | | `end` | json | Event end | | `attendees` | json | Event attendees | @@ -81,7 +85,10 @@ List events from Google Calendar. Returns API-aligned fields only. | `calendarId` | string | No | Google Calendar ID \(e.g., primary or calendar@group.calendar.google.com\) | | `timeMin` | string | No | Lower bound for events \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) | | `timeMax` | string | No | Upper bound for events \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) | -| `orderBy` | string | No | Order of events returned \(startTime or updated\) | +| `q` | string | No | Free-text search across event summary, description, location, attendees, and organizer | +| `maxResults` | number | No | Maximum number of events to return \(max 2500\) | +| `pageToken` | string | No | Token for retrieving the next page of results | +| `orderBy` | string | No | Order of events returned \(startTime or updated\). Defaults to startTime. | | `showDeleted` | boolean | No | Include deleted events | #### Output @@ -132,10 +139,12 @@ Update an existing event in Google Calendar. Returns API-aligned fields only. | `summary` | string | No | New event title/summary | | `description` | string | No | New event description | | `location` | string | No | New event location | -| `startDateTime` | string | No | New start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter | -| `endDateTime` | string | No | New end date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter | +| `startDateTime` | string | No | New start time. Use a datetime with timezone offset \(2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event | +| `endDateTime` | string | No | New end time. Use a datetime with timezone offset \(2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event | | `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. | -| `attendees` | array | No | Array of attendee email addresses \(replaces existing attendees\) | +| `attendees` | array | No | Array of attendee email addresses \(replaces the existing attendee list\) | +| `recurrence` | string | No | Recurrence rule\(s\) in RFC 5545 format \(e.g., RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\). Separate multiple rules with newlines. | +| `addGoogleMeet` | boolean | No | Attach a Google Meet video conference link to the event | | `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | #### Output @@ -144,10 +153,12 @@ Update an existing event in Google Calendar. Returns API-aligned fields only. | --------- | ---- | ----------- | | `id` | string | Event ID | | `htmlLink` | string | Event link | +| `hangoutLink` | string | Google Meet link | | `status` | string | Event status | | `summary` | string | Event title | | `description` | string | Event description | | `location` | string | Event location | +| `recurrence` | json | Recurrence rules | | `start` | json | Event start | | `end` | json | Event end | | `attendees` | json | Event attendees | @@ -298,7 +309,7 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field | `calendarId` | string | No | Google Calendar ID \(e.g., primary or calendar@group.calendar.google.com\) | | `eventId` | string | Yes | Google Calendar event ID to invite attendees to | | `attendees` | array | Yes | Array of attendee email addresses to invite | -| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | +| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none \(defaults to all\) | | `replaceExisting` | boolean | No | Whether to replace existing attendees or add to them \(defaults to false\) | #### Output @@ -317,6 +328,113 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field | `creator` | json | Event creator | | `organizer` | json | Event organizer | +### `google_calendar_freebusy` + +Query free/busy information for one or more Google Calendars. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarIds` | string | Yes | Comma-separated calendar IDs to query \(e.g., "primary,other@example.com"\) | +| `timeMin` | string | Yes | Start of the time range \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) | +| `timeMax` | string | Yes | End of the time range \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) | +| `timeZone` | string | No | IANA time zone \(e.g., "UTC", "America/New_York"\). Defaults to UTC. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeMin` | string | Start of the queried time range | +| `timeMax` | string | End of the queried time range | +| `calendars` | json | Per-calendar free/busy data with busy periods and any errors | + +### `google_calendar_create_calendar` + +Create a new secondary calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `summary` | string | Yes | Title of the new calendar | +| `description` | string | No | Description of the new calendar | +| `location` | string | No | Geographic location of the calendar as free-form text | +| `timeZone` | string | No | Time zone of the calendar as an IANA name \(e.g., America/Los_Angeles\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Calendar ID | +| `summary` | string | Calendar title | +| `description` | string | Calendar description | +| `location` | string | Calendar location | +| `timeZone` | string | Calendar time zone | + +### `google_calendar_share_calendar` + +Grant a user, group, or domain access to a calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Calendar ID to share \(e.g., primary or calendar@group.calendar.google.com\) | +| `role` | string | Yes | Access role to grant: freeBusyReader, reader, writer, or owner | +| `scopeType` | string | Yes | Type of grantee: user, group, domain, or default \(public\) | +| `scopeValue` | string | No | Email \(user/group\), domain name \(domain\), or empty for default. Required unless scope type is default. | +| `sendNotifications` | boolean | No | Whether to send a notification email about the change. Defaults to true. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ACL rule ID | +| `role` | string | Granted access role | +| `scope` | json | Grantee scope \(type and value\) | + +### `google_calendar_list_acl` + +List the access control rules (sharing) for a calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Calendar ID to inspect \(e.g., primary or calendar@group.calendar.google.com\) | +| `maxResults` | number | No | Maximum number of ACL rules to return | +| `pageToken` | string | No | Token for retrieving subsequent pages of results | +| `showDeleted` | boolean | No | Include deleted ACL rules \(with role "none"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `nextPageToken` | string | Next page token | +| `rules` | array | List of ACL rules | +| ↳ `id` | string | ACL rule ID | +| ↳ `role` | string | Access role | +| ↳ `scope` | json | Grantee scope \(type and value\) | + +### `google_calendar_unshare_calendar` + +Revoke an access control rule (sharing) from a calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Calendar ID to modify \(e.g., primary or calendar@group.calendar.google.com\) | +| `ruleId` | string | Yes | ACL rule ID to remove \(e.g., user:person@example.com\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ruleId` | string | Removed ACL rule ID | +| `deleted` | boolean | Whether removal was successful | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/grafana.mdx b/apps/docs/content/docs/en/integrations/grafana.mdx index 4033539ff75..103d69dc265 100644 --- a/apps/docs/content/docs/en/integrations/grafana.mdx +++ b/apps/docs/content/docs/en/integrations/grafana.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} @@ -401,6 +401,34 @@ List all alert notification contact points | ↳ `disableResolveMessage` | boolean | Whether resolve messages are disabled | | ↳ `provenance` | string | Provisioning source \(empty if API-managed\) | +### `grafana_create_contact_point` + +Create a notification contact point (e.g., Slack, email, PagerDuty) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `name` | string | Yes | Name of the contact point \(groups receivers shown in the UI\) | +| `type` | string | Yes | Receiver type \(e.g., slack, email, pagerduty, webhook\) | +| `settings` | string | Yes | JSON object of type-specific settings \(e.g., \{"addresses":"a@b.com"\} for email, \{"url":"..."\} for slack\) | +| `disableResolveMessage` | boolean | No | Do not send a notification when the alert resolves | +| `disableProvenance` | boolean | No | Set X-Disable-Provenance header so the contact point remains editable in the UI | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `uid` | string | UID of the created contact point | +| `name` | string | Name of the contact point | +| `type` | string | Receiver type | +| `settings` | json | Type-specific settings | +| `disableResolveMessage` | boolean | Whether resolve notifications are suppressed | +| `provenance` | string | Provisioning source \(empty if API-managed\) | + ### `grafana_create_annotation` Create an annotation on a dashboard or as a global annotation @@ -584,6 +612,26 @@ Get a data source by its ID or UID | `version` | number | Data source version | | `readOnly` | boolean | Whether the data source is read-only | +### `grafana_check_data_source_health` + +Test connectivity to a data source by its UID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `dataSourceUid` | string | Yes | The UID of the data source to health-check \(e.g., P1234AB5678\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Health status of the data source \(e.g., OK\) | +| `message` | string | Detailed health message from the data source | + ### `grafana_list_folders` List all folders in Grafana @@ -655,4 +703,112 @@ Create a new folder in Grafana | `updated` | string | Timestamp when the folder was last updated | | `version` | number | Version number of the folder | +### `grafana_get_folder` + +Get a folder by its UID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `folderUid` | string | Yes | The UID of the folder to retrieve \(e.g., folder-abc123\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | The numeric ID of the folder | +| `uid` | string | The UID of the folder | +| `title` | string | The title of the folder | +| `url` | string | The URL path to the folder | +| `parentUid` | string | Parent folder UID \(nested folders only\) | +| `parents` | array | Ancestor folder hierarchy \(nested folders only\) | +| `hasAcl` | boolean | Whether the folder has custom ACL permissions | +| `canSave` | boolean | Whether the current user can save the folder | +| `canEdit` | boolean | Whether the current user can edit the folder | +| `canAdmin` | boolean | Whether the current user has admin rights on the folder | +| `createdBy` | string | Username of who created the folder | +| `created` | string | Timestamp when the folder was created | +| `updatedBy` | string | Username of who last updated the folder | +| `updated` | string | Timestamp when the folder was last updated | +| `version` | number | Version number of the folder | + +### `grafana_update_folder` + +Update (rename) a folder. Fetches the current folder and merges your changes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `folderUid` | string | Yes | The UID of the folder to update \(e.g., folder-abc123\) | +| `title` | string | Yes | New title for the folder | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | The numeric ID of the folder | +| `uid` | string | The UID of the folder | +| `title` | string | The updated title of the folder | +| `url` | string | The URL path to the folder | +| `parentUid` | string | Parent folder UID \(nested folders only\) | +| `parents` | array | Ancestor folder hierarchy \(nested folders only\) | +| `hasAcl` | boolean | Whether the folder has custom ACL permissions | +| `canSave` | boolean | Whether the current user can save the folder | +| `canEdit` | boolean | Whether the current user can edit the folder | +| `canAdmin` | boolean | Whether the current user has admin rights on the folder | +| `createdBy` | string | Username of who created the folder | +| `created` | string | Timestamp when the folder was created | +| `updatedBy` | string | Username of who last updated the folder | +| `updated` | string | Timestamp when the folder was last updated | +| `version` | number | Version number of the folder | + +### `grafana_delete_folder` + +Delete a folder by its UID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `folderUid` | string | Yes | The UID of the folder to delete \(e.g., folder-abc123\) | +| `forceDeleteRules` | boolean | No | Delete any alert rules stored in the folder along with it \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `uid` | string | The UID of the deleted folder | +| `message` | string | Confirmation message | + +### `grafana_get_health` + +Check the health of the Grafana instance (version, database status) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commit` | string | Git commit hash of the running Grafana build | +| `database` | string | Database health status \(e.g., ok\) | +| `version` | string | Grafana version | + diff --git a/apps/docs/content/docs/en/integrations/jira_service_management.mdx b/apps/docs/content/docs/en/integrations/jira_service_management.mdx index 46440071f19..b22676bbbc1 100644 --- a/apps/docs/content/docs/en/integrations/jira_service_management.mdx +++ b/apps/docs/content/docs/en/integrations/jira_service_management.mdx @@ -988,6 +988,272 @@ Copy forms from one Jira issue to another | `copiedForms` | json | Array of successfully copied forms | | `errors` | json | Array of errors encountered during copy | +### `jsm_list_object_schemas` + +List Assets (Insight/CMDB) object schemas in Jira Service Management + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `startAt` | number | No | Pagination start index \(e.g., 0, 50\) | +| `maxResults` | number | No | Maximum schemas to return \(e.g., 25, 50\) | +| `includeCounts` | boolean | No | Include object and object-type counts per schema | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `schemas` | array | List of Assets object schemas | +| ↳ `id` | string | Schema ID | +| ↳ `name` | string | Schema name | +| ↳ `objectSchemaKey` | string | Schema key | +| ↳ `status` | string | Schema status | +| ↳ `description` | string | Schema description | +| ↳ `objectCount` | number | Number of objects | +| ↳ `objectTypeCount` | number | Number of object types | +| `total` | number | Total number of schemas | +| `isLast` | boolean | Whether this is the last page | + +### `jsm_get_object_schema` + +Get a single Assets (Insight/CMDB) object schema by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `schemaId` | string | Yes | The Assets object schema ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `schema` | json | The Assets object schema | +| ↳ `id` | string | Schema ID | +| ↳ `name` | string | Schema name | +| ↳ `objectSchemaKey` | string | Schema key | +| ↳ `status` | string | Schema status | +| ↳ `description` | string | Schema description | +| ↳ `objectCount` | number | Number of objects | +| ↳ `objectTypeCount` | number | Number of object types | + +### `jsm_list_object_types` + +List object types within an Assets (Insight/CMDB) object schema + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `schemaId` | string | Yes | The Assets object schema ID to list object types for | +| `excludeAbstract` | boolean | No | Exclude abstract object types from the result | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `objectTypes` | array | List of object types in the schema | +| ↳ `id` | string | Object type ID | +| ↳ `name` | string | Object type name | +| ↳ `description` | string | Object type description | +| ↳ `objectSchemaId` | string | Parent schema ID | +| ↳ `objectCount` | number | Number of objects | +| ↳ `abstractObjectType` | boolean | Whether the type is abstract | +| ↳ `inherited` | boolean | Whether the type inherits attributes | +| `total` | number | Total number of object types | + +### `jsm_get_object_type_attributes` + +Get the attribute definitions for an Assets (Insight/CMDB) object type. Use the returned attribute IDs to build create/update payloads or map columns. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectTypeId` | string | Yes | The Assets object type ID | +| `onlyValueEditable` | boolean | No | Return only attributes whose values can be edited | +| `query` | string | No | Filter attributes by a search query | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `attributes` | array | Attribute definitions for the object type | +| ↳ `id` | string | Attribute definition ID — use as objectTypeAttributeId in create/update | +| ↳ `name` | string | Attribute name | +| ↳ `label` | boolean | Whether this attribute is the object label | +| ↳ `type` | number | Data type discriminator \(integer enum\) | +| ↳ `defaultType` | json | Default data type \{ id, name \} | +| ↳ `editable` | boolean | Whether the value is editable | +| ↳ `minimumCardinality` | number | Minimum number of values \(>= 1 means required\) | +| ↳ `maximumCardinality` | number | Maximum number of values | +| ↳ `uniqueAttribute` | boolean | Whether values must be unique | +| `total` | number | Total number of attributes | + +### `jsm_search_objects_aql` + +Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. objectType = + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `qlQuery` | string | Yes | AQL query string \(e.g., objectType = "Host" AND "Operating System" = "Ubuntu"\) | +| `page` | number | No | Page number \(1-based, defaults to 1\) | +| `resultsPerPage` | number | No | Results per page \(e.g., 25, 50\) | +| `includeAttributes` | boolean | No | Include resolved attribute values on each object \(defaults to true\) | +| `objectTypeId` | string | No | Optionally scope the search to a single object type ID | +| `objectSchemaId` | string | No | Optionally scope the search to a single object schema ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `objects` | array | Matching Assets objects | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values | +| `total` | number | Total number of matching objects \(totalFilterCount\) | +| `pageNumber` | number | Current page number | +| `pageSize` | number | Number of objects on this page | + +### `jsm_get_object` + +Get a single Assets (Insight/CMDB) object by ID, including its attribute values + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectId` | string | Yes | The Assets object ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `object` | json | The Assets object | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Human-readable object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `globalId` | string | Global object ID | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values for the object | +| ↳ `hasAvatar` | boolean | Whether the object has an avatar | +| ↳ `created` | string | Creation timestamp | +| ↳ `updated` | string | Last update timestamp | +| ↳ `link` | string | Self link to the object | + +### `jsm_create_object` + +Create an Assets (Insight/CMDB) object of a given object type. Attributes use objectTypeAttributeId values from the object type definition. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectTypeId` | string | Yes | The object type ID to create the object under | +| `attributes` | json | Yes | Array of attributes: \[\{ objectTypeAttributeId, objectAttributeValues: \[\{ value \}\] \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `object` | json | The created Assets object | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Human-readable object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `globalId` | string | Global object ID | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values for the object | +| ↳ `hasAvatar` | boolean | Whether the object has an avatar | +| ↳ `created` | string | Creation timestamp | +| ↳ `updated` | string | Last update timestamp | +| ↳ `link` | string | Self link to the object | + +### `jsm_update_object` + +Update an existing Assets (Insight/CMDB) object. Provide the attributes to change using their objectTypeAttributeId values. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectId` | string | Yes | The Assets object ID to update | +| `attributes` | json | Yes | Array of attributes to set: \[\{ objectTypeAttributeId, objectAttributeValues: \[\{ value \}\] \}\] | +| `objectTypeId` | string | No | Optional object type ID \(only if changing the type\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `object` | json | The updated Assets object | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Human-readable object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `globalId` | string | Global object ID | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values for the object | +| ↳ `hasAvatar` | boolean | Whether the object has an avatar | +| ↳ `created` | string | Creation timestamp | +| ↳ `updated` | string | Last update timestamp | +| ↳ `link` | string | Self link to the object | + +### `jsm_delete_object` + +Delete an Assets (Insight/CMDB) object by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectId` | string | Yes | The Assets object ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `objectId` | string | The deleted object ID | +| `deleted` | boolean | Whether the object was deleted | + ## Triggers diff --git a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx index 0de440497c1..5b54a40cb5c 100644 --- a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx @@ -7,15 +7,22 @@ import { Callout } from 'fumadocs-ui/components/callout' import { FAQ } from '@/components/ui/faq' import { Image } from '@/components/ui/image' -Access Control lets organization admins define permission groups that restrict what each set of organization members can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to the **organization** and apply to every workspace under it: a user belongs to at most one group per organization. Restrictions are enforced both in the workflow executor and in Mothership, based on the organization that owns the workflow's workspace. +Access Control lets organization admins define permission groups that restrict what each set of organization members can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to the **organization** and can govern either every workspace in the organization or a specific subset of workspaces. A user can belong to multiple groups, but is governed by exactly one group in any given workspace. Restrictions are enforced both in the workflow executor and in Mothership, based on the organization that owns the workflow's workspace. --- ## How it works -Access control is built around **permission groups**. Each group belongs to a specific organization and has a name, an optional description, and a configuration that defines what its members can and cannot do. A user can belong to at most one permission group **per organization**, and that group governs them in every workspace under the organization. Personal workspaces that do not belong to an organization have no permission groups. +Access control is built around **permission groups**. Each group belongs to a specific organization and has a name, an optional description, a **workspace scope** (all workspaces or a specific subset), and a configuration that defines what its members can and cannot do. A user can belong to multiple permission groups, but at most one group governs them in any given workspace. Personal workspaces that do not belong to an organization have no permission groups. -Sim resolves the governing group deterministically, with no per-workspace fallbacks: a user's explicitly assigned group takes precedence; otherwise the organization's **default group** applies (if one is set); otherwise no restrictions apply. +Sim resolves the governing group for a user in a workspace deterministically, with **specific-over-all precedence**: + +1. a group the user belongs to that **specifically targets that workspace** takes precedence; otherwise +2. a group they belong to that applies to **all workspaces** applies; otherwise +3. the organization's **default group** applies (if one is set); otherwise +4. no restrictions apply. + +Because a user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group, the governing group is always unambiguous. When a user runs a workflow or uses Mothership, Sim reads the resolved group's configuration and applies it: @@ -34,7 +41,7 @@ Go to **Settings → Enterprise → Access Control** from any workspace in your ### 2. Create a permission group -Click **+ Create** and enter a name (required) and optional description. You can also mark the group as the **organization default group** — when set, it governs every organization member who is not explicitly assigned to another group, as well as external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time. +Click **+ Create** and enter a name (required) and optional description. Choose whether the group applies to **all workspaces** in the organization or only a **specific set** of workspaces — when specific, pick the workspaces from the multi-select. You can also mark the group as the **organization default group** — when set, it governs every organization member who is not explicitly assigned to another group, as well as external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time, and the default group always applies to all workspaces. ### 3. Configure permissions @@ -130,7 +137,9 @@ Controls visibility of platform features and modules. ### 4. Add members -Open the group's **Details** view and add members by searching for users by name or email. The member picker lists your organization's members. A user can belong to at most one group per organization — adding a user to a new group removes them from their current group. +Open the group's **Details** view and add members by searching for users by name or email. The member picker lists your organization's members. A user can belong to multiple groups, but only one group can govern them in any given workspace — so adding a user to a group is rejected when it would conflict with another of their groups on a workspace (two all-workspaces groups, or two specific groups that share a workspace). In bulk adds, conflicting users are skipped rather than added. + +You can also change a group's workspace scope at any time from the **Workspaces** row in the Details view. External workspace members (people who have access to a workspace but belong to a different organization) are not assigned to groups individually. They are governed by the organization's **default group** when one is set; otherwise no restrictions apply to them. @@ -159,10 +168,11 @@ When a user opens Mothership, their permission group is read before any block or ## User membership rules -- A user can belong to **at most one** permission group **per organization**, and that group governs them in every workspace under the organization. -- Moving a user to a new group automatically removes them from their previous group. -- Users not assigned to any group fall under the organization's **default group** if one is set; otherwise no restrictions are applied to them. -- Only one group per organization can be marked as the **default group**. The default also governs external workspace members operating in the organization's workspaces. +- A user can belong to **multiple** permission groups, but **at most one** group governs them in any given workspace. +- For a given workspace, a group that **specifically targets that workspace** takes precedence over a group that applies to **all workspaces**, which takes precedence over the organization's **default group**. +- A user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group. Adding a user in a way that would violate this is rejected (single add) or skipped (bulk add) — memberships are never silently moved between groups. +- Users not governed by any group fall under the organization's **default group** if one is set; otherwise no restrictions are applied to them. +- Only one group per organization can be marked as the **default group**, and it always applies to all workspaces. The default also governs external workspace members operating in the organization's workspaces. - Personal or grandfathered workspaces that do not belong to an organization have no permission groups. --- @@ -178,7 +188,11 @@ When a user opens Mothership, their permission group is read before any block or }, { question: "Can a user be in multiple permission groups?", - answer: "A user can belong to at most one permission group per organization, and that group governs them across every workspace under the organization. Adding a user to a new group automatically removes them from their previous group." + answer: "Yes. A user can belong to multiple permission groups, but only one group governs them in any given workspace. A group that specifically targets a workspace takes precedence over an all-workspaces group, which takes precedence over the organization's default group. A user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group." + }, + { + question: "Can a permission group apply to only some workspaces?", + answer: "Yes. When creating or editing a group, choose 'Specific workspaces' and select the workspaces it should govern. A specific-scope group governs its members only in those workspaces; elsewhere those members fall back to their all-workspaces group (if any) or the organization default. The default group always applies to all workspaces." }, { question: "What governs a user who has no permission group assigned?", @@ -189,8 +203,8 @@ When a user opens Mothership, their permission group is read before any block or answer: "Yes. Mothership reads the user's permission group for the workspace's organization before suggesting blocks or tools. Disallowed blocks are filtered out of the block picker, and disallowed tool types are skipped during workflow generation." }, { - question: "Can I apply different restrictions to different people?", - answer: "Permission groups apply across the entire organization, so assign different sets of users to different permission groups to give them different restrictions. Who can access a given workspace is still controlled separately by workspace invitations and permissions." + question: "Can I apply different restrictions to different people or workspaces?", + answer: "Yes. Assign different sets of users to different permission groups to give them different restrictions, and scope each group to all workspaces or a specific subset to vary restrictions per workspace. A user can be in an all-workspaces group for a baseline plus a specific-workspace group that overrides it in select workspaces. Who can access a given workspace is still controlled separately by workspace invitations and permissions." }, { question: "What is the default group?", diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index bba3ba664be..7dc4980ace3 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -191,9 +191,9 @@ import { FAQ } from '@/components/ui/faq' \ No newline at end of file diff --git a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx index 73a95f98b02..ee2f4ede8a3 100644 --- a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx +++ b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx @@ -1,5 +1,10 @@ import { env } from '@/lib/core/config/env' -import { isGithubAuthDisabled, isGoogleAuthDisabled, isProd } from '@/lib/core/config/feature-flags' +import { + isGithubAuthDisabled, + isGoogleAuthDisabled, + isMicrosoftAuthDisabled, + isProd, +} from '@/lib/core/config/env-flags' export async function getOAuthProviderStatus() { const githubAvailable = @@ -8,5 +13,8 @@ export async function getOAuthProviderStatus() { const googleAvailable = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) && !isGoogleAuthDisabled - return { githubAvailable, googleAvailable, isProduction: isProd } + const microsoftAvailable = + !!(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET) && !isMicrosoftAuthDisabled + + return { githubAvailable, googleAvailable, microsoftAvailable, isProduction: isProd } } diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 674ebe2eeb0..ed46b9413aa 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -2,12 +2,13 @@ import { type ReactNode, useState } from 'react' import { Button } from '@/components/emcn' -import { GithubIcon, GoogleIcon } from '@/components/icons' +import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { client } from '@/lib/auth/auth-client' interface SocialLoginButtonsProps { githubAvailable: boolean googleAvailable: boolean + microsoftAvailable: boolean callbackURL?: string isProduction: boolean children?: ReactNode @@ -16,12 +17,14 @@ interface SocialLoginButtonsProps { export function SocialLoginButtons({ githubAvailable, googleAvailable, + microsoftAvailable, callbackURL = '/workspace', isProduction, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) const [isGoogleLoading, setIsGoogleLoading] = useState(false) + const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false) async function signInWithGithub() { if (!githubAvailable) return @@ -69,6 +72,29 @@ export function SocialLoginButtons({ } } + async function signInWithMicrosoft() { + if (!microsoftAvailable) return + + setIsMicrosoftLoading(true) + try { + await client.signIn.social({ provider: 'microsoft', callbackURL }) + } catch (err: any) { + let errorMessage = 'Failed to sign in with Microsoft' + + if (err.message?.includes('account exists')) { + errorMessage = 'An account with this email already exists. Please sign in instead.' + } else if (err.message?.includes('cancelled')) { + errorMessage = 'Microsoft sign in was cancelled. Please try again.' + } else if (err.message?.includes('network')) { + errorMessage = 'Network error. Please check your connection and try again.' + } else if (err.message?.includes('rate limit')) { + errorMessage = 'Too many attempts. Please try again later.' + } + } finally { + setIsMicrosoftLoading(false) + } + } + const githubButton = (
{googleAvailable && googleButton} + {microsoftAvailable && microsoftButton} {githubAvailable && githubButton} {children}
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 67ac09b9461..de314167c7e 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -78,10 +78,12 @@ const validatePassword = (passwordValue: string): string[] => { export default function LoginPage({ githubAvailable, googleAvailable, + microsoftAvailable, isProduction, }: { githubAvailable: boolean googleAvailable: boolean + microsoftAvailable: boolean isProduction: boolean }) { const router = useRouter() @@ -335,7 +337,7 @@ export default function LoginPage({ const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) - const hasSocial = githubAvailable || googleAvailable + const hasSocial = githubAvailable || googleAvailable || microsoftAvailable const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial const showTopSSO = hasOnlySSO const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) @@ -483,6 +485,7 @@ export default function LoginPage({
diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 1f01e004643..1fbd4cbfd22 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { isRegistrationDisabled } from '@/lib/core/config/feature-flags' +import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' @@ -14,13 +14,16 @@ export default async function SignupPage() { return
Registration is disabled, please contact your admin.
} - const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus() + const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = + await getOAuthProviderStatus() return ( ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 90490160dff..ae73e36cb5a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -75,10 +75,18 @@ const validateEmailField = (emailValue: string): string[] => { interface SignupFormProps { githubAvailable: boolean googleAvailable: boolean + microsoftAvailable: boolean isProduction: boolean + emailSignupEnabled: boolean } -function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: SignupFormProps) { +function SignupFormContent({ + githubAvailable, + googleAvailable, + microsoftAvailable, + isProduction, + emailSignupEnabled, +}: SignupFormProps) { const router = useRouter() const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() @@ -346,6 +354,14 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S } } + const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const emailEnabled = + !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled + const hasSocial = githubAvailable || googleAvailable || microsoftAvailable + const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial + const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) + const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection + return ( <>
@@ -357,21 +373,13 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S

- {/* SSO Login Button (primary top-only when it is the only method) */} - {(() => { - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) - const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) - const hasSocial = githubAvailable || googleAvailable - const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial - return hasOnlySSO - })() && ( + {hasOnlySSO && (
)} - {/* Email/Password Form - show unless explicitly disabled */} - {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( + {emailEnabled && (
@@ -540,16 +548,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S )} - {/* Divider - show when we have multiple auth methods */} - {(() => { - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) - const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) - const hasSocial = githubAvailable || googleAvailable - const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial - const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) - const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection - return showDivider - })() && ( + {showDivider && (
@@ -562,26 +561,16 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
)} - {(() => { - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) - const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) - const hasSocial = githubAvailable || googleAvailable - const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial - const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) - return showBottomSection - })() && ( -
+ {showBottomSection && ( +
- {isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) && ( + {ssoEnabled && !hasOnlySSO && ( )} @@ -625,14 +614,18 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S export default function SignupPage({ githubAvailable, googleAvailable, + microsoftAvailable, isProduction, + emailSignupEnabled, }: SignupFormProps) { return ( Loading…
}> ) diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx index 70c5484144c..c8825186d02 100644 --- a/apps/sim/app/(auth)/verify/page.tsx +++ b/apps/sim/app/(auth)/verify/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags' +import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags' import { hasEmailService } from '@/lib/messaging/email/mailer' import { VerifyContent } from '@/app/(auth)/verify/verify-content' diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index f64ee69b34c..d0a1a985ac0 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -14,7 +14,7 @@ import { ModalTitle, ModalTrigger, } from '@/components/emcn' -import { GithubIcon, GoogleIcon } from '@/components/icons' +import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth' import { client } from '@/lib/auth/auth-client' @@ -40,6 +40,7 @@ let fetchPromise: Promise | null = null const FALLBACK_STATUS: ProviderStatus = { githubAvailable: false, googleAvailable: false, + microsoftAvailable: false, registrationDisabled: false, } @@ -49,9 +50,10 @@ const SOCIAL_BTN = function fetchProviderStatus(): Promise { if (fetchPromise) return fetchPromise fetchPromise = requestJson(getAuthProvidersContract, {}) - .then(({ githubAvailable, googleAvailable, registrationDisabled }) => ({ + .then(({ githubAvailable, googleAvailable, microsoftAvailable, registrationDisabled }) => ({ githubAvailable, googleAvailable, + microsoftAvailable, registrationDisabled, })) .catch(() => { @@ -66,14 +68,17 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal const [open, setOpen] = useState(false) const [view, setView] = useState(defaultView) const [providerStatus, setProviderStatus] = useState(null) - const [socialLoading, setSocialLoading] = useState<'github' | 'google' | null>(null) + const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'microsoft' | null>(null) const brand = useMemo(() => getBrandConfig(), []) useEffect(() => { fetchProviderStatus().then(setProviderStatus) }, []) - const hasSocial = providerStatus?.githubAvailable || providerStatus?.googleAvailable + const hasSocial = + providerStatus?.githubAvailable || + providerStatus?.googleAvailable || + providerStatus?.microsoftAvailable const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) const hasModalContent = hasSocial || ssoEnabled @@ -104,7 +109,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal } } - async function handleSocialLogin(provider: 'github' | 'google') { + async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') { setSocialLoading(provider) try { await client.signIn.social({ provider, callbackURL: '/workspace' }) @@ -184,6 +189,19 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal )} + {providerStatus.microsoftAvailable && ( + + )} {providerStatus.githubAvailable && ( + + +

Open in scheduled tasks

+
+ + ) +} + interface EmbeddedLogProps { workspaceId: string logId: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index d41ba2a8770..3d0df4475f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -3,6 +3,7 @@ import type { ElementType, ReactNode } from 'react' import type { QueryClient } from '@tanstack/react-query' import { + Calendar, Connections, Database, File as FileIcon, @@ -23,6 +24,7 @@ import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { scheduleKeys } from '@/hooks/queries/schedules' import { tableKeys } from '@/hooks/queries/tables' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' @@ -183,6 +185,15 @@ export const RESOURCE_REGISTRY: Record , }, + scheduledtask: { + type: 'scheduledtask', + label: 'Scheduled Tasks', + icon: Calendar, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, log: { type: 'log', label: 'Logs', @@ -241,6 +252,9 @@ const RESOURCE_INVALIDATORS: Record< task: (qc, wId) => { qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) }) }, + scheduledtask: (qc, wId) => { + qc.invalidateQueries({ queryKey: scheduleKeys.list(wId) }) + }, log: (qc, _wId, id) => { qc.invalidateQueries({ queryKey: logKeys.details() }) qc.invalidateQueries({ queryKey: logKeys.detail(id) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 443c1222892..43cdbef772a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -27,6 +27,7 @@ const PORTABLE_KIND_TO_ID_FIELD = { file: 'fileId', folder: 'folderId', filefolder: 'fileFolderId', + scheduledtask: 'scheduleId', knowledge: 'knowledgeId', past_chat: 'chatId', workflow: 'workflowId', @@ -207,6 +208,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext { return { kind: 'folder', folderId: link.id, label: link.label } case 'filefolder': return { kind: 'filefolder', fileFolderId: link.id, label: link.label } + case 'scheduledtask': + return { kind: 'scheduledtask', scheduleId: link.id, label: link.label } case 'knowledge': return { kind: 'knowledge', knowledgeId: link.id, label: link.label } case 'past_chat': diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index a1065032200..645ace30c60 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -112,6 +112,7 @@ const RESOURCE_TO_CONTEXT: Record< task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }), log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }), integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }), + scheduledtask: (r) => ({ kind: 'scheduledtask', scheduleId: r.id, label: r.title }), generic: (r) => ({ kind: 'docs', label: r.title }), } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 2c438e0c858..9209614ec4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -18,10 +18,10 @@ import { ManageCredentialOperation, ManageCustomTool, ManageCustomToolOperation, - ManageJob, - ManageJobOperation, ManageMcpTool, ManageMcpToolOperation, + ManageScheduledTask, + ManageScheduledTaskOperation, ManageSkill, ManageSkillOperation, MoveFolder, @@ -345,17 +345,17 @@ export function resolveToolDisplayTitle( ) } - if (name === ManageJob.id) { + if (name === ManageScheduledTask.id) { return resolveOperationDisplayTitle( args.operation, { - [ManageJobOperation.create]: 'Creating job', - [ManageJobOperation.get]: 'Getting job', - [ManageJobOperation.update]: 'Updating job', - [ManageJobOperation.delete]: 'Deleting job', - [ManageJobOperation.list]: 'Listing jobs', + [ManageScheduledTaskOperation.create]: 'Creating scheduled task', + [ManageScheduledTaskOperation.get]: 'Getting scheduled task', + [ManageScheduledTaskOperation.update]: 'Updating scheduled task', + [ManageScheduledTaskOperation.delete]: 'Deleting scheduled task', + [ManageScheduledTaskOperation.list]: 'Listing scheduled tasks', }, - 'Job action' + 'Scheduled task action' ) } @@ -496,17 +496,17 @@ export function resolveStreamingToolDisplayTitle( ) } - if (name === ManageJob.id) { + if (name === ManageScheduledTask.id) { return resolveOperationDisplayTitle( matchStreamingStringArg(streamingArgs, 'operation'), { - [ManageJobOperation.create]: 'Creating job', - [ManageJobOperation.get]: 'Getting job', - [ManageJobOperation.update]: 'Updating job', - [ManageJobOperation.delete]: 'Deleting job', - [ManageJobOperation.list]: 'Listing jobs', + [ManageScheduledTaskOperation.create]: 'Creating scheduled task', + [ManageScheduledTaskOperation.get]: 'Getting scheduled task', + [ManageScheduledTaskOperation.update]: 'Updating scheduled task', + [ManageScheduledTaskOperation.delete]: 'Deleting scheduled task', + [ManageScheduledTaskOperation.list]: 'Listing scheduled tasks', }, - 'Job action' + 'Scheduled task action' ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5374c77b416..dc5ae5d86d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -294,6 +294,8 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.folderId === 'string' case 'filefolder': return typeof value.fileFolderId === 'string' + case 'scheduledtask': + return typeof value.scheduleId === 'string' case 'docs': return true case 'slash_command': diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 30307e4ca31..531a5a18639 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -12,7 +12,6 @@ import { GetPageContents, Glob, Grep, - Job, Knowledge, KnowledgeBase, ManageMcpTool, @@ -21,6 +20,7 @@ import { OpenResource, Read as ReadTool, Research, + ScheduledTask, ScrapePage, SearchLibraryDocs, SearchOnline, @@ -193,6 +193,8 @@ export const SUBAGENT_LABELS: Record = { superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', + scheduled_task: 'Scheduled Task Agent', + // `job` retained as a backward-compat alias so historical transcripts still render a label. job: 'Job Agent', file: 'File Agent', media: 'Media Agent', @@ -230,7 +232,8 @@ export const TOOL_UI_METADATA: Record = { [Knowledge.id]: { title: 'Knowledge Agent' }, [KnowledgeBase.id]: { title: 'Managing knowledge base' }, [Table.id]: { title: 'Table Agent' }, - [Job.id]: { title: 'Job Agent' }, + [ScheduledTask.id]: { title: 'Scheduled Task Agent' }, + job: { title: 'Job Agent' }, [Agent.id]: { title: 'Tools Agent' }, custom_tool: { title: 'Creating tool' }, [Research.id]: { title: 'Research Agent' }, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index f62abbd9227..2266d160c7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -35,7 +35,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useBulkChunkOperation, @@ -278,7 +278,7 @@ export function Document({ const knowledgeBaseCrumbLabel = knowledgeBase?.name || knowledgeBaseName || '…' const documentCrumbLabel = documentData?.filename || documentName || '…' const ConnectorIcon = documentData?.connectorType - ? CONNECTOR_REGISTRY[documentData.connectorType]?.icon + ? CONNECTOR_META_REGISTRY[documentData.connectorType]?.icon : null const DocumentIcon = ConnectorIcon || getDocumentIcon(documentData?.mimeType ?? '', effectiveDocumentName) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 095979ed277..51572bc459e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -61,7 +61,7 @@ import { import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBase, useKnowledgeBaseDocuments, @@ -926,7 +926,7 @@ export function KnowledgeBase({ connectors.length > 0 ? ( <> {connectors.map((connector) => { - const def = CONNECTOR_REGISTRY[connector.connectorType] + const def = CONNECTOR_META_REGISTRY[connector.connectorType] const ConnectorIcon = def?.icon return (
-
+
-

- {viewingGroup.name} -

+

{viewingGroup.name}

{viewingGroup.description && ( -

{viewingGroup.description}

+

{viewingGroup.description}

)}
-
-
- - Default group - + +
Applies to everyone in the organization not assigned to another group, including external workspace members + handleToggleDefault(checked)} + disabled={updatePermissionGroup.isPending} + />
- handleToggleDefault(checked)} - disabled={updatePermissionGroup.isPending} - /> -
+ + + +
+ + {viewingGroup.appliesToAllWorkspaces + ? 'Governs every workspace in the organization' + : viewingGroup.workspaces.length > 0 + ? `Governs ${viewingGroup.workspaces.length} workspace${ + viewingGroup.workspaces.length === 1 ? '' : 's' + }: ${viewingGroup.workspaces.map((ws) => ws.name).join(', ')}` + : 'No workspaces selected — this group governs nobody'} + + {viewingGroup.isDefault ? ( + + + + {}} + options={workspaceOptions} + disabled + className='pointer-events-none' + /> + + + + The default group always applies to all workspaces + + + ) : ( + ws.id) + } + onChange={handleScopeChange} + options={workspaceOptions} + isLoading={workspacesLoading} + className='flex-shrink-0' + /> + )} +
+
-
-
- Members - + Add -
- + } + > {membersLoading ? ( -
+
{[1, 2].map((i) => ( -
-
- -
- - -
-
+
+ +
))}
) : members.length === 0 ? ( -

+

No members yet. Click "Add" to get started. -

+
) : ( -
- {members.map((member) => { - const name = member.userName || 'Unknown' - const avatarInitial = name.charAt(0).toUpperCase() - - return ( -
-
- - {member.userImage && ( - - )} - + {members.map((member) => ( + + +
- - handleRemoveMember(member.id)} - disabled={removeMember.isPending} - className='flex-shrink-0' - > - Remove - -
- ) - })} + + + + + handleRemoveMember(member.id)} + > + Remove + + + + } + /> + ))}
)} -
+
@@ -1468,51 +1647,68 @@ export function AccessControl() {
-
- setSearchTerm(e.target.value)} - /> +
+
+

Access Control

+

+ Manage permission groups across every workspace in your organization. +

+
- {filteredGroups.length === 0 && searchTerm.trim() ? ( -
- No results found matching "{searchTerm}" -
- ) : permissionGroups.length === 0 ? ( -
- Click "Create Group" above to get started -
- ) : ( -
- {filteredGroups.map((group) => ( -
- - - ))} -
- )} + + + ))} +
+ )} +
@@ -1547,13 +1743,27 @@ export function AccessControl() { setNewGroupIsDefault(checked === true)} + onCheckedChange={(checked) => { + const isDefault = checked === true + setNewGroupIsDefault(isDefault) + if (isDefault) setNewGroupWorkspaceIds([]) + }} />
+ + + {createError} [...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const, + orgWorkspaces: (organizationId?: string) => + [...permissionGroupKeys.all, 'orgWorkspaces', organizationId ?? ''] as const, } export function usePermissionGroups(organizationId?: string, enabled = true) { @@ -65,6 +74,22 @@ export function usePermissionGroupMembers(organizationId?: string, permissionGro }) } +export function useOrganizationWorkspaces(organizationId?: string, enabled = true) { + return useQuery({ + queryKey: permissionGroupKeys.orgWorkspaces(organizationId), + queryFn: async ({ signal }) => { + if (!organizationId) return [] + const data = await requestJson(listOrganizationWorkspacesContract, { + params: { id: organizationId }, + signal, + }) + return data.workspaces + }, + enabled: Boolean(organizationId) && enabled, + staleTime: 60 * 1000, + }) +} + export function useUserPermissionConfig(workspaceId?: string) { return useQuery({ queryKey: permissionGroupKeys.userConfig(workspaceId), @@ -86,6 +111,8 @@ export interface CreatePermissionGroupData { description?: string config?: Partial isDefault?: boolean + appliesToAllWorkspaces?: boolean + workspaceIds?: string[] } export function useCreatePermissionGroup() { @@ -113,6 +140,8 @@ export interface UpdatePermissionGroupData { description?: string | null config?: Partial isDefault?: boolean + appliesToAllWorkspaces?: boolean + workspaceIds?: string[] } export function useUpdatePermissionGroup() { diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index a28fd7ae417..467a46d851b 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -11,6 +11,7 @@ const { mockGetProviderFromModel, mockGetBlock, mockExplicitGroup, + mockAllWorkspacesGroup, mockDefaultGroup, } = vi.hoisted(() => ({ DEFAULT_PERMISSION_GROUP_CONFIG: { @@ -42,30 +43,53 @@ const { mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), mockGetProviderFromModel: vi.fn<(model: string) => string>(), mockGetBlock: vi.fn<(type: string) => { hideFromToolbar?: boolean } | undefined>(), - // The explicit-group query joins permission_group_member -> permission_group; - // the org-default query selects permission_group directly. The db mock returns - // the explicit rows when `innerJoin` was called and the default rows otherwise. - mockExplicitGroup: { value: [] as Array<{ config: Record }> }, + // resolveWorkspaceGroup joins member -> group -> LEFT JOIN group_workspace and + // awaits the `where` directly (no limit). resolveOrganizationWideGroup joins + // member -> group (inner only) with limit. resolveDefaultGroup selects group + // directly with limit. The db mock branches on which joins were used: + // leftJoin -> mockExplicitGroup (the user's group rows) + // innerJoin (no left) -> mockAllWorkspacesGroup (org-wide member group) + // no join -> mockDefaultGroup (the org default) + mockExplicitGroup: { + value: [] as Array<{ + id?: string + name?: string + config: Record + appliesToAllWorkspaces?: boolean + targetsWorkspace?: string | null + }>, + }, + mockAllWorkspacesGroup: { value: [] as Array<{ config: Record }> }, mockDefaultGroup: { value: [] as Array<{ config: Record }> }, })) vi.mock('@sim/db', () => ({ db: { select: vi.fn().mockImplementation(() => { - const chain: Record = {} let usedInnerJoin = false + let usedLeftJoin = false + const resolveRows = () => { + if (usedLeftJoin) return mockExplicitGroup.value + if (usedInnerJoin) return mockAllWorkspacesGroup.value + return mockDefaultGroup.value + } + const chain: Record = {} chain.from = vi.fn().mockReturnValue(chain) chain.innerJoin = vi.fn().mockImplementation(() => { usedInnerJoin = true return chain }) + chain.leftJoin = vi.fn().mockImplementation(() => { + usedLeftJoin = true + return chain + }) chain.where = vi.fn().mockReturnValue(chain) chain.orderBy = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => - Promise.resolve(usedInnerJoin ? mockExplicitGroup.value : mockDefaultGroup.value) - ) + chain.limit = vi.fn().mockImplementation(() => Promise.resolve(resolveRows())) + // resolveWorkspaceGroup awaits the builder directly after `where` (no limit), + // so the chain must be thenable. + chain.then = (onFulfilled: (rows: unknown) => unknown) => + Promise.resolve(resolveRows()).then(onFulfilled) return chain }), }, @@ -74,6 +98,7 @@ vi.mock('@sim/db', () => ({ vi.mock('@sim/db/schema', () => ({ permissionGroup: {}, permissionGroupMember: {}, + permissionGroupWorkspace: {}, })) vi.mock('drizzle-orm', () => ({ @@ -90,7 +115,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv, isAccessControlEnabled: true, isHosted: true, @@ -164,6 +189,7 @@ describe('getUserPermissionConfig (org-scoped resolution)', () => { beforeEach(() => { vi.clearAllMocks() mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) }) @@ -227,6 +253,7 @@ describe('getUserPermissionConfig (org-scoped resolution)', () => { it('returns null when there is no explicit group and no default group', async () => { setEnterpriseOrgWorkspace() mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -246,10 +273,90 @@ describe('getUserPermissionConfig (org-scoped resolution)', () => { }) }) +describe('getUserPermissionConfig (workspace-scope precedence)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] + mockDefaultGroup.value = [] + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + setEnterpriseOrgWorkspace() + }) + + it('prefers a specific group covering the workspace over an all-workspaces group', async () => { + mockExplicitGroup.value = [ + { + id: 'all', + name: 'All', + config: { disableMcpTools: true }, + appliesToAllWorkspaces: true, + targetsWorkspace: null, + }, + { + id: 'specific', + name: 'Specific', + config: { disableSkills: true }, + appliesToAllWorkspaces: false, + targetsWorkspace: 'workspace-1', + }, + ] + + const config = await getUserPermissionConfig('user-123', 'workspace-1') + + expect(config?.disableSkills).toBe(true) + expect(config?.disableMcpTools).toBe(false) + }) + + it('uses the all-workspaces group when no specific group covers the workspace', async () => { + mockExplicitGroup.value = [ + { + id: 'all', + name: 'All', + config: { disableMcpTools: true }, + appliesToAllWorkspaces: true, + targetsWorkspace: null, + }, + { + // A specific group the user is in, but it does not target this workspace + // (left join produced no row, so targetsWorkspace is null). + id: 'specific-other', + name: 'Specific Other', + config: { disableSkills: true }, + appliesToAllWorkspaces: false, + targetsWorkspace: null, + }, + ] + + const config = await getUserPermissionConfig('user-123', 'workspace-1') + + expect(config?.disableMcpTools).toBe(true) + expect(config?.disableSkills).toBe(false) + }) + + it('falls back to the org default when the user has only a non-covering specific group', async () => { + mockExplicitGroup.value = [ + { + id: 'specific-other', + name: 'Specific Other', + config: { disableSkills: true }, + appliesToAllWorkspaces: false, + targetsWorkspace: null, + }, + ] + mockDefaultGroup.value = [{ config: { disableCustomTools: true } }] + + const config = await getUserPermissionConfig('user-123', 'workspace-1') + + expect(config?.disableCustomTools).toBe(true) + expect(config?.disableSkills).toBe(false) + }) +}) + describe('validateBlockType', () => { beforeEach(() => { vi.clearAllMocks() mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] }) @@ -327,6 +434,7 @@ describe('validateModelProvider', () => { beforeEach(() => { vi.clearAllMocks() mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() @@ -402,6 +510,7 @@ describe('validateMcpToolsAllowed', () => { beforeEach(() => { vi.clearAllMocks() mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() @@ -426,6 +535,7 @@ describe('assertPermissionsAllowed', () => { beforeEach(() => { vi.clearAllMocks() mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() @@ -508,6 +618,7 @@ describe('assertPermissionsAllowed', () => { it('passes when the workspace has no blocking config', async () => { mockExplicitGroup.value = [] + mockAllWorkspacesGroup.value = [] mockDefaultGroup.value = [] await assertPermissionsAllowed({ diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 7d304ea42a2..1fbc647435a 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { permissionGroup, permissionGroupMember } from '@sim/db/schema' +import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, asc, eq } from 'drizzle-orm' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { getAllowedIntegrationsFromEnv, @@ -9,7 +9,7 @@ import { isHosted, isInvitationsDisabled, isPublicApiDisabled, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, @@ -112,56 +112,149 @@ function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGrou } /** - * Resolve the raw (pre-env-merge) permission-group config that governs `userId` - * within `organizationId`. Resolution is deterministic with no workspace-level - * fallbacks: - * 1. the user's explicitly assigned group in the organization, else - * 2. the organization's default group (`isDefault`), which also governs - * external members operating in the org's workspaces, else - * 3. `null` (unrestricted). + * The permission group that governs a user in a given context, with its parsed + * config. Shared by the executor path and the `/api/permission-groups/user` + * route so resolution never drifts between the two. + */ +export interface ResolvedPermissionGroup { + permissionGroupId: string + groupName: string + config: PermissionGroupConfig +} + +/** The organization's single default group (`isDefault`), or `null`. */ +async function resolveDefaultGroup( + organizationId: string +): Promise { + const [defaultGroup] = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + config: permissionGroup.config, + }) + .from(permissionGroup) + .where( + and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true)) + ) + .limit(1) + + if (!defaultGroup) { + return null + } + + return { + permissionGroupId: defaultGroup.id, + groupName: defaultGroup.name, + config: parsePermissionGroupConfig(defaultGroup.config), + } +} + +/** + * Resolve the group governing `userId` in `workspaceId` (which belongs to + * `organizationId`). Deterministic precedence, one effective group per + * workspace: + * 1. a specific-scope group the user is in that targets this workspace, else + * 2. the user's all-workspaces group, else + * 3. the organization's default group (also governs external members), else + * 4. `null` (unrestricted). + * + * Specific-scope groups a user belongs to should not overlap on a workspace, + * and a user should belong to at most one all-workspaces group (enforced at + * assignment time, though not by a DB constraint). If an overlap nonetheless + * exists, the oldest group wins — rows are ordered by `created_at` (then `id`) + * so resolution is deterministic. * - * Callers are responsible for the enterprise-entitlement gate before invoking - * this and for merging the env allowlist afterwards. + * Callers gate on enterprise entitlement before invoking this and merge the env + * allowlist afterwards. */ -async function resolveOrganizationGroupConfig( +export async function resolveWorkspaceGroup( userId: string, - organizationId: string -): Promise { - const [explicit] = await db - .select({ config: permissionGroup.config }) + organizationId: string, + workspaceId: string +): Promise { + const rows = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + config: permissionGroup.config, + appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces, + // Non-null only when this group has a specific row targeting the workspace. + targetsWorkspace: permissionGroupWorkspace.workspaceId, + }) .from(permissionGroupMember) .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) + .leftJoin( + permissionGroupWorkspace, + and( + eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id), + eq(permissionGroupWorkspace.workspaceId, workspaceId) + ) + ) .where( and( eq(permissionGroupMember.userId, userId), eq(permissionGroupMember.organizationId, organizationId) ) ) - .limit(1) + .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) + + const specific = rows.find((row) => !row.appliesToAllWorkspaces && row.targetsWorkspace !== null) + const winner = specific ?? rows.find((row) => row.appliesToAllWorkspaces) - if (explicit) { - return parsePermissionGroupConfig(explicit.config) + if (winner) { + return { + permissionGroupId: winner.id, + groupName: winner.name, + config: parsePermissionGroupConfig(winner.config), + } } - const [defaultGroup] = await db - .select({ config: permissionGroup.config }) - .from(permissionGroup) + return resolveDefaultGroup(organizationId) +} + +/** + * Organization-level resolution (no specific workspace in context, e.g. + * organization-wide invitations): the user's all-workspaces group, else the + * organization default. Specific-scope groups require a workspace and therefore + * do not gate organization-level actions. + */ +export async function resolveOrganizationWideGroup( + userId: string, + organizationId: string +): Promise { + const [allWorkspacesGroup] = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + config: permissionGroup.config, + }) + .from(permissionGroupMember) + .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) .where( - and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true)) + and( + eq(permissionGroupMember.userId, userId), + eq(permissionGroupMember.organizationId, organizationId), + eq(permissionGroup.appliesToAllWorkspaces, true) + ) ) + .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) .limit(1) - if (defaultGroup) { - return parsePermissionGroupConfig(defaultGroup.config) + if (allWorkspacesGroup) { + return { + permissionGroupId: allWorkspacesGroup.id, + groupName: allWorkspacesGroup.name, + config: parsePermissionGroupConfig(allWorkspacesGroup.config), + } } - return null + return resolveDefaultGroup(organizationId) } /** * Resolve the effective permission-group config for a user in the context of a * specific workspace. The workspace is mapped to its organization and the - * org-scoped config is resolved (explicit group -> org default -> none). + * governing group is resolved with specific-over-all precedence. * * Returns `null` (after env merge) when the workspace has no organization, the * organization isn't on an enterprise plan, or no group governs the user. @@ -182,13 +275,19 @@ export async function getUserPermissionConfig( return mergeEnvAllowlist(null) } - return getUserPermissionConfigForOrganization(userId, ws.organizationId) + const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId) + if (!isEnterprise) { + return mergeEnvAllowlist(null) + } + + const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId) + return mergeEnvAllowlist(resolved?.config ?? null) } /** - * Org-addressed variant of {@link getUserPermissionConfig}. Use when the - * organization id is already known (e.g. organization-level invitations) so no - * workspace -> organization lookup is needed. + * Org-addressed variant of {@link getUserPermissionConfig}. Use when only the + * organization is known (e.g. organization-level invitations); resolves the + * user's all-workspaces group or the org default. */ export async function getUserPermissionConfigForOrganization( userId: string, @@ -203,7 +302,8 @@ export async function getUserPermissionConfigForOrganization( return mergeEnvAllowlist(null) } - return mergeEnvAllowlist(await resolveOrganizationGroupConfig(userId, organizationId)) + const resolved = await resolveOrganizationWideGroup(userId, organizationId) + return mergeEnvAllowlist(resolved?.config ?? null) } /** diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 4e3bff54a35..5ac649517f8 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { Chip, ChipSelect, toast } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getUserRole } from '@/lib/workspaces/organization/utils' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { InfoNote } from '@/ee/components/info-note' diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index e3840b7492c..048cd0a3c96 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -19,7 +19,7 @@ import { import type { SsoRegistrationBody } from '@/lib/api/contracts/auth' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { getUserRole } from '@/lib/workspaces/organization/utils' diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index f6cf33511d3..c10ccb1e89f 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -10,7 +10,7 @@ import { Button, ChipInput, Label, Loader, toast } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { HEX_COLOR_REGEX } from '@/lib/branding' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { cn } from '@/lib/core/utils/cn' import { getUserRole } from '@/lib/workspaces/organization/utils' import { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 2d7a89c0a87..6990a719bf2 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -11,7 +11,7 @@ import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: false, isProd: false, isDev: true, diff --git a/apps/sim/executor/utils/file-tool-processor.ts b/apps/sim/executor/utils/file-tool-processor.ts index 1fa86ffc731..eabac4b35c8 100644 --- a/apps/sim/executor/utils/file-tool-processor.ts +++ b/apps/sim/executor/utils/file-tool-processor.ts @@ -138,7 +138,7 @@ export class FileToolProcessor { } if (!buffer && data.url) { - buffer = await downloadFileFromUrl(data.url) + buffer = await downloadFileFromUrl(data.url, { userId: context.userId }) } if (buffer) { diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index 479cc022bc6..3be208c2cd6 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -8,7 +8,7 @@ import { generateCopilotApiKeyContract, listCopilotApiKeysContract, } from '@/lib/api/contracts' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('CopilotKeysQuery') diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index 42108faf8ee..c3f035a0d59 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -11,6 +11,7 @@ import { deleteScheduleContract, disableScheduleContract, excludeOccurrenceContract, + getScheduleByIdContract, getScheduleContract, listWorkspaceSchedulesContract, reactivateScheduleContract, @@ -31,6 +32,7 @@ export const scheduleKeys = { details: () => [...scheduleKeys.all, 'detail'] as const, schedule: (workflowId: string, blockId: string) => [...scheduleKeys.details(), workflowId, blockId] as const, + byId: (scheduleId: string) => [...scheduleKeys.details(), scheduleId] as const, } export type ScheduleData = WorkflowScheduleRow @@ -88,6 +90,30 @@ export function useWorkspaceSchedules(workspaceId?: string) { }) } +/** + * Fetch a single schedule (job) by id. Used by the mothership resource viewer so + * opening a scheduled-task artifact does a lightweight by-id read instead of the + * whole-workspace `useWorkspaceSchedules` fetch (which contended with the chat + * stream connection and stalled start/resume). + */ +export function useScheduleById(scheduleId?: string) { + return useQuery({ + queryKey: scheduleKeys.byId(scheduleId ?? ''), + queryFn: async ({ signal }) => { + if (!scheduleId) throw new Error('Schedule ID required') + + const data = await requestJson(getScheduleByIdContract, { + params: { id: scheduleId }, + signal, + }) + return data.schedule + }, + enabled: Boolean(scheduleId), + staleTime: 30 * 1000, + placeholderData: keepPreviousData, + }) +} + /** * Hook to fetch schedule data for a workflow block */ diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index e52eb884a9e..9d536bb5276 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -83,6 +83,17 @@ function normalizeOtlpMetricsUrl(url: string): string { } } +// deployment.environment in the GO value space (dev | staging | prod) without +// any new infra env var. Every deployed Sim tier already gets +// APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we +// reuse it and map production -> prod to match Go's `OTEL_DEPLOYMENT_ENVIRONMENT` +// (and thus a single Grafana $env filter spans Sim + Go). Returns undefined when +// unset (local dev) so the OTEL_/NODE_ENV fallbacks still apply. +function deploymentEnvFromAppConfig(v: string | undefined): string | undefined { + if (!v) return undefined + return v === 'production' ? 'prod' : v +} + // Sampling ratio from env (mirrors Go's `samplerFromEnv`); fallback // is 100% everywhere. Retention caps cost, not sampling. function resolveSamplingRatio(_isLocalEndpoint: boolean): number { @@ -270,11 +281,15 @@ async function initializeOpenTelemetry() { resourceFromAttributes({ [ATTR_SERVICE_NAME]: telemetryConfig.serviceName, [ATTR_SERVICE_VERSION]: telemetryConfig.serviceVersion, - // OTEL_ → DEPLOYMENT_ENVIRONMENT → NODE_ENV; matches Go's - // `resourceEnvFromEnv()` so both halves tag the same value. + // OTEL_ → DEPLOYMENT_ENVIRONMENT → APPCONFIG_ENVIRONMENT (mapped to the + // Go value space) → NODE_ENV. Matches Go's `resourceEnvFromEnv()` so a + // single $env spans Sim + Go. APPCONFIG_ENVIRONMENT (already set on every + // deployed tier) is the fix that stops deployed Sim tagging everything + // "production" via the NODE_ENV fallback — no new infra env var needed. [ATTR_DEPLOYMENT_ENVIRONMENT]: process.env.OTEL_DEPLOYMENT_ENVIRONMENT || process.env.DEPLOYMENT_ENVIRONMENT || + deploymentEnvFromAppConfig(process.env.APPCONFIG_ENVIRONMENT) || env.NODE_ENV || 'development', 'service.namespace': 'mothership', diff --git a/apps/sim/lib/a2a/push-notifications.ts b/apps/sim/lib/a2a/push-notifications.ts index 4413b569fbf..016e993c4ac 100644 --- a/apps/sim/lib/a2a/push-notifications.ts +++ b/apps/sim/lib/a2a/push-notifications.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { secureFetchWithPinnedIP, validateUrlWithDNS, diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index eedcb955727..ff8c568e14d 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -7,7 +7,7 @@ */ import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { getClientIp } from '@/lib/core/utils/request' import { getBaseDomain } from '@/lib/core/utils/urls' diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 65e0f80362d..6c1fcba13f0 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/core/config/env', () => ({ env: {}, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: false, })) diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index b0b19a9a491..b131d2f7425 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { and, asc, eq } from 'drizzle-orm' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { decryptSecret } from '@/lib/core/security/encryption' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { getHostedModels } from '@/providers/models' diff --git a/apps/sim/lib/api/contracts/auth.ts b/apps/sim/lib/api/contracts/auth.ts index 1a95e7cd620..8c47ce7d684 100644 --- a/apps/sim/lib/api/contracts/auth.ts +++ b/apps/sim/lib/api/contracts/auth.ts @@ -9,6 +9,7 @@ export const ssoProvidersQuerySchema = z.object({ export const authProviderStatusResponseSchema = z.object({ githubAvailable: z.boolean(), googleAvailable: z.boolean(), + microsoftAvailable: z.boolean(), registrationDisabled: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 3da7fdd6d01..f522728f647 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -92,6 +92,7 @@ const copilotResourceTypeSchema = z.enum([ 'workflow', 'knowledgebase', 'folder', + 'scheduledtask', 'log', ]) diff --git a/apps/sim/lib/api/contracts/permission-groups.test.ts b/apps/sim/lib/api/contracts/permission-groups.test.ts new file mode 100644 index 00000000000..b8f506631c4 --- /dev/null +++ b/apps/sim/lib/api/contracts/permission-groups.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createPermissionGroupBodySchema, + updatePermissionGroupBodySchema, +} from '@/lib/api/contracts/permission-groups' + +describe('createPermissionGroupBodySchema', () => { + it('accepts a group that defaults to all workspaces', () => { + const result = createPermissionGroupBodySchema.safeParse({ name: 'Engineering' }) + expect(result.success).toBe(true) + }) + + it('accepts a specific-scope group with at least one workspace', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Contractors', + appliesToAllWorkspaces: false, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(true) + }) + + it('rejects a specific-scope group with no workspaces', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Contractors', + appliesToAllWorkspaces: false, + workspaceIds: [], + }) + expect(result.success).toBe(false) + }) + + it('rejects a specific-scope group that omits workspaceIds', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Contractors', + appliesToAllWorkspaces: false, + }) + expect(result.success).toBe(false) + }) + + it('rejects a default group that targets specific workspaces', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Baseline', + isDefault: true, + appliesToAllWorkspaces: false, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(false) + }) + + it('accepts a default group that applies to all workspaces', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Baseline', + isDefault: true, + appliesToAllWorkspaces: true, + }) + expect(result.success).toBe(true) + }) + + it('rejects a default group with workspaceIds (appliesToAllWorkspaces omitted)', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Baseline', + isDefault: true, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(false) + }) + + it('rejects an all-workspaces group that also names specific workspaces', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Engineering', + appliesToAllWorkspaces: true, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(false) + }) +}) + +describe('updatePermissionGroupBodySchema', () => { + it('accepts an empty update', () => { + expect(updatePermissionGroupBodySchema.safeParse({}).success).toBe(true) + }) + + it('rejects switching to specific scope with no workspaces', () => { + const result = updatePermissionGroupBodySchema.safeParse({ + appliesToAllWorkspaces: false, + workspaceIds: [], + }) + expect(result.success).toBe(false) + }) + + it('accepts switching to specific scope with workspaces', () => { + const result = updatePermissionGroupBodySchema.safeParse({ + appliesToAllWorkspaces: false, + workspaceIds: ['ws-1', 'ws-2'], + }) + expect(result.success).toBe(true) + }) + + it('rejects making a specific-scope group the default', () => { + const result = updatePermissionGroupBodySchema.safeParse({ + isDefault: true, + appliesToAllWorkspaces: false, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(false) + }) + + it('rejects workspaceIds when making the group the default', () => { + const result = updatePermissionGroupBodySchema.safeParse({ + isDefault: true, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(false) + }) + + it('rejects workspaceIds on an all-workspaces update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ + appliesToAllWorkspaces: true, + workspaceIds: ['ws-1'], + }) + expect(result.success).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 8e471952494..2ea7d651ff2 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -43,6 +43,13 @@ export const permissionGroupDetailParamsSchema = z.object({ groupId: z.string().min(1), }) +/** A workspace a permission group targets (id + display name). */ +export const permissionGroupWorkspaceRefSchema = z.object({ + id: z.string(), + name: z.string(), +}) +export type PermissionGroupWorkspaceRef = z.output + export const permissionGroupSchema = z.object({ id: z.string(), name: z.string(), @@ -55,6 +62,10 @@ export const permissionGroupSchema = z.object({ creatorEmail: z.string().nullable(), memberCount: z.number(), isDefault: z.boolean(), + /** When true the group governs every workspace; when false only `workspaces`. */ + appliesToAllWorkspaces: z.boolean(), + /** Workspaces targeted when `appliesToAllWorkspaces` is false (empty otherwise). */ + workspaces: z.array(permissionGroupWorkspaceRefSchema), }) export type PermissionGroup = z.output @@ -68,6 +79,9 @@ export const permissionGroupWriteSchema = z.object({ createdAt: z.string(), updatedAt: z.string(), isDefault: z.boolean(), + appliesToAllWorkspaces: z.boolean(), + /** Ids of targeted workspaces when `appliesToAllWorkspaces` is false. */ + workspaceIds: z.array(z.string()), }) export type PermissionGroupWrite = z.output @@ -97,19 +111,73 @@ export const userPermissionConfigSchema = z.object({ }) export type UserPermissionConfig = z.output -export const createPermissionGroupBodySchema = z.object({ - name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), - config: permissionGroupConfigSchema.optional(), - isDefault: z.boolean().optional(), -}) +/** Upper bound on how many workspaces a single group can explicitly target. */ +export const MAX_PERMISSION_GROUP_WORKSPACES = 500 -export const updatePermissionGroupBodySchema = z.object({ - name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), - config: permissionGroupConfigSchema.optional(), - isDefault: z.boolean().optional(), -}) +const workspaceIdsSchema = z.array(z.string().min(1)).max(MAX_PERMISSION_GROUP_WORKSPACES) + +/** + * Enforce the workspace-scope invariants shared by create and update: + * - a specific-scope group (`appliesToAllWorkspaces === false`) must name at + * least one workspace, + * - the organization default group must apply to all workspaces, and + * - an all-workspaces or default group must not name specific workspaces + * (otherwise `workspaceIds` would be silently dropped server-side). + */ +function refineWorkspaceScope( + body: { appliesToAllWorkspaces?: boolean; workspaceIds?: string[]; isDefault?: boolean }, + ctx: z.RefinementCtx +) { + // A default group is always org-wide, and an explicit all-workspaces group has + // no specific workspaces. Reject workspaceIds in either case rather than + // silently dropping them when the scope resolves to all-workspaces. + const allWorkspaces = body.isDefault === true || body.appliesToAllWorkspaces === true + if (allWorkspaces && body.workspaceIds && body.workspaceIds.length > 0) { + ctx.addIssue({ + code: 'custom', + path: ['workspaceIds'], + message: 'workspaceIds can only be set when the group targets specific workspaces', + }) + } + if (body.appliesToAllWorkspaces === false) { + if (!body.workspaceIds || body.workspaceIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['workspaceIds'], + message: 'Select at least one workspace when the group targets specific workspaces', + }) + } + if (body.isDefault === true) { + ctx.addIssue({ + code: 'custom', + path: ['appliesToAllWorkspaces'], + message: 'The default group must apply to all workspaces', + }) + } + } +} + +export const createPermissionGroupBodySchema = z + .object({ + name: z.string().trim().min(1).max(100), + description: z.string().max(500).optional(), + config: permissionGroupConfigSchema.optional(), + isDefault: z.boolean().optional(), + appliesToAllWorkspaces: z.boolean().optional(), + workspaceIds: workspaceIdsSchema.optional(), + }) + .superRefine(refineWorkspaceScope) + +export const updatePermissionGroupBodySchema = z + .object({ + name: z.string().trim().min(1).max(100).optional(), + description: z.string().max(500).nullable().optional(), + config: permissionGroupConfigSchema.optional(), + isDefault: z.boolean().optional(), + appliesToAllWorkspaces: z.boolean().optional(), + workspaceIds: workspaceIdsSchema.optional(), + }) + .superRefine(refineWorkspaceScope) export const removePermissionGroupMemberQuerySchema = z.object({ memberId: z.string().min(1), @@ -234,7 +302,26 @@ export const bulkAddPermissionGroupMembersContract = defineRouteContract({ mode: 'json', schema: z.object({ added: z.number(), - moved: z.number(), + // Users not added because they were already in this group. A conflicting + // selection fails the whole request (409) rather than being skipped, so + // the add is all-or-nothing for conflicts. + skipped: z.number(), + }), + }, +}) + +/** + * List the workspaces belonging to an organization, used to populate the + * workspace multi-select when scoping a permission group to specific workspaces. + */ +export const listOrganizationWorkspacesContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/workspaces', + params: permissionGroupParamsSchema, + response: { + mode: 'json', + schema: z.object({ + workspaces: z.array(permissionGroupWorkspaceRefSchema), }), }, }) diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index ca48bce7643..d4eaf3d5e11 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -216,6 +216,23 @@ export const listWorkspaceSchedulesContract = defineRouteContract({ }, }) +/** + * Single-schedule read by id. Used by the mothership resource viewer so opening + * a scheduled-task artifact does a lightweight by-id fetch instead of pulling + * the entire workspace schedule list (which contended with the chat stream). + */ +export const getScheduleByIdContract = defineRouteContract({ + method: 'GET', + path: '/api/schedules/[id]', + params: scheduleIdParamsSchema, + response: { + mode: 'json', + schema: z.object({ + schedule: workflowScheduleRowSchema, + }), + }, +}) + /** * Newly-created job schedules emit a partial summary with the canonical fields * the route synthesizes server-side; everything else is filled in on diff --git a/apps/sim/lib/api/contracts/selectors/jsm.ts b/apps/sim/lib/api/contracts/selectors/jsm.ts index f8fc3c40e93..dea80b9344c 100644 --- a/apps/sim/lib/api/contracts/selectors/jsm.ts +++ b/apps/sim/lib/api/contracts/selectors/jsm.ts @@ -185,6 +185,80 @@ export const jsmCopyFormsBodySchema = jsmBaseBodySchema.extend({ formIds: z.array(z.string(), { error: 'formIds must be an array of form UUIDs' }).optional(), }) +const jsmAssetsBaseBodySchema = jsmBaseBodySchema.extend({ + workspaceId: z.string().optional(), +}) + +const jsmAssetsPaginationField = z.union([z.string(), z.number()]).optional() + +export const jsmListObjectSchemasBodySchema = jsmAssetsBaseBodySchema.extend({ + startAt: jsmAssetsPaginationField, + maxResults: jsmAssetsPaginationField, + includeCounts: z.union([z.string(), z.boolean()]).optional(), +}) + +export const jsmObjectSchemaBodySchema = jsmAssetsBaseBodySchema.extend({ + schemaId: z.string({ error: 'Schema ID is required' }).min(1, 'Schema ID is required'), +}) + +export const jsmObjectTypesBodySchema = jsmAssetsBaseBodySchema.extend({ + schemaId: z.string({ error: 'Schema ID is required' }).min(1, 'Schema ID is required'), + excludeAbstract: z.union([z.string(), z.boolean()]).optional(), +}) + +export const jsmObjectTypeAttributesBodySchema = jsmAssetsBaseBodySchema.extend({ + objectTypeId: z + .string({ error: 'Object type ID is required' }) + .min(1, 'Object type ID is required'), + onlyValueEditable: z.union([z.string(), z.boolean()]).optional(), + query: z.string().optional(), +}) + +export const jsmSearchObjectsAqlBodySchema = jsmAssetsBaseBodySchema.extend({ + qlQuery: z.string({ error: 'AQL query is required' }).min(1, 'AQL query is required'), + page: jsmAssetsPaginationField, + resultsPerPage: jsmAssetsPaginationField, + includeAttributes: z.union([z.string(), z.boolean()]).optional(), + objectTypeId: z.string().optional(), + objectSchemaId: z.string().optional(), +}) + +export const jsmGetObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'), +}) + +const jsmAssetAttributeInputSchema = z.object({ + objectTypeAttributeId: z + .string({ error: 'objectTypeAttributeId is required' }) + .min(1, 'objectTypeAttributeId is required'), + objectAttributeValues: z + .array(z.object({ value: z.unknown() }), { + error: 'objectAttributeValues must be an array of { value } entries', + }) + .min(1, 'Each attribute needs at least one value'), +}) + +export const jsmCreateObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectTypeId: z + .string({ error: 'Object type ID is required' }) + .min(1, 'Object type ID is required'), + attributes: z + .array(jsmAssetAttributeInputSchema, { error: 'attributes is required' }) + .min(1, 'At least one attribute is required'), +}) + +export const jsmUpdateObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'), + objectTypeId: z.string().optional(), + attributes: z + .array(jsmAssetAttributeInputSchema, { error: 'attributes is required' }) + .min(1, 'At least one attribute is required'), +}) + +export const jsmDeleteObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'), +}) + export const defineJsmToolContract = (path: string, body: TBody) => definePostSelector(path, body, z.unknown()) @@ -314,6 +388,43 @@ export const jsmCopyFormsContract = defineJsmToolContract( jsmCopyFormsBodySchema ) +export const jsmListObjectSchemasContract = defineJsmToolContract( + '/api/tools/jsm/assets/schemas', + jsmListObjectSchemasBodySchema +) +export const jsmGetObjectSchemaContract = defineJsmToolContract( + '/api/tools/jsm/assets/schema', + jsmObjectSchemaBodySchema +) +export const jsmListObjectTypesContract = defineJsmToolContract( + '/api/tools/jsm/assets/object-types', + jsmObjectTypesBodySchema +) +export const jsmObjectTypeAttributesContract = defineJsmToolContract( + '/api/tools/jsm/assets/attributes', + jsmObjectTypeAttributesBodySchema +) +export const jsmSearchObjectsAqlContract = defineJsmToolContract( + '/api/tools/jsm/assets/search', + jsmSearchObjectsAqlBodySchema +) +export const jsmGetObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/get', + jsmGetObjectBodySchema +) +export const jsmCreateObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/create', + jsmCreateObjectBodySchema +) +export const jsmUpdateObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/update', + jsmUpdateObjectBodySchema +) +export const jsmDeleteObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/delete', + jsmDeleteObjectBodySchema +) + export type JsmServiceDesksBody = ContractBody export type JsmQueuesBody = ContractBody export type JsmRequestTypesBody = ContractBody diff --git a/apps/sim/lib/api/contracts/tools/agiloft.ts b/apps/sim/lib/api/contracts/tools/agiloft.ts index f8c6e1f565c..9f0fd92a681 100644 --- a/apps/sim/lib/api/contracts/tools/agiloft.ts +++ b/apps/sim/lib/api/contracts/tools/agiloft.ts @@ -74,3 +74,323 @@ export type AgiloftRetrieveResponse = ContractJsonResponse export type AgiloftAttachBodyInput = ContractBodyInput export type AgiloftAttachResponse = ContractJsonResponse + +const agiloftBaseFields = { + instanceUrl: z.string().min(1, 'Instance URL is required'), + knowledgeBase: z.string().min(1, 'Knowledge base is required'), + login: z.string().min(1, 'Login is required'), + password: z.string().min(1, 'Password is required'), + table: z.string().min(1, 'Table is required'), +} as const + +export const agiloftCreateRecordBodySchema = z.object({ + ...agiloftBaseFields, + data: z.string().min(1, 'Data is required'), +}) + +export const agiloftCreateRecordResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + id: z.string().nullable(), + fields: z.record(z.string(), z.unknown()), + }), + error: z.string().optional(), +}) + +export const agiloftCreateRecordContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/create_record', + body: agiloftCreateRecordBodySchema, + response: { mode: 'json', schema: agiloftCreateRecordResponseSchema }, +}) + +export type AgiloftCreateRecordBody = ContractBody +export type AgiloftCreateRecordBodyInput = ContractBodyInput +export type AgiloftCreateRecordResponse = ContractJsonResponse + +export const agiloftReadRecordBodySchema = z.object({ + ...agiloftBaseFields, + recordId: z.string().min(1, 'Record ID is required'), + fields: z.string().optional(), +}) + +export const agiloftReadRecordResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + id: z.string().nullable(), + fields: z.record(z.string(), z.unknown()), + }), + error: z.string().optional(), +}) + +export const agiloftReadRecordContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/read_record', + body: agiloftReadRecordBodySchema, + response: { mode: 'json', schema: agiloftReadRecordResponseSchema }, +}) + +export type AgiloftReadRecordBody = ContractBody +export type AgiloftReadRecordBodyInput = ContractBodyInput +export type AgiloftReadRecordResponse = ContractJsonResponse + +export const agiloftUpdateRecordBodySchema = z.object({ + ...agiloftBaseFields, + recordId: z.string().min(1, 'Record ID is required'), + data: z.string().min(1, 'Data is required'), +}) + +export const agiloftUpdateRecordResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + id: z.string().nullable(), + fields: z.record(z.string(), z.unknown()), + }), + error: z.string().optional(), +}) + +export const agiloftUpdateRecordContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/update_record', + body: agiloftUpdateRecordBodySchema, + response: { mode: 'json', schema: agiloftUpdateRecordResponseSchema }, +}) + +export type AgiloftUpdateRecordBody = ContractBody +export type AgiloftUpdateRecordBodyInput = ContractBodyInput +export type AgiloftUpdateRecordResponse = ContractJsonResponse + +export const agiloftDeleteRecordBodySchema = z.object({ + ...agiloftBaseFields, + recordId: z.string().min(1, 'Record ID is required'), +}) + +export const agiloftDeleteRecordResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + id: z.string(), + deleted: z.boolean(), + }), + error: z.string().optional(), +}) + +export const agiloftDeleteRecordContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/delete_record', + body: agiloftDeleteRecordBodySchema, + response: { mode: 'json', schema: agiloftDeleteRecordResponseSchema }, +}) + +export type AgiloftDeleteRecordBody = ContractBody +export type AgiloftDeleteRecordBodyInput = ContractBodyInput +export type AgiloftDeleteRecordResponse = ContractJsonResponse + +export const agiloftLockRecordBodySchema = z.object({ + ...agiloftBaseFields, + recordId: z.string().min(1, 'Record ID is required'), + lockAction: z.enum(['lock', 'unlock', 'check'], { + message: 'Lock action must be "lock", "unlock", or "check"', + }), +}) + +export const agiloftLockRecordResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + id: z.string(), + lockStatus: z.string(), + lockedBy: z.string().nullable(), + lockExpiresInMinutes: z.number().nullable(), + }), + error: z.string().optional(), +}) + +export const agiloftLockRecordContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/lock_record', + body: agiloftLockRecordBodySchema, + response: { mode: 'json', schema: agiloftLockRecordResponseSchema }, +}) + +export type AgiloftLockRecordBody = ContractBody +export type AgiloftLockRecordBodyInput = ContractBodyInput +export type AgiloftLockRecordResponse = ContractJsonResponse + +export const agiloftSearchRecordsBodySchema = z.object({ + ...agiloftBaseFields, + query: z.string().min(1, 'Query is required'), + fields: z.string().optional(), + page: z.string().optional(), + limit: z.string().optional(), +}) + +export const agiloftSearchRecordsResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + records: z.array(z.record(z.string(), z.unknown())), + totalCount: z.number(), + page: z.number(), + limit: z.number(), + }), + error: z.string().optional(), +}) + +export const agiloftSearchRecordsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/search_records', + body: agiloftSearchRecordsBodySchema, + response: { mode: 'json', schema: agiloftSearchRecordsResponseSchema }, +}) + +export type AgiloftSearchRecordsBody = ContractBody +export type AgiloftSearchRecordsBodyInput = ContractBodyInput +export type AgiloftSearchRecordsResponse = ContractJsonResponse + +export const agiloftSelectRecordsBodySchema = z.object({ + ...agiloftBaseFields, + where: z.string().min(1, 'Where clause is required'), +}) + +export const agiloftSelectRecordsResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + recordIds: z.array(z.string()), + totalCount: z.number(), + }), + error: z.string().optional(), +}) + +export const agiloftSelectRecordsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/select_records', + body: agiloftSelectRecordsBodySchema, + response: { mode: 'json', schema: agiloftSelectRecordsResponseSchema }, +}) + +export type AgiloftSelectRecordsBody = ContractBody +export type AgiloftSelectRecordsBodyInput = ContractBodyInput +export type AgiloftSelectRecordsResponse = ContractJsonResponse + +export const agiloftSavedSearchBodySchema = z.object({ + ...agiloftBaseFields, +}) + +export const agiloftSavedSearchResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + searches: z.array( + z.object({ + name: z.string(), + label: z.string(), + id: z.union([z.string(), z.number()]), + description: z.string().nullable(), + }) + ), + }), + error: z.string().optional(), +}) + +export const agiloftSavedSearchContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/saved_search', + body: agiloftSavedSearchBodySchema, + response: { mode: 'json', schema: agiloftSavedSearchResponseSchema }, +}) + +export type AgiloftSavedSearchBody = ContractBody +export type AgiloftSavedSearchBodyInput = ContractBodyInput +export type AgiloftSavedSearchResponse = ContractJsonResponse + +export const agiloftAttachmentInfoBodySchema = z.object({ + ...agiloftBaseFields, + recordId: z.string().min(1, 'Record ID is required'), + fieldName: z.string().min(1, 'Field name is required'), +}) + +export const agiloftAttachmentInfoResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + attachments: z.array( + z.object({ + position: z.number(), + name: z.string(), + size: z.number(), + }) + ), + totalCount: z.number(), + }), + error: z.string().optional(), +}) + +export const agiloftAttachmentInfoContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/attachment_info', + body: agiloftAttachmentInfoBodySchema, + response: { mode: 'json', schema: agiloftAttachmentInfoResponseSchema }, +}) + +export type AgiloftAttachmentInfoBody = ContractBody +export type AgiloftAttachmentInfoBodyInput = ContractBodyInput +export type AgiloftAttachmentInfoResponse = ContractJsonResponse< + typeof agiloftAttachmentInfoContract +> + +export const agiloftRemoveAttachmentBodySchema = z.object({ + ...agiloftBaseFields, + recordId: z.string().min(1, 'Record ID is required'), + fieldName: z.string().min(1, 'Field name is required'), + position: z.string().min(1, 'Position is required'), +}) + +export const agiloftRemoveAttachmentResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + recordId: z.string(), + fieldName: z.string(), + remainingAttachments: z.number(), + }), + error: z.string().optional(), +}) + +export const agiloftRemoveAttachmentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/remove_attachment', + body: agiloftRemoveAttachmentBodySchema, + response: { mode: 'json', schema: agiloftRemoveAttachmentResponseSchema }, +}) + +export type AgiloftRemoveAttachmentBody = ContractBody +export type AgiloftRemoveAttachmentBodyInput = ContractBodyInput< + typeof agiloftRemoveAttachmentContract +> +export type AgiloftRemoveAttachmentResponse = ContractJsonResponse< + typeof agiloftRemoveAttachmentContract +> + +export const agiloftGetChoiceLineIdBodySchema = z.object({ + ...agiloftBaseFields, + fieldName: z.string().min(1, 'Field name is required'), + value: z.string().min(1, 'Value is required'), +}) + +export const agiloftGetChoiceLineIdResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ + choiceLineId: z.number().nullable(), + }), + error: z.string().optional(), +}) + +export const agiloftGetChoiceLineIdContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/agiloft/get_choice_line_id', + body: agiloftGetChoiceLineIdBodySchema, + response: { mode: 'json', schema: agiloftGetChoiceLineIdResponseSchema }, +}) + +export type AgiloftGetChoiceLineIdBody = ContractBody +export type AgiloftGetChoiceLineIdBodyInput = ContractBodyInput< + typeof agiloftGetChoiceLineIdContract +> +export type AgiloftGetChoiceLineIdResponse = ContractJsonResponse< + typeof agiloftGetChoiceLineIdContract +> diff --git a/apps/sim/lib/api/contracts/tools/grafana.ts b/apps/sim/lib/api/contracts/tools/grafana.ts new file mode 100644 index 00000000000..515a25f06bf --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/grafana.ts @@ -0,0 +1,158 @@ +import { z } from 'zod' +import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const grafanaUpdateDashboardBodySchema = z.object({ + apiKey: z.string().min(1, 'Grafana Service Account Token is required'), + baseUrl: z.string().min(1, 'Grafana instance URL is required'), + organizationId: z.string().optional(), + dashboardUid: z.string().min(1, 'Dashboard UID is required'), + title: z.string().optional(), + folderUid: z.string().optional(), + tags: z.string().optional(), + timezone: z.string().optional(), + refresh: z.string().optional(), + panels: z.string().optional(), + overwrite: z.boolean().optional(), + message: z.string().optional(), +}) + +const grafanaUpdateDashboardOutputSchema = z.object({ + id: z.number().optional(), + uid: z.string().optional(), + url: z.string().optional(), + status: z.string().optional(), + version: z.number().optional(), + slug: z.string().optional(), +}) + +export const grafanaUpdateDashboardResponseSchema = z.object({ + success: z.boolean(), + output: grafanaUpdateDashboardOutputSchema, + error: z.string().optional(), +}) + +const grafanaUpdateAlertRuleBodySchema = z.object({ + apiKey: z.string().min(1, 'Grafana Service Account Token is required'), + baseUrl: z.string().min(1, 'Grafana instance URL is required'), + organizationId: z.string().optional(), + alertRuleUid: z.string().min(1, 'Alert rule UID is required'), + title: z.string().optional(), + folderUid: z.string().optional(), + ruleGroup: z.string().optional(), + condition: z.string().optional(), + data: z.string().optional(), + forDuration: z.string().optional(), + noDataState: z.string().optional(), + execErrState: z.string().optional(), + annotations: z.string().optional(), + labels: z.string().optional(), + isPaused: z.boolean().optional(), + keepFiringFor: z.string().optional(), + missingSeriesEvalsToResolve: z.number().optional(), + notificationSettings: z.string().optional(), + record: z.string().optional(), + disableProvenance: z.boolean().optional(), +}) + +const grafanaUpdateAlertRuleOutputSchema = z.object({ + id: z.number().nullable(), + uid: z.string().nullable(), + title: z.string().nullable(), + condition: z.string().nullable(), + data: z.array(z.unknown()), + updated: z.string().nullable(), + noDataState: z.string().nullable(), + execErrState: z.string().nullable(), + for: z.string().nullable(), + keepFiringFor: z.string().nullable(), + missingSeriesEvalsToResolve: z.number().nullable(), + annotations: z.record(z.string(), z.string()), + labels: z.record(z.string(), z.string()), + isPaused: z.boolean(), + folderUID: z.string().nullable(), + ruleGroup: z.string().nullable(), + orgID: z.number().nullable(), + provenance: z.string(), + notification_settings: z.record(z.string(), z.unknown()).nullable(), + record: z.record(z.string(), z.unknown()).nullable(), +}) + +export const grafanaUpdateAlertRuleResponseSchema = z.object({ + success: z.boolean(), + output: z.union([grafanaUpdateAlertRuleOutputSchema, z.object({})]), + error: z.string().optional(), +}) + +const grafanaUpdateFolderBodySchema = z.object({ + apiKey: z.string().min(1, 'Grafana Service Account Token is required'), + baseUrl: z.string().min(1, 'Grafana instance URL is required'), + organizationId: z.string().optional(), + folderUid: z.string().min(1, 'Folder UID is required'), + title: z.string().min(1, 'Folder title is required'), +}) + +const grafanaUpdateFolderOutputSchema = z.object({ + id: z.number().nullable(), + uid: z.string().nullable(), + title: z.string().nullable(), + url: z.string().nullable(), + parentUid: z.string().nullable(), + parents: z.array(z.object({ uid: z.string(), title: z.string(), url: z.string() })), + hasAcl: z.boolean().nullable(), + canSave: z.boolean().nullable(), + canEdit: z.boolean().nullable(), + canAdmin: z.boolean().nullable(), + createdBy: z.string().nullable(), + created: z.string().nullable(), + updatedBy: z.string().nullable(), + updated: z.string().nullable(), + version: z.number().nullable(), +}) + +export const grafanaUpdateFolderResponseSchema = z.object({ + success: z.boolean(), + output: z.union([grafanaUpdateFolderOutputSchema, z.object({})]), + error: z.string().optional(), +}) + +export const grafanaUpdateDashboardContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/grafana/update_dashboard', + body: grafanaUpdateDashboardBodySchema, + response: { mode: 'json', schema: grafanaUpdateDashboardResponseSchema }, +}) + +export const grafanaUpdateAlertRuleContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/grafana/update_alert_rule', + body: grafanaUpdateAlertRuleBodySchema, + response: { mode: 'json', schema: grafanaUpdateAlertRuleResponseSchema }, +}) + +export const grafanaUpdateFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/grafana/update_folder', + body: grafanaUpdateFolderBodySchema, + response: { mode: 'json', schema: grafanaUpdateFolderResponseSchema }, +}) + +export { + grafanaUpdateDashboardBodySchema, + grafanaUpdateDashboardOutputSchema, + grafanaUpdateAlertRuleBodySchema, + grafanaUpdateAlertRuleOutputSchema, + grafanaUpdateFolderBodySchema, + grafanaUpdateFolderOutputSchema, +} + +export type GrafanaUpdateDashboardBody = ContractBody +export type GrafanaUpdateDashboardResponse = ContractJsonResponse< + typeof grafanaUpdateDashboardContract +> +export type GrafanaUpdateAlertRuleBody = ContractBody +export type GrafanaUpdateAlertRuleResponse = ContractJsonResponse< + typeof grafanaUpdateAlertRuleContract +> +export type GrafanaUpdateFolderBody = ContractBody +export type GrafanaUpdateFolderResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index decadf189c6..3270a34ef99 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -15,6 +15,7 @@ export * from './file' export * from './firecrawl' export * from './github' export * from './google' +export * from './grafana' export * from './imap' export * from './latex' export * from './mail' diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index 8e685d3f7ac..5667f724ca6 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isAppConfigEnabled() { return flagRef.isAppConfigEnabled }, diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts index c5972590296..a460257e299 100644 --- a/apps/sim/lib/auth/access-control.ts +++ b/apps/sim/lib/auth/access-control.ts @@ -1,7 +1,7 @@ import { normalizeEmail } from '@sim/utils/string' import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' import { env } from '@/lib/core/config/env' -import { isAppConfigEnabled } from '@/lib/core/config/feature-flags' +import { isAppConfigEnabled } from '@/lib/core/config/env-flags' /** * Name of the AppConfig configuration profile holding the signup/login gating diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index 1a969c7cd19..b1ca36b84d2 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -11,7 +11,7 @@ import { import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' import { env } from '@/lib/core/config/env' -import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls' import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider' diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index c43a7f56ce5..9b0f4523a42 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -65,16 +65,18 @@ import { isAuthDisabled, isBillingEnabled, isEmailPasswordEnabled, + isEmailSignupDisabled, isEmailVerificationEnabled, isGithubAuthDisabled, isGoogleAuthDisabled, isHosted, + isMicrosoftAuthDisabled, isOrganizationsEnabled, isRegistrationDisabled, isSignupEmailValidationEnabled, isSignupMxValidationEnabled, isSsoEnabled, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' @@ -724,6 +726,15 @@ export const auth = betterAuth({ ], }, }), + ...(!isMicrosoftAuthDisabled && + env.MICROSOFT_CLIENT_ID && + env.MICROSOFT_CLIENT_SECRET && { + microsoft: { + clientId: env.MICROSOFT_CLIENT_ID, + clientSecret: env.MICROSOFT_CLIENT_SECRET, + scope: ['openid', 'profile', 'email'], + }, + }), }, emailVerification: { autoSignInAfterVerification: true, @@ -874,6 +885,11 @@ export const auth = betterAuth({ }) } + if (isEmailSignupDisabled && ctx.path.startsWith('/sign-up/email')) + throw new APIError('FORBIDDEN', { + message: 'Email sign-up is disabled. Please use Google, Microsoft, or GitHub.', + }) + const isSignIn = ctx.path.startsWith('/sign-in') const isSignUp = ctx.path.startsWith('/sign-up') diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index f6e53aa9bb0..7a38b914294 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -22,7 +22,7 @@ vi.mock('@/lib/core/config/env', () => ({ return envRef }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false })) +vi.mock('@/lib/core/config/env-flags', () => ({ isAppConfigEnabled: false })) import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index d78d984d9ad..b59dd045a13 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -11,7 +11,7 @@ const { mockFlags, mockDbLimit, mockGetOrgMemberUsageLimit, mockGetOrgMemberWork mockGetOrgMemberWorkspaceUsage: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockFlags.isHosted }, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index df6e7d7fda3..906007689f8 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -22,7 +22,7 @@ import { import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' -import { isBillingEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('UsageMonitor') diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts index a6435aff69c..11a93055003 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -8,7 +8,7 @@ const { mockFlags } = vi.hoisted(() => ({ mockFlags: { isBillingEnabled: true }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/calculations/usage-reservation.ts b/apps/sim/lib/billing/calculations/usage-reservation.ts index c448c88776d..a66e9e43f7f 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' diff --git a/apps/sim/lib/billing/core/api-access.test.ts b/apps/sim/lib/billing/core/api-access.test.ts index fd5bfe8d92a..fc1341d1c0a 100644 --- a/apps/sim/lib/billing/core/api-access.test.ts +++ b/apps/sim/lib/billing/core/api-access.test.ts @@ -10,7 +10,7 @@ const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, billingState: { isBillingEnabled: true, isFreeApiDeploymentGateEnabled: true }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return billingState.isBillingEnabled }, diff --git a/apps/sim/lib/billing/core/api-access.ts b/apps/sim/lib/billing/core/api-access.ts index be41d29293f..43e65d67d8b 100644 --- a/apps/sim/lib/billing/core/api-access.ts +++ b/apps/sim/lib/billing/core/api-access.ts @@ -1,6 +1,6 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' -import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' /** The programmatic-execution paywall is active only when billing is enforced AND the gate flag is on. */ diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 2f49aa6ba25..4d25f9ec426 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -30,7 +30,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ USABLE_SUBSCRIPTION_STATUSES: ['active', 'trialing'], })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isAccessControlEnabled: false, isBillingEnabled: true, isCredentialSetsEnabled: false, diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 550fdcd3400..ca0996eabc6 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -27,7 +27,7 @@ import { isHosted, isInboxEnabled, isSsoEnabled, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' const logger = createLogger('SubscriptionCore') diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index ff2f446c832..889cb3662f7 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -57,7 +57,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: mockIsOrgScopedSubscription, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true, })) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 3d1fcf04be2..ef3cc7d7631 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -32,7 +32,7 @@ import { } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import type { DbClient } from '@/lib/db/types' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/lib/billing/organizations/seat-drift.test.ts b/apps/sim/lib/billing/organizations/seat-drift.test.ts index 63d319effa3..b3b2de307b4 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.test.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.test.ts @@ -27,7 +27,7 @@ vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: mockReconcileOrganizationSeats, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/organizations/seat-drift.ts b/apps/sim/lib/billing/organizations/seat-drift.ts index 18819ac6455..f4f06d1a9f5 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('SeatDriftSweep') diff --git a/apps/sim/lib/billing/organizations/seats.test.ts b/apps/sim/lib/billing/organizations/seats.test.ts index 95725b94667..da1d27aef90 100644 --- a/apps/sim/lib/billing/organizations/seats.test.ts +++ b/apps/sim/lib/billing/organizations/seats.test.ts @@ -52,7 +52,7 @@ vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/organizations/seats.ts b/apps/sim/lib/billing/organizations/seats.ts index ac2b85f8d92..a956a38d444 100644 --- a/apps/sim/lib/billing/organizations/seats.ts +++ b/apps/sim/lib/billing/organizations/seats.ts @@ -6,7 +6,7 @@ import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' import { isTeam } from '@/lib/billing/plan-helpers' import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' const logger = createLogger('OrganizationSeats') diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 7cfb6b19ff6..6c9c96d4208 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -16,7 +16,7 @@ import { eq } from 'drizzle-orm' import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { getEnv } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('StorageLimits') diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 8fb3d962efb..5e838d76368 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -9,7 +9,7 @@ import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('StorageTracking') diff --git a/apps/sim/lib/billing/validation/seat-management.test.ts b/apps/sim/lib/billing/validation/seat-management.test.ts index c99cd09e26b..cbab22d9898 100644 --- a/apps/sim/lib/billing/validation/seat-management.test.ts +++ b/apps/sim/lib/billing/validation/seat-management.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ getEffectiveSeats: vi.fn().mockReturnValue(10), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 48bb573633d..f9f671ddc1a 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -6,7 +6,7 @@ import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { hasInflightOutboxEvent } from '@/lib/core/outbox/service' import { quickValidateEmail } from '@/lib/messaging/email/validation' diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index a1f92a08195..1afbfbbaac3 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { featureFlagsMock, workflowsUtilsMock } from '@sim/testing' +import { envFlagsMock, workflowsUtilsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ @@ -19,7 +19,7 @@ vi.mock('@/lib/billing/plan-helpers', () => ({ ), })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@/lib/mcp/utils', () => ({ createMcpToolId: vi.fn(), diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 31da4396bc1..a3d0bb9014b 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -7,7 +7,7 @@ import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { stripVersionSuffix } from '@/tools/utils' @@ -37,6 +37,7 @@ interface BuildPayloadParams { userTimezone?: string userMetadata?: { name?: string + email?: string timezone?: string } includeMothershipTools?: boolean @@ -367,7 +368,8 @@ export async function buildCopilotRequestPayload( ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), ...(params.userPermission ? { userPermission: params.userPermission } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), - ...(params.userMetadata && (params.userMetadata.name || params.userMetadata.timezone) + ...(params.userMetadata && + (params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone) ? { userMetadata: params.userMetadata } : {}), // Tell the copilot file subagent which document toolchain to write. Emitted diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 5465b25437a..e1347d05316 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -75,6 +75,7 @@ const ResourceAttachmentSchema = z.object({ 'filefolder', 'task', 'log', + 'scheduledtask', 'generic', ]), id: z.string().min(1), @@ -91,6 +92,7 @@ const GENERIC_RESOURCE_TITLE: Record['t filefolder: 'File Folder', task: 'Task', log: 'Log', + scheduledtask: 'Scheduled Task', generic: 'Resource', } @@ -108,6 +110,7 @@ const ChatContextSchema = z.object({ 'file', 'folder', 'filefolder', + 'scheduledtask', 'integration', 'skill', ]), @@ -123,6 +126,7 @@ const ChatContextSchema = z.object({ folderId: z.string().optional(), fileFolderId: z.string().optional(), skillId: z.string().optional(), + scheduleId: z.string().optional(), }) const ChatMessageSchema = z.object({ @@ -169,7 +173,7 @@ type UnifiedChatBranch = fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string userTimezone?: string - userMetadata?: { name?: string; timezone?: string } + userMetadata?: { name?: string; email?: string; timezone?: string } workflowId: string workflowName?: string workspaceId?: string @@ -204,7 +208,7 @@ type UnifiedChatBranch = fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string userTimezone?: string - userMetadata?: { name?: string; timezone?: string } + userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string }) => Promise> buildExecutionContext: (params: { @@ -722,6 +726,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { const body = ChatMessageSchema.parse(await req.json()) const userMetadata = { ...(authenticatedUserName ? { name: authenticatedUserName } : {}), + ...(authenticatedUserEmail ? { email: authenticatedUserEmail } : {}), ...(body.userTimezone ? { timezone: body.userTimezone } : {}), } const normalizedContexts = normalizeContexts(body.contexts) ?? [] diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index cc930889f13..3edafe1ca93 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -1,11 +1,12 @@ import { db, dbReplica } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' +import { knowledgeBase, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, } from '@sim/workflow-authz' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNull, ne } from 'drizzle-orm' +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, canonicalBlockVfsPath, @@ -16,7 +17,7 @@ import { encodeVfsPathSegments, encodeVfsSegment, } from '@/lib/copilot/vfs/path-utils' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -168,6 +169,16 @@ export async function processContextsServer( path: result.path, } } + if (ctx.kind === 'scheduledtask' && ctx.scheduleId && currentWorkspaceId) { + const result = await resolveScheduledTaskResource(ctx.scheduleId, currentWorkspaceId) + if (!result) return null + return { + type: 'active_resource', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, + } + } if (ctx.kind === 'docs') { try { const { searchDocumentationServerTool } = await import( @@ -695,6 +706,9 @@ export async function resolveActiveResourceContext( case 'filefolder': { return await resolveFileFolderResource(resourceId, workspaceId) } + case 'scheduledtask': { + return await resolveScheduledTaskResource(resourceId, workspaceId) + } default: return null } @@ -718,6 +732,38 @@ async function resolveTableResource( } } +async function resolveScheduledTaskResource( + scheduleId: string, + workspaceId: string +): Promise { + const [row] = await db + .select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle }) + .from(workflowSchedule) + .where( + and( + eq(workflowSchedule.id, scheduleId), + eq(workflowSchedule.sourceWorkspaceId, workspaceId), + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt), + // Mirror the VFS materializer (workspace-vfs `materializeJobs`), which + // excludes completed jobs — otherwise we'd point at a meta.json it never + // wrote and the agent's read would dangle. + ne(workflowSchedule.status, 'completed') + ) + ) + .limit(1) + if (!row) return null + // The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see + // workspace-vfs `materializeJobs`); emit the same lightweight path pointer so + // the agent reads it via the VFS instead of us inlining the (heavy) row. + return { + type: 'active_resource', + tag: '@active_resource', + content: '', + path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`, + } +} + async function resolveFileResource( fileId: string, workspaceId: string diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts new file mode 100644 index 00000000000..dd8527f8158 --- /dev/null +++ b/apps/sim/lib/copilot/generated/metrics-v1.ts @@ -0,0 +1,58 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// +// Source: copilot/copilot/contracts/metrics-v1.schema.json +// Regenerate with: bun run metrics-contract:generate +// +// Canonical mothership OTel metric names. Call sites should reference +// `Metric.` (e.g. `Metric.CopilotToolDuration`) rather than raw +// string literals, so the Go-side contract is the single source of truth and +// typos become compile errors. +// +// NAMES ONLY. Label keys and histogram bucket boundaries are NOT in this +// contract — Go owns the label-cardinality allowlist and the shared bucket +// constant, and the Sim emitter MUST mirror those by hand so the Go∪Sim metric +// union is queryable as one series set. + +export const Metric = { + CopilotCacheAttempted: 'copilot.cache.attempted', + CopilotCacheHit: 'copilot.cache.hit', + CopilotCacheWrite: 'copilot.cache.write', + CopilotFileReadDuration: 'copilot.file.read.duration', + CopilotFileReadSize: 'copilot.file.read.size', + CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration', + CopilotRequestCount: 'copilot.request.count', + CopilotRequestDuration: 'copilot.request.duration', + CopilotToolCalls: 'copilot.tool.calls', + CopilotToolDuration: 'copilot.tool.duration', + CopilotVfsMaterializeDuration: 'copilot.vfs.materialize.duration', + GenAiClientCacheTokenUsage: 'gen_ai.client.cache.token.usage', + GenAiClientTokenUsage: 'gen_ai.client.token.usage', + LlmClientErrors: 'llm.client.errors', + LlmClientOutputCutoff: 'llm.client.output_cutoff', + LlmClientStreamDuration: 'llm.client.stream.duration', + LlmClientTimeToFirstToken: 'llm.client.time_to_first_token', +} as const + +export type MetricKey = keyof typeof Metric +export type MetricValue = (typeof Metric)[MetricKey] + +/** Readonly sorted list of every canonical mothership metric name. */ +export const MetricValues: readonly MetricValue[] = [ + 'copilot.cache.attempted', + 'copilot.cache.hit', + 'copilot.cache.write', + 'copilot.file.read.duration', + 'copilot.file.read.size', + 'copilot.messages.serialize.duration', + 'copilot.request.count', + 'copilot.request.duration', + 'copilot.tool.calls', + 'copilot.tool.duration', + 'copilot.vfs.materialize.duration', + 'gen_ai.client.cache.token.usage', + 'gen_ai.client.token.usage', + 'llm.client.errors', + 'llm.client.output_cutoff', + 'llm.client.stream.duration', + 'llm.client.time_to_first_token', +] as const diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 87178f69903..ae4296db336 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -10,7 +10,7 @@ export interface ToolCatalogEntry { | 'agent' | 'auth' | 'check_deployment_status' - | 'complete_job' + | 'complete_scheduled_task' | 'crawl_website' | 'create_file' | 'create_file_folder' @@ -42,14 +42,13 @@ export interface ToolCatalogEntry { | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' - | 'get_job_logs' | 'get_page_contents' | 'get_platform_actions' + | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' | 'grep' - | 'job' | 'knowledge' | 'knowledge_base' | 'list_file_folders' @@ -61,8 +60,8 @@ export interface ToolCatalogEntry { | 'load_integration_tool' | 'manage_credential' | 'manage_custom_tool' - | 'manage_job' | 'manage_mcp_tool' + | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' | 'media' @@ -88,6 +87,7 @@ export interface ToolCatalogEntry { | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' + | 'scheduled_task' | 'scrape_page' | 'search_documentation' | 'search_library_docs' @@ -99,7 +99,7 @@ export interface ToolCatalogEntry { | 'superagent' | 'table' | 'update_deployment_version' - | 'update_job_history' + | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_memory' | 'user_table' @@ -111,7 +111,7 @@ export interface ToolCatalogEntry { | 'agent' | 'auth' | 'check_deployment_status' - | 'complete_job' + | 'complete_scheduled_task' | 'crawl_website' | 'create_file' | 'create_file_folder' @@ -143,14 +143,13 @@ export interface ToolCatalogEntry { | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' - | 'get_job_logs' | 'get_page_contents' | 'get_platform_actions' + | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' | 'grep' - | 'job' | 'knowledge' | 'knowledge_base' | 'list_file_folders' @@ -162,8 +161,8 @@ export interface ToolCatalogEntry { | 'load_integration_tool' | 'manage_credential' | 'manage_custom_tool' - | 'manage_job' | 'manage_mcp_tool' + | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' | 'media' @@ -189,6 +188,7 @@ export interface ToolCatalogEntry { | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' + | 'scheduled_task' | 'scrape_page' | 'search_documentation' | 'search_library_docs' @@ -200,7 +200,7 @@ export interface ToolCatalogEntry { | 'superagent' | 'table' | 'update_deployment_version' - | 'update_job_history' + | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_memory' | 'user_table' @@ -216,11 +216,11 @@ export interface ToolCatalogEntry { | 'auth' | 'deploy' | 'file' - | 'job' | 'knowledge' | 'media' | 'research' | 'run' + | 'scheduled_task' | 'superagent' | 'table' | 'workflow' @@ -275,15 +275,15 @@ export const CheckDeploymentStatus: ToolCatalogEntry = { }, } -export const CompleteJob: ToolCatalogEntry = { - id: 'complete_job', - name: 'complete_job', +export const CompleteScheduledTask: ToolCatalogEntry = { + id: 'complete_scheduled_task', + name: 'complete_scheduled_task', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - jobId: { type: 'string', description: 'The ID of the job to mark as completed.' }, + jobId: { type: 'string', description: 'The ID of the scheduled task to mark as completed.' }, }, required: ['jobId'], }, @@ -1990,26 +1990,6 @@ export const GetDeploymentLog: ToolCatalogEntry = { }, } -export const GetJobLogs: ToolCatalogEntry = { - id: 'get_job_logs', - name: 'get_job_logs', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Optional execution ID for a specific run.' }, - includeDetails: { - type: 'boolean', - description: 'Include tool calls, outputs, and cost details.', - }, - jobId: { type: 'string', description: 'The job (schedule) ID to get logs for.' }, - limit: { type: 'number', description: 'Max number of entries (default: 3, max: 5)' }, - }, - required: ['jobId'], - }, -} - export const GetPageContents: ToolCatalogEntry = { id: 'get_page_contents', name: 'get_page_contents', @@ -2045,6 +2025,26 @@ export const GetPlatformActions: ToolCatalogEntry = { parameters: { type: 'object', properties: {} }, } +export const GetScheduledTaskLogs: ToolCatalogEntry = { + id: 'get_scheduled_task_logs', + name: 'get_scheduled_task_logs', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + executionId: { type: 'string', description: 'Optional execution ID for a specific run.' }, + includeDetails: { + type: 'boolean', + description: 'Include tool calls, outputs, and cost details.', + }, + jobId: { type: 'string', description: 'The scheduled task (schedule) ID to get logs for.' }, + limit: { type: 'number', description: 'Max number of entries (default: 3, max: 5)' }, + }, + required: ['jobId'], + }, +} + export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -2155,20 +2155,6 @@ export const Grep: ToolCatalogEntry = { }, } -export const Job: ToolCatalogEntry = { - id: 'job', - name: 'job', - route: 'subagent', - mode: 'async', - parameters: { - properties: { request: { description: 'What job action is needed.', type: 'string' } }, - required: ['request'], - type: 'object', - }, - subagentId: 'job', - internal: true, -} - export const Knowledge: ToolCatalogEntry = { id: 'knowledge', name: 'knowledge', @@ -2522,7 +2508,7 @@ export const ManageCustomTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -2576,9 +2562,61 @@ export const ManageCustomTool: ToolCatalogEntry = { requiredPermission: 'write', } -export const ManageJob: ToolCatalogEntry = { - id: 'manage_job', - name: 'manage_job', +export const ManageMcpTool: ToolCatalogEntry = { + id: 'manage_mcp_tool', + name: 'manage_mcp_tool', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + description: 'Required for add and edit. The MCP server configuration.', + properties: { + enabled: { + type: 'boolean', + description: 'Whether the server is enabled (default: true)', + }, + headers: { + type: 'object', + description: 'Optional HTTP headers to send with requests (key-value pairs)', + }, + name: { type: 'string', description: 'Display name for the MCP server' }, + timeout: { + type: 'number', + description: 'Request timeout in milliseconds (default: 30000)', + }, + transport: { + type: 'string', + description: "Transport protocol: 'streamable-http' or 'sse'", + enum: ['streamable-http', 'sse'], + default: 'streamable-http', + }, + url: { type: 'string', description: 'The MCP server endpoint URL (required for add)' }, + }, + }, + operation: { + type: 'string', + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + enum: ['add', 'edit', 'delete', 'list'], + }, + serverId: { + type: 'string', + description: + "The MCP server's id — the `id` field inside the VFS file agent/mcp-servers/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", + }, + }, + required: ['operation'], + }, + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const ManageScheduledTask: ToolCatalogEntry = { + id: 'manage_scheduled_task', + name: 'manage_scheduled_task', route: 'sim', mode: 'async', parameters: { @@ -2592,39 +2630,42 @@ export const ManageJob: ToolCatalogEntry = { cron: { type: 'string', description: - "Cron expression for a recurring job (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", }, - jobId: { type: 'string', description: 'Job ID (required for get, update)' }, + jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' }, jobIds: { type: 'array', - description: 'Array of job IDs (for batch delete)', + description: 'Array of scheduled task IDs (for batch delete)', items: { type: 'string' }, }, lifecycle: { type: 'string', description: - "'persistent' (default) or 'until_complete'. Until_complete jobs stop when complete_job is called.", + "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", enum: ['persistent', 'until_complete'], }, maxRuns: { type: 'integer', description: 'Max executions before auto-completing. Safety limit.', }, - prompt: { type: 'string', description: 'The prompt to execute when the job fires' }, + prompt: { + type: 'string', + description: 'The prompt to execute when the scheduled task fires', + }, status: { type: 'string', - description: 'Job status: active, paused', + description: 'Scheduled task status: active, paused', enum: ['active', 'paused'], }, successCondition: { type: 'string', description: - 'What must happen for the job to be considered complete (until_complete lifecycle).', + 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', }, time: { type: 'string', description: - "ISO 8601 datetime. One-time job -> set time and omit cron. May also anchor a recurring cron job's first-fire time.", + "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", }, timezone: { type: 'string', @@ -2632,7 +2673,7 @@ export const ManageJob: ToolCatalogEntry = { }, title: { type: 'string', - description: "Short descriptive title for the job (e.g. 'Email Poller')", + description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", }, }, }, @@ -2647,58 +2688,6 @@ export const ManageJob: ToolCatalogEntry = { }, } -export const ManageMcpTool: ToolCatalogEntry = { - id: 'manage_mcp_tool', - name: 'manage_mcp_tool', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - description: 'Required for add and edit. The MCP server configuration.', - properties: { - enabled: { - type: 'boolean', - description: 'Whether the server is enabled (default: true)', - }, - headers: { - type: 'object', - description: 'Optional HTTP headers to send with requests (key-value pairs)', - }, - name: { type: 'string', description: 'Display name for the MCP server' }, - timeout: { - type: 'number', - description: 'Request timeout in milliseconds (default: 30000)', - }, - transport: { - type: 'string', - description: "Transport protocol: 'streamable-http' or 'sse'", - enum: ['streamable-http', 'sse'], - default: 'streamable-http', - }, - url: { type: 'string', description: 'The MCP server endpoint URL (required for add)' }, - }, - }, - operation: { - type: 'string', - description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", - enum: ['add', 'edit', 'delete', 'list'], - }, - serverId: { - type: 'string', - description: - "The MCP server's id — the `id` field inside the VFS file agent/mcp-servers/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", - }, - }, - required: ['operation'], - }, - requiresConfirmation: true, - requiredPermission: 'write', -} - export const ManageSkill: ToolCatalogEntry = { id: 'manage_skill', name: 'manage_skill', @@ -2723,7 +2712,7 @@ export const ManageSkill: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -2935,7 +2924,7 @@ export const OpenResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], }, }, required: ['type'], @@ -3484,6 +3473,22 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { requiresConfirmation: true, } +export const ScheduledTask: ToolCatalogEntry = { + id: 'scheduled_task', + name: 'scheduled_task', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + request: { description: 'What scheduled task action is needed.', type: 'string' }, + }, + required: ['request'], + type: 'object', + }, + subagentId: 'scheduled_task', + internal: true, +} + export const ScrapePage: ToolCatalogEntry = { id: 'scrape_page', name: 'scrape_page', @@ -3771,15 +3776,15 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { requiredPermission: 'write', } -export const UpdateJobHistory: ToolCatalogEntry = { - id: 'update_job_history', - name: 'update_job_history', +export const UpdateScheduledTaskHistory: ToolCatalogEntry = { + id: 'update_scheduled_task_history', + name: 'update_scheduled_task_history', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - jobId: { type: 'string', description: 'The job ID.' }, + jobId: { type: 'string', description: 'The scheduled task ID.' }, summary: { type: 'string', description: @@ -4433,24 +4438,6 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const -export const ManageJobOperation = { - create: 'create', - list: 'list', - get: 'get', - update: 'update', - delete: 'delete', -} as const - -export type ManageJobOperation = (typeof ManageJobOperation)[keyof typeof ManageJobOperation] - -export const ManageJobOperationValues = [ - ManageJobOperation.create, - ManageJobOperation.list, - ManageJobOperation.get, - ManageJobOperation.update, - ManageJobOperation.delete, -] as const - export const ManageMcpToolOperation = { add: 'add', edit: 'edit', @@ -4468,6 +4455,25 @@ export const ManageMcpToolOperationValues = [ ManageMcpToolOperation.list, ] as const +export const ManageScheduledTaskOperation = { + create: 'create', + list: 'list', + get: 'get', + update: 'update', + delete: 'delete', +} as const + +export type ManageScheduledTaskOperation = + (typeof ManageScheduledTaskOperation)[keyof typeof ManageScheduledTaskOperation] + +export const ManageScheduledTaskOperationValues = [ + ManageScheduledTaskOperation.create, + ManageScheduledTaskOperation.list, + ManageScheduledTaskOperation.get, + ManageScheduledTaskOperation.update, + ManageScheduledTaskOperation.delete, +] as const + export const ManageSkillOperation = { add: 'add', edit: 'edit', @@ -4604,7 +4610,7 @@ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, [CheckDeploymentStatus.id]: CheckDeploymentStatus, - [CompleteJob.id]: CompleteJob, + [CompleteScheduledTask.id]: CompleteScheduledTask, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, [CreateFileFolder.id]: CreateFileFolder, @@ -4636,14 +4642,13 @@ export const TOOL_CATALOG: Record = { [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, - [GetJobLogs.id]: GetJobLogs, [GetPageContents.id]: GetPageContents, [GetPlatformActions.id]: GetPlatformActions, + [GetScheduledTaskLogs.id]: GetScheduledTaskLogs, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, [Grep.id]: Grep, - [Job.id]: Job, [Knowledge.id]: Knowledge, [KnowledgeBase.id]: KnowledgeBase, [ListFileFolders.id]: ListFileFolders, @@ -4655,8 +4660,8 @@ export const TOOL_CATALOG: Record = { [LoadIntegrationTool.id]: LoadIntegrationTool, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, - [ManageJob.id]: ManageJob, [ManageMcpTool.id]: ManageMcpTool, + [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, @@ -4682,6 +4687,7 @@ export const TOOL_CATALOG: Record = { [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, + [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, [SearchDocumentation.id]: SearchDocumentation, [SearchLibraryDocs.id]: SearchLibraryDocs, @@ -4693,7 +4699,7 @@ export const TOOL_CATALOG: Record = { [Superagent.id]: Superagent, [Table.id]: Table, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, - [UpdateJobHistory.id]: UpdateJobHistory, + [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a93837798b1..5ed8d9224d6 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - ['agent']: { + agent: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['auth']: { + auth: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['check_deployment_status']: { + check_deployment_status: { parameters: { type: 'object', properties: { @@ -48,20 +48,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['complete_job']: { + complete_scheduled_task: { parameters: { type: 'object', properties: { jobId: { type: 'string', - description: 'The ID of the job to mark as completed.', + description: 'The ID of the scheduled task to mark as completed.', }, }, required: ['jobId'], }, resultSchema: undefined, }, - ['crawl_website']: { + crawl_website: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_file']: { + create_file: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['create_file_folder']: { + create_file_folder: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_folder']: { + create_folder: { parameters: { type: 'object', properties: { @@ -201,7 +201,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_workflow']: { + create_workflow: { parameters: { type: 'object', properties: { @@ -226,7 +226,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_workspace_mcp_server']: { + create_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -259,7 +259,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_file']: { + delete_file: { parameters: { type: 'object', properties: { @@ -289,7 +289,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['delete_file_folder']: { + delete_file_folder: { parameters: { type: 'object', properties: { @@ -305,7 +305,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_folder']: { + delete_folder: { parameters: { type: 'object', properties: { @@ -321,7 +321,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_workflow']: { + delete_workflow: { parameters: { type: 'object', properties: { @@ -337,7 +337,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_workspace_mcp_server']: { + delete_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -350,7 +350,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['deploy']: { + deploy: { parameters: { properties: { request: { @@ -364,7 +364,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['deploy_api']: { + deploy_api: { parameters: { type: 'object', properties: { @@ -448,7 +448,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['deploy_chat']: { + deploy_chat: { parameters: { type: 'object', properties: { @@ -607,7 +607,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['deploy_mcp']: { + deploy_mcp: { parameters: { type: 'object', properties: { @@ -723,7 +723,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - ['diff_workflows']: { + diff_workflows: { parameters: { type: 'object', properties: { @@ -747,7 +747,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['download_to_workspace_file']: { + download_to_workspace_file: { parameters: { type: 'object', properties: { @@ -796,7 +796,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['edit_content']: { + edit_content: { parameters: { type: 'object', properties: { @@ -828,7 +828,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['edit_workflow']: { + edit_workflow: { parameters: { type: 'object', properties: { @@ -867,7 +867,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['enrichment_run']: { + enrichment_run: { parameters: { type: 'object', properties: { @@ -911,7 +911,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ['ffmpeg']: { + ffmpeg: { parameters: { type: 'object', properties: { @@ -1092,7 +1092,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['file']: { + file: { parameters: { properties: { prompt: { @@ -1105,7 +1105,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['function_execute']: { + function_execute: { parameters: { type: 'object', properties: { @@ -1243,7 +1243,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_api_key']: { + generate_api_key: { parameters: { type: 'object', properties: { @@ -1261,7 +1261,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_audio']: { + generate_audio: { parameters: { type: 'object', properties: { @@ -1413,7 +1413,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_image']: { + generate_image: { parameters: { type: 'object', properties: { @@ -1541,7 +1541,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_video']: { + generate_video: { parameters: { type: 'object', properties: { @@ -1708,7 +1708,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_block_outputs']: { + get_block_outputs: { parameters: { type: 'object', properties: { @@ -1729,7 +1729,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_block_upstream_references']: { + get_block_upstream_references: { parameters: { type: 'object', properties: { @@ -1751,7 +1751,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_deployed_workflow_state']: { + get_deployed_workflow_state: { parameters: { type: 'object', properties: { @@ -1764,7 +1764,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_deployment_log']: { + get_deployment_log: { parameters: { type: 'object', properties: { @@ -1777,32 +1777,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_job_logs']: { - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', - description: 'Optional execution ID for a specific run.', - }, - includeDetails: { - type: 'boolean', - description: 'Include tool calls, outputs, and cost details.', - }, - jobId: { - type: 'string', - description: 'The job (schedule) ID to get logs for.', - }, - limit: { - type: 'number', - description: 'Max number of entries (default: 3, max: 5)', - }, - }, - required: ['jobId'], - }, - resultSchema: undefined, - }, - ['get_page_contents']: { + get_page_contents: { parameters: { type: 'object', properties: { @@ -1830,14 +1805,39 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_platform_actions']: { + get_platform_actions: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - ['get_workflow_data']: { + get_scheduled_task_logs: { + parameters: { + type: 'object', + properties: { + executionId: { + type: 'string', + description: 'Optional execution ID for a specific run.', + }, + includeDetails: { + type: 'boolean', + description: 'Include tool calls, outputs, and cost details.', + }, + jobId: { + type: 'string', + description: 'The scheduled task (schedule) ID to get logs for.', + }, + limit: { + type: 'number', + description: 'Max number of entries (default: 3, max: 5)', + }, + }, + required: ['jobId'], + }, + resultSchema: undefined, + }, + get_workflow_data: { parameters: { type: 'object', properties: { @@ -1856,7 +1856,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_workflow_run_options']: { + get_workflow_run_options: { parameters: { type: 'object', properties: { @@ -1869,7 +1869,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['glob']: { + glob: { parameters: { type: 'object', properties: { @@ -1888,7 +1888,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['grep']: { + grep: { parameters: { type: 'object', properties: { @@ -1936,20 +1936,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['job']: { - parameters: { - properties: { - request: { - description: 'What job action is needed.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - resultSchema: undefined, - }, - ['knowledge']: { + knowledge: { parameters: { properties: { request: { @@ -1962,7 +1949,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['knowledge_base']: { + knowledge_base: { parameters: { type: 'object', properties: { @@ -2155,7 +2142,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['list_file_folders']: { + list_file_folders: { parameters: { type: 'object', properties: { @@ -2167,7 +2154,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['list_folders']: { + list_folders: { parameters: { type: 'object', properties: { @@ -2179,7 +2166,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['list_integration_tools']: { + list_integration_tools: { parameters: { properties: { integration: { @@ -2193,14 +2180,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['list_user_workspaces']: { + list_user_workspaces: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - ['list_workspace_mcp_servers']: { + list_workspace_mcp_servers: { parameters: { type: 'object', properties: { @@ -2213,7 +2200,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['load_deployment']: { + load_deployment: { parameters: { type: 'object', properties: { @@ -2232,7 +2219,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['load_integration_tool']: { + load_integration_tool: { parameters: { properties: { tool_ids: { @@ -2249,7 +2236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_credential']: { + manage_credential: { parameters: { type: 'object', properties: { @@ -2278,7 +2265,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_custom_tool']: { + manage_custom_tool: { parameters: { type: 'object', properties: { @@ -2290,7 +2277,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -2358,7 +2345,59 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_job']: { + manage_mcp_tool: { + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + description: 'Required for add and edit. The MCP server configuration.', + properties: { + enabled: { + type: 'boolean', + description: 'Whether the server is enabled (default: true)', + }, + headers: { + type: 'object', + description: 'Optional HTTP headers to send with requests (key-value pairs)', + }, + name: { + type: 'string', + description: 'Display name for the MCP server', + }, + timeout: { + type: 'number', + description: 'Request timeout in milliseconds (default: 30000)', + }, + transport: { + type: 'string', + description: "Transport protocol: 'streamable-http' or 'sse'", + enum: ['streamable-http', 'sse'], + default: 'streamable-http', + }, + url: { + type: 'string', + description: 'The MCP server endpoint URL (required for add)', + }, + }, + }, + operation: { + type: 'string', + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + enum: ['add', 'edit', 'delete', 'list'], + }, + serverId: { + type: 'string', + description: + "The MCP server's id — the `id` field inside the VFS file agent/mcp-servers/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, + manage_scheduled_task: { parameters: { type: 'object', properties: { @@ -2370,15 +2409,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { cron: { type: 'string', description: - "Cron expression for a recurring job (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", }, jobId: { type: 'string', - description: 'Job ID (required for get, update)', + description: 'Scheduled task ID (required for get, update)', }, jobIds: { type: 'array', - description: 'Array of job IDs (for batch delete)', + description: 'Array of scheduled task IDs (for batch delete)', items: { type: 'string', }, @@ -2386,7 +2425,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { lifecycle: { type: 'string', description: - "'persistent' (default) or 'until_complete'. Until_complete jobs stop when complete_job is called.", + "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", enum: ['persistent', 'until_complete'], }, maxRuns: { @@ -2395,22 +2434,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, prompt: { type: 'string', - description: 'The prompt to execute when the job fires', + description: 'The prompt to execute when the scheduled task fires', }, status: { type: 'string', - description: 'Job status: active, paused', + description: 'Scheduled task status: active, paused', enum: ['active', 'paused'], }, successCondition: { type: 'string', description: - 'What must happen for the job to be considered complete (until_complete lifecycle).', + 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', }, time: { type: 'string', description: - "ISO 8601 datetime. One-time job -> set time and omit cron. May also anchor a recurring cron job's first-fire time.", + "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", }, timezone: { type: 'string', @@ -2418,7 +2457,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, title: { type: 'string', - description: "Short descriptive title for the job (e.g. 'Email Poller')", + description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", }, }, }, @@ -2433,59 +2472,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_mcp_tool']: { - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - description: 'Required for add and edit. The MCP server configuration.', - properties: { - enabled: { - type: 'boolean', - description: 'Whether the server is enabled (default: true)', - }, - headers: { - type: 'object', - description: 'Optional HTTP headers to send with requests (key-value pairs)', - }, - name: { - type: 'string', - description: 'Display name for the MCP server', - }, - timeout: { - type: 'number', - description: 'Request timeout in milliseconds (default: 30000)', - }, - transport: { - type: 'string', - description: "Transport protocol: 'streamable-http' or 'sse'", - enum: ['streamable-http', 'sse'], - default: 'streamable-http', - }, - url: { - type: 'string', - description: 'The MCP server endpoint URL (required for add)', - }, - }, - }, - operation: { - type: 'string', - description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", - enum: ['add', 'edit', 'delete', 'list'], - }, - serverId: { - type: 'string', - description: - "The MCP server's id — the `id` field inside the VFS file agent/mcp-servers/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, - ['manage_skill']: { + manage_skill: { parameters: { type: 'object', properties: { @@ -2505,7 +2492,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -2518,7 +2505,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['materialize_file']: { + materialize_file: { parameters: { type: 'object', properties: { @@ -2542,7 +2529,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['media']: { + media: { parameters: { properties: { prompt: { @@ -2555,7 +2542,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_file']: { + move_file: { parameters: { type: 'object', properties: { @@ -2576,7 +2563,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_file_folder']: { + move_file_folder: { parameters: { type: 'object', properties: { @@ -2594,7 +2581,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_folder']: { + move_folder: { parameters: { type: 'object', properties: { @@ -2612,7 +2599,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_workflow']: { + move_workflow: { parameters: { type: 'object', properties: { @@ -2632,7 +2619,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['oauth_get_auth_link']: { + oauth_get_auth_link: { parameters: { type: 'object', properties: { @@ -2646,7 +2633,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['oauth_request_access']: { + oauth_request_access: { parameters: { type: 'object', properties: { @@ -2660,7 +2647,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['open_resource']: { + open_resource: { parameters: { type: 'object', properties: { @@ -2683,7 +2670,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], }, }, required: ['type'], @@ -2694,7 +2681,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['promote_to_live']: { + promote_to_live: { parameters: { type: 'object', properties: { @@ -2713,7 +2700,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['query_logs']: { + query_logs: { parameters: { type: 'object', properties: { @@ -2824,7 +2811,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['read']: { + read: { parameters: { type: 'object', properties: { @@ -2851,7 +2838,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['redeploy']: { + redeploy: { parameters: { type: 'object', properties: { @@ -2930,7 +2917,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['rename_file']: { + rename_file: { parameters: { type: 'object', properties: { @@ -2966,7 +2953,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['rename_file_folder']: { + rename_file_folder: { parameters: { type: 'object', properties: { @@ -2983,7 +2970,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['rename_workflow']: { + rename_workflow: { parameters: { type: 'object', properties: { @@ -3000,7 +2987,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['research']: { + research: { parameters: { properties: { topic: { @@ -3013,7 +3000,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['respond']: { + respond: { parameters: { additionalProperties: true, properties: { @@ -3036,7 +3023,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['restore_resource']: { + restore_resource: { parameters: { type: 'object', properties: { @@ -3054,7 +3041,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run']: { + run: { parameters: { properties: { context: { @@ -3071,7 +3058,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_block']: { + run_block: { parameters: { type: 'object', properties: { @@ -3103,7 +3090,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_from_block']: { + run_from_block: { parameters: { type: 'object', properties: { @@ -3135,7 +3122,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_workflow']: { + run_workflow: { parameters: { type: 'object', properties: { @@ -3173,7 +3160,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_workflow_until_block']: { + run_workflow_until_block: { parameters: { type: 'object', properties: { @@ -3216,7 +3203,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['scrape_page']: { + scheduled_task: { + parameters: { + properties: { + request: { + description: 'What scheduled task action is needed.', + type: 'string', + }, + }, + required: ['request'], + type: 'object', + }, + resultSchema: undefined, + }, + scrape_page: { parameters: { type: 'object', properties: { @@ -3237,7 +3237,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_documentation']: { + search_documentation: { parameters: { type: 'object', properties: { @@ -3254,7 +3254,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_library_docs']: { + search_library_docs: { parameters: { type: 'object', properties: { @@ -3275,7 +3275,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_online']: { + search_online: { parameters: { type: 'object', properties: { @@ -3315,7 +3315,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_patterns']: { + search_patterns: { parameters: { type: 'object', properties: { @@ -3337,7 +3337,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_block_enabled']: { + set_block_enabled: { parameters: { type: 'object', properties: { @@ -3359,7 +3359,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_environment_variables']: { + set_environment_variables: { parameters: { type: 'object', properties: { @@ -3393,7 +3393,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_global_workflow_variables']: { + set_global_workflow_variables: { parameters: { type: 'object', properties: { @@ -3434,7 +3434,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['superagent']: { + superagent: { parameters: { properties: { task: { @@ -3448,7 +3448,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['table']: { + table: { parameters: { properties: { request: { @@ -3461,7 +3461,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_deployment_version']: { + update_deployment_version: { parameters: { type: 'object', properties: { @@ -3490,13 +3490,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_job_history']: { + update_scheduled_task_history: { parameters: { type: 'object', properties: { jobId: { type: 'string', - description: 'The job ID.', + description: 'The scheduled task ID.', }, summary: { type: 'string', @@ -3508,7 +3508,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_workspace_mcp_server']: { + update_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -3533,7 +3533,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['user_memory']: { + user_memory: { parameters: { type: 'object', properties: { @@ -3582,7 +3582,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['user_table']: { + user_table: { parameters: { type: 'object', properties: { @@ -3944,7 +3944,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['workflow']: { + workflow: { parameters: { properties: { prompt: { @@ -3957,7 +3957,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['workspace_file']: { + workspace_file: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 441ec59d16d..982857673f5 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -54,10 +54,8 @@ export const TraceAttr = { AuthProvider: 'auth.provider', AuthValidateStatusCode: 'auth.validate.status_code', AwsRegion: 'aws.region', - BedrockErrorCode: 'bedrock.error_code', - BedrockModelId: 'bedrock.model_id', - BedrockRequestBodyBytesRetry: 'bedrock.request.body_bytes_retry', BillingAttempts: 'billing.attempts', + BillingByok: 'billing.byok', BillingChangeType: 'billing.change_type', BillingCostInputUsd: 'billing.cost.input_usd', BillingCostOutputUsd: 'billing.cost.output_usd', @@ -159,6 +157,14 @@ export const TraceAttr = { ContextReduced: 'context.reduced', ContextSummarizeInputChars: 'context.summarize.input_chars', ContextSummarizeOutputChars: 'context.summarize.output_chars', + ContextTransformCaller: 'context.transform.caller', + ContextTransformCharsIn: 'context.transform.chars_in', + ContextTransformCharsOut: 'context.transform.chars_out', + ContextTransformDropCount: 'context.transform.drop_count', + ContextTransformDrops: 'context.transform.drops', + ContextTransformMessagesIn: 'context.transform.messages_in', + ContextTransformMessagesOut: 'context.transform.messages_out', + ContextTransformStage: 'context.transform.stage', CopilotAbortControllerFired: 'copilot.abort.controller_fired', CopilotAbortGoMarkerOk: 'copilot.abort.go_marker_ok', CopilotAbortLocalAborted: 'copilot.abort.local_aborted', @@ -276,6 +282,7 @@ export const TraceAttr = { CopilotVfsOutcome: 'copilot.vfs.outcome', CopilotVfsOutputBytes: 'copilot.vfs.output.bytes', CopilotVfsOutputMediaType: 'copilot.vfs.output.media_type', + CopilotVfsPhase: 'copilot.vfs.phase', CopilotVfsReadImageResized: 'copilot.vfs.read.image.resized', CopilotVfsReadOutcome: 'copilot.vfs.read.outcome', CopilotVfsReadOutputBytes: 'copilot.vfs.read.output.bytes', @@ -389,6 +396,7 @@ export const TraceAttr = { GenAiRequestToolUseBlocks: 'gen_ai.request.tool_use_blocks', GenAiRequestToolsCount: 'gen_ai.request.tools.count', GenAiRequestUserMessages: 'gen_ai.request.user_messages', + GenAiResponseFinishReasons: 'gen_ai.response.finish_reasons', GenAiResponseModel: 'gen_ai.response.model', GenAiStreamPhaseTextBytes: 'gen_ai.stream.phase.text.bytes', GenAiStreamPhaseTextChunks: 'gen_ai.stream.phase.text.chunks', @@ -434,7 +442,9 @@ export const TraceAttr = { InvitationRole: 'invitation.role', KnowledgeBaseId: 'knowledge_base.id', KnowledgeBaseName: 'knowledge_base.name', + LlmBackend: 'llm.backend', LlmErrorStage: 'llm.error_stage', + LlmProtocol: 'llm.protocol', LlmRequestBodyBytes: 'llm.request.body_bytes', LlmStreamBytes: 'llm.stream.bytes', LlmStreamChunks: 'llm.stream.chunks', @@ -460,6 +470,10 @@ export const TraceAttr = { MemoryPath: 'memory.path', MemoryRowCount: 'memory.row_count', MessageId: 'message.id', + MessagesDeserializeMs: 'messages.deserialize_ms', + MessagesSerializeOp: 'messages.serialize.op', + MessagesSerializeSite: 'messages.serialize.site', + MessagesSerializeMs: 'messages.serialize_ms', MessagingDestinationName: 'messaging.destination.name', MessagingSystem: 'messaging.system', ModelDurationMs: 'model.duration_ms', @@ -495,14 +509,15 @@ export const TraceAttr = { ResumeResultsFailureCount: 'resume.results.failure_count', ResumeResultsSuccessCount: 'resume.results.success_count', RouterBackendName: 'router.backend_name', - RouterBedrockEnabled: 'router.bedrock_enabled', - RouterBedrockSupportedModel: 'router.bedrock_supported_model', + RouterConfigVersion: 'router.config_version', RouterId: 'router.id', RouterName: 'router.name', + RouterRouteReason: 'router.route_reason', RouterSelectedBackend: 'router.selected_backend', RouterSelectedPath: 'router.selected_path', RunId: 'run.id', SearchResultsCount: 'search.results_count', + ServerAddress: 'server.address', ServiceInstanceId: 'service.instance.id', ServiceName: 'service.name', ServiceNamespace: 'service.namespace', @@ -663,10 +678,8 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'auth.provider', 'auth.validate.status_code', 'aws.region', - 'bedrock.error_code', - 'bedrock.model_id', - 'bedrock.request.body_bytes_retry', 'billing.attempts', + 'billing.byok', 'billing.change_type', 'billing.cost.input_usd', 'billing.cost.output_usd', @@ -768,6 +781,14 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'context.reduced', 'context.summarize.input_chars', 'context.summarize.output_chars', + 'context.transform.caller', + 'context.transform.chars_in', + 'context.transform.chars_out', + 'context.transform.drop_count', + 'context.transform.drops', + 'context.transform.messages_in', + 'context.transform.messages_out', + 'context.transform.stage', 'copilot.abort.controller_fired', 'copilot.abort.go_marker_ok', 'copilot.abort.local_aborted', @@ -885,6 +906,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.vfs.outcome', 'copilot.vfs.output.bytes', 'copilot.vfs.output.media_type', + 'copilot.vfs.phase', 'copilot.vfs.read.image.resized', 'copilot.vfs.read.outcome', 'copilot.vfs.read.output.bytes', @@ -987,6 +1009,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.request.tool_use_blocks', 'gen_ai.request.tools.count', 'gen_ai.request.user_messages', + 'gen_ai.response.finish_reasons', 'gen_ai.response.model', 'gen_ai.stream.phase.text.bytes', 'gen_ai.stream.phase.text.chunks', @@ -1032,7 +1055,9 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'invitation.role', 'knowledge_base.id', 'knowledge_base.name', + 'llm.backend', 'llm.error_stage', + 'llm.protocol', 'llm.request.body_bytes', 'llm.stream.bytes', 'llm.stream.chunks', @@ -1058,6 +1083,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'memory.path', 'memory.row_count', 'message.id', + 'messages.deserialize_ms', + 'messages.serialize.op', + 'messages.serialize.site', + 'messages.serialize_ms', 'messaging.destination.name', 'messaging.system', 'model.duration_ms', @@ -1093,14 +1122,15 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'resume.results.failure_count', 'resume.results.success_count', 'router.backend_name', - 'router.bedrock_enabled', - 'router.bedrock_supported_model', + 'router.config_version', 'router.id', 'router.name', + 'router.route_reason', 'router.selected_backend', 'router.selected_path', 'run.id', 'search.results_count', + 'server.address', 'service.instance.id', 'service.name', 'service.namespace', diff --git a/apps/sim/lib/copilot/generated/trace-events-v1.ts b/apps/sim/lib/copilot/generated/trace-events-v1.ts index 345606eff40..ffc4c17361d 100644 --- a/apps/sim/lib/copilot/generated/trace-events-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-events-v1.ts @@ -10,7 +10,7 @@ // become compile errors. export const TraceEvent = { - BedrockInvokeRetryWithoutImages: 'bedrock.invoke.retry_without_images', + ContextTransform: 'context.transform', CopilotOutputFileError: 'copilot.output_file.error', CopilotSseFirstEvent: 'copilot.sse.first_event', CopilotSseIdleGapExceeded: 'copilot.sse.idle_gap_exceeded', @@ -33,7 +33,7 @@ export type TraceEventValue = (typeof TraceEvent)[TraceEventKey] /** Readonly sorted list of every canonical event name. */ export const TraceEventValues: readonly TraceEventValue[] = [ - 'bedrock.invoke.retry_without_images', + 'context.transform', 'copilot.output_file.error', 'copilot.sse.first_event', 'copilot.sse.idle_gap_exceeded', diff --git a/apps/sim/lib/copilot/request/metrics.ts b/apps/sim/lib/copilot/request/metrics.ts new file mode 100644 index 00000000000..31f0e7996f6 --- /dev/null +++ b/apps/sim/lib/copilot/request/metrics.ts @@ -0,0 +1,101 @@ +// Sim server-side copilot metrics (U17). Sim's MeterProvider is wired in +// instrumentation-node.ts (OTLP → Mimir, 60s) but had no copilot instruments; +// this module is its first consumer. We emit the SAME metric names + label keys +// + histogram bucket boundaries as the Go side (copilot internal/telemetry + +// contracts/metrics_v1.go) so the Go∪Sim union is queryable as one series set +// — e.g. `copilot.tool.duration` split by `tool.executor` (go|client|sim). +// +// Bounded cardinality only: tool.name is capped to the shared tool catalog +// (else "other"); vfs phase / file-read outcome are bounded sets. NEVER a +// user/chat/request id (those explode Prometheus series). +import { type Counter, type Histogram, metrics } from '@opentelemetry/api' +import { Metric } from '@/lib/copilot/generated/metrics-v1' +import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' + +// MUST match Go's copilot/internal/telemetry/metrics.go LatencyBucketsMs +// exactly — a histogram_quantile(sum by (le) …) over the Go∪Sim union is only +// valid with identical boundaries. If you change one side, change the other. +const LATENCY_BUCKETS_MS = [ + 50, 100, 250, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000, 300000, +] + +// File sizes span KB→tens of MB; a bytes-appropriate bucket set (not latency). +const BYTE_BUCKETS = [1024, 8192, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456] + +interface CopilotMeterInstruments { + toolDuration: Histogram + toolCalls: Counter + vfsMaterializeDuration: Histogram + fileReadDuration: Histogram + fileReadBytes: Histogram +} + +let cached: CopilotMeterInstruments | undefined + +// Lazy init: Turbopack/Next can evaluate this module before the NodeSDK +// installs the real MeterProvider, so resolve instruments on first use (a +// no-op meter before then simply drops records — same pattern as getCopilotTracer). +function instruments(): CopilotMeterInstruments { + if (cached) return cached + const meter = metrics.getMeter('sim-copilot') + cached = { + toolDuration: meter.createHistogram(Metric.CopilotToolDuration, { + unit: 'ms', + advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, + }), + toolCalls: meter.createCounter(Metric.CopilotToolCalls), + vfsMaterializeDuration: meter.createHistogram(Metric.CopilotVfsMaterializeDuration, { + unit: 'ms', + advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, + }), + fileReadDuration: meter.createHistogram(Metric.CopilotFileReadDuration, { + unit: 'ms', + advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, + }), + fileReadBytes: meter.createHistogram(Metric.CopilotFileReadSize, { + unit: 'By', + advice: { explicitBucketBoundaries: BYTE_BUCKETS }, + }), + } + return cached +} + +// Caps tool.name to the shared catalog (matches Go's cappedToolName): a +// catalog tool keeps its name, everything else (user MCP/custom/unknown) +// collapses to "other" so series count stays finite. +function cappedToolName(name: string): string { + return TOOL_CATALOG[name] ? name : 'other' +} + +// recordSimToolMetric emits copilot.tool.calls (+1) and copilot.tool.duration +// for one server-side Sim tool dispatch (executor=sim). outcome is the bounded +// tool outcome (success/error/…). Pure telemetry. +export function recordSimToolMetric(name: string, outcome: string, durationMs: number): void { + const { toolDuration, toolCalls } = instruments() + const attrs = { + [TraceAttr.ToolName]: cappedToolName(name), + [TraceAttr.ToolExecutor]: 'sim', + [TraceAttr.ToolOutcome]: outcome, + } + toolCalls.add(1, attrs) + if (durationMs >= 0) toolDuration.record(durationMs, attrs) +} + +// recordVfsMaterialize records VFS materialization time. Call once per phase +// with that phase's duration and once with phase="total" for the whole op, so +// the dashboard can show total + per-phase. phase must be a bounded value. +export function recordVfsMaterialize(phase: string, durationMs: number): void { + if (durationMs < 0) return + instruments().vfsMaterializeDuration.record(durationMs, { + [TraceAttr.CopilotVfsPhase]: phase, + }) +} + +// recordFileRead records server-side file-read duration + size by outcome. +export function recordFileRead(outcome: string, durationMs: number, bytes: number): void { + const { fileReadDuration, fileReadBytes } = instruments() + const attrs = { [TraceAttr.CopilotVfsReadOutcome]: outcome } + if (durationMs >= 0) fileReadDuration.record(durationMs, attrs) + if (bytes >= 0) fileReadBytes.record(bytes, attrs) +} diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index b9a78eae0bf..b1637b017ce 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -43,6 +43,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +import { recordSimToolMetric } from '@/lib/copilot/request/metrics' import { withCopilotToolSpan } from '@/lib/copilot/request/otel' import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' import { @@ -397,15 +398,32 @@ export async function executeToolAndReport( argsPreview: argsPayload?.slice(0, 200), }, async (otelSpan) => { - const completion = await executeToolAndReportInner(toolCall, context, execContext, options) - otelSpan.setAttribute(TraceAttr.ToolOutcome, completion.status) - if (completion.message) { - otelSpan.setAttribute( - TraceAttr.ToolOutcomeMessage, - String(completion.message).slice(0, 500) - ) + const startedAt = Date.now() + try { + const completion = await executeToolAndReportInner(toolCall, context, execContext, options) + const durationMs = Date.now() - startedAt + otelSpan.setAttribute(TraceAttr.ToolOutcome, completion.status) + otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs) + if (completion.message) { + otelSpan.setAttribute( + TraceAttr.ToolOutcomeMessage, + String(completion.message).slice(0, 500) + ) + } + // Durable Grafana signal for "which Sim tool is slowest" (executor=sim); + // pairs with the Go executor-boundary metric (U15) as one series set. + recordSimToolMetric(toolCall.name, completion.status, durationMs) + return completion + } catch (err) { + // executeToolAndReportInner threw (infra/unexpected error, not a normal + // 'error' completion). Still stamp the span + record the dispatch so + // copilot.tool.* isn't silently biased toward successful calls. + const durationMs = Date.now() - startedAt + otelSpan.setAttribute(TraceAttr.ToolOutcome, 'error') + otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs) + recordSimToolMetric(toolCall.name, 'error', durationMs) + throw err } - return completion } ) } diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index e8a5be5877b..17c0544dcd4 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -12,6 +12,9 @@ const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({ vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById, +})) + +vi.mock('@/lib/table/rows/service', () => ({ replaceTableRows: mockReplaceTableRows, })) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index ec1b72d6fa5..3db2ab18c60 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -11,7 +11,8 @@ import { withCopilotSpan } from '@/lib/copilot/request/otel' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { getTableById, replaceTableRows } from '@/lib/table/service' +import { replaceTableRows } from '@/lib/table/rows/service' +import { getTableById } from '@/lib/table/service' const logger = createLogger('CopilotToolResultTables') diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index 7244214f190..e2e381f3f31 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { extractResourcesFromToolResult } from './extraction' +import { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult } from './extraction' describe('extractResourcesFromToolResult', () => { it('extracts file resources from create_file results', () => { @@ -141,4 +141,66 @@ describe('extractResourcesFromToolResult', () => { expect(resources).toEqual([]) }) + + it('auto-opens a scheduledtask resource from manage_scheduled_task create results', () => { + const resources = extractResourcesFromToolResult( + 'manage_scheduled_task', + { operation: 'create', args: { title: 'Daily Report' } }, + { jobId: 'sched_123', title: 'Daily Report', message: 'Job created successfully.' } + ) + + expect(resources).toEqual([{ type: 'scheduledtask', id: 'sched_123', title: 'Daily Report' }]) + }) + + it('auto-opens a scheduledtask resource on update, falling back to the args title', () => { + const resources = extractResourcesFromToolResult( + 'manage_scheduled_task', + { operation: 'update', args: { jobId: 'sched_123', title: 'Renamed Task' } }, + { jobId: 'sched_123', updated: ['title'], message: 'Job updated successfully' } + ) + + expect(resources).toEqual([{ type: 'scheduledtask', id: 'sched_123', title: 'Renamed Task' }]) + }) + + it('does not auto-open for read-only manage_scheduled_task operations', () => { + expect( + extractResourcesFromToolResult( + 'manage_scheduled_task', + { operation: 'list' }, + { jobs: [], count: 0 } + ) + ).toEqual([]) + expect( + extractResourcesFromToolResult( + 'manage_scheduled_task', + { operation: 'get', args: { jobId: 'sched_123' } }, + { id: 'sched_123', title: 'Daily Report' } + ) + ).toEqual([]) + }) +}) + +describe('extractDeletedResourcesFromToolResult', () => { + it('removes scheduledtask resources on manage_scheduled_task delete', () => { + const resources = extractDeletedResourcesFromToolResult( + 'manage_scheduled_task', + { operation: 'delete', args: { jobIds: ['sched_1', 'sched_2'] } }, + { deleted: ['sched_1', 'sched_2'], notFound: [] } + ) + + expect(resources).toEqual([ + { type: 'scheduledtask', id: 'sched_1', title: 'Scheduled Task' }, + { type: 'scheduledtask', id: 'sched_2', title: 'Scheduled Task' }, + ]) + }) + + it('does not remove anything for non-delete manage_scheduled_task ops', () => { + expect( + extractDeletedResourcesFromToolResult( + 'manage_scheduled_task', + { operation: 'update', args: { jobId: 'sched_1' } }, + { jobId: 'sched_1', updated: ['title'] } + ) + ).toEqual([]) + }) }) diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 20e77c91183..d9e311f4720 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -11,6 +11,7 @@ import { GenerateVideo, Knowledge, KnowledgeBase, + ManageScheduledTask, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -29,6 +30,7 @@ const RESOURCE_TOOL_NAMES: Set = new Set([ FunctionExecute.id, KnowledgeBase.id, Knowledge.id, + ManageScheduledTask.id, GenerateImage.id, GenerateVideo.id, GenerateAudio.id, @@ -219,6 +221,19 @@ export function extractResourcesFromToolResult( return resources } + case ManageScheduledTask.id: { + // Read-only ops never auto-open; only create/update surface the task. + const op = getOperation(params) + if (op === 'list' || op === 'get') return [] + const jobId = (result.jobId as string) ?? (data.jobId as string) + if (jobId) { + const args = asRecord(params?.args) + const title = (result.title as string) ?? (args.title as string) ?? 'Scheduled Task' + return [{ type: 'scheduledtask', id: jobId, title }] + } + return [] + } + default: return [] } @@ -229,6 +244,7 @@ const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { [WorkspaceFile.id]: 'file', [UserTable.id]: 'table', [KnowledgeBase.id]: 'knowledgebase', + [ManageScheduledTask.id]: 'scheduledtask', } export function hasDeleteCapability(toolName: string): boolean { @@ -292,6 +308,12 @@ export function extractDeletedResourcesFromToolResult( return [] } + case ManageScheduledTask.id: { + if (operation !== 'delete') return [] + const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : [] + return deletedIds.map((id) => ({ type: resourceType, id, title: 'Scheduled Task' })) + } + default: return [] } diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/copilot/resources/persistence.ts index 0407e61dd38..c389adc6491 100644 --- a/apps/sim/lib/copilot/resources/persistence.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq, sql } from 'drizzle-orm' -import type { MothershipResource } from './types' +import { GENERIC_RESOURCE_TITLES, type MothershipResource } from './types' export { extractDeletedResourcesFromToolResult, @@ -42,7 +42,6 @@ export async function persistChatResources( const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] const map = new Map() - const GENERIC = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log']) for (const r of existing) { map.set(`${r.type}:${r.id}`, r) @@ -51,7 +50,10 @@ export async function persistChatResources( for (const r of toMerge) { const key = `${r.type}:${r.id}` const prev = map.get(key) - if (!prev || (GENERIC.has(prev.title) && !GENERIC.has(r.title))) { + if ( + !prev || + (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(r.title)) + ) { map.set(key, r) } } diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index ff1703ebbf9..41e0fee863b 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -6,6 +6,7 @@ export const MothershipResourceType = { folder: 'folder', filefolder: 'filefolder', task: 'task', + scheduledtask: 'scheduledtask', log: 'log', integration: 'integration', generic: 'generic', @@ -24,10 +25,22 @@ export function isEphemeralResource(resource: MothershipResource): boolean { return resource.type === 'generic' || resource.id === 'streaming-file' } +/** Placeholder resource titles that a more specific title may overwrite during dedup. */ +export const GENERIC_RESOURCE_TITLES = new Set([ + 'Table', + 'File', + 'Workflow', + 'Knowledge Base', + 'Folder', + 'Scheduled Task', + 'Log', +]) + export const VFS_DIR_TO_RESOURCE: Record = { tables: 'table', files: 'file', workflows: 'workflow', knowledgebases: 'knowledgebase', folders: 'folder', + jobs: 'scheduledtask', } as const diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 41e31df57cf..48784f1a6bb 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { CheckDeploymentStatus, - CompleteJob, + CompleteScheduledTask, CreateFolder, CreateWorkflow, CreateWorkspaceMcpServer, @@ -30,8 +30,8 @@ import { LoadDeployment, ManageCredential, ManageCustomTool, - ManageJob, ManageMcpTool, + ManageScheduledTask, ManageSkill, MaterializeFile, MoveFolder, @@ -51,7 +51,7 @@ import { SetBlockEnabled, SetGlobalWorkflowVariables, UpdateDeploymentVersion, - UpdateJobHistory, + UpdateScheduledTaskHistory, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' @@ -177,9 +177,9 @@ function buildHandlerMap(): Record { [PromoteToLive.id]: h(executePromoteToLive), [UpdateDeploymentVersion.id]: h(executeUpdateDeploymentVersion), - [ManageJob.id]: h(executeManageJob), - [CompleteJob.id]: h(executeCompleteJob), - [UpdateJobHistory.id]: h(executeUpdateJobHistory), + [ManageScheduledTask.id]: h(executeManageJob), + [CompleteScheduledTask.id]: h(executeCompleteJob), + [UpdateScheduledTaskHistory.id]: h(executeUpdateJobHistory), [GrepTool.id]: h(executeVfsGrep), [GlobTool.id]: h(executeVfsGlob), diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 3df97818c0e..895b218ffb7 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -2,8 +2,9 @@ import { createLogger } from '@sim/logger' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' -import { getTableById, listTables, queryRows } from '@/lib/table/service' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { queryRows } from '@/lib/table/rows/service' +import { getTableById, listTables } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer, @@ -70,10 +71,11 @@ async function resolveInputFiles( ): Promise { const sandboxFiles: SandboxFile[] = [] let totalSize = 0 + const betaEnabled = await isFeatureEnabled('mothership-beta') if (inputFiles?.length && workspaceId) { const allFiles = await listWorkspaceFiles(workspaceId, { - includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + includeReservedSystemFiles: betaEnabled, }) for (const fileRef of inputFiles) { const filePath = @@ -135,11 +137,11 @@ async function resolveInputFiles( if (inputDirectories?.length && workspaceId) { const folders = await listWorkspaceFileFolders(workspaceId, { - includeReservedSystemFolders: isMothershipBetaFeaturesEnabled, + includeReservedSystemFolders: betaEnabled, }) const allFiles = await listWorkspaceFiles(workspaceId, { folders, - includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + includeReservedSystemFiles: betaEnabled, }) for (const dirRef of inputDirectories) { const dirPath = diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 885e57e30ce..e7a970f3992 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,3 +1,6 @@ +import { db } from '@sim/db' +import { workflowSchedule } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' @@ -83,6 +86,26 @@ async function resolveResource( }) title = `${workflowName} — ${timestamp}` } + if (resourceType === 'scheduledtask') { + if (!item.id) return { error: 'scheduledtask resources require `id`.' } + if (!context.workspaceId) + return { error: 'Opening a scheduled task requires workspace context.' } + const [schedule] = await db + .select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle }) + .from(workflowSchedule) + .where( + and( + eq(workflowSchedule.id, item.id), + eq(workflowSchedule.sourceWorkspaceId, context.workspaceId), + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt) + ) + ) + .limit(1) + if (!schedule) return { error: `No scheduled task with id "${item.id}".` } + resourceId = schedule.id + title = schedule.jobTitle || 'Scheduled Task' + } return { type: resourceType, id: resourceId, title } } diff --git a/apps/sim/lib/copilot/tools/mcp/definitions.ts b/apps/sim/lib/copilot/tools/mcp/definitions.ts index bdbbb635244..8adede9671b 100644 --- a/apps/sim/lib/copilot/tools/mcp/definitions.ts +++ b/apps/sim/lib/copilot/tools/mcp/definitions.ts @@ -240,7 +240,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [ lifecycle: { type: 'string', description: - '"persistent" (default, runs indefinitely) or "until_complete" (runs until complete_job is called).', + '"persistent" (default, runs indefinitely) or "until_complete" (runs until complete_scheduled_task is called).', }, successCondition: { type: 'string', @@ -443,9 +443,9 @@ Supports full and partial execution: }, { name: 'sim_job', - agentId: 'job', + agentId: 'scheduled_task', description: - 'Manage scheduled background jobs. Supports creating, listing, updating, pausing, resuming, and deleting jobs that run prompts against Sim on a schedule or at a specific time.', + 'Manage scheduled tasks. Supports creating, listing, updating, pausing, resuming, and deleting scheduled tasks that run prompts against Sim on a schedule or at a specific time.', inputSchema: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 1e1e5c30332..f2fc3846ada 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -5,7 +5,7 @@ import { toError } from '@sim/utils/errors' import { z } from 'zod' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index 0227357908f..6a12632149b 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getAllBlocks } from '@/blocks/registry' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 920830f193b..f61c917c9b7 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' import { @@ -53,7 +53,7 @@ export interface E2BDocFormat { * pptx/docx → node, pdf/xlsx → python. Only meaningful when the E2B doc sandbox * is enabled; callers gate on isE2BDocEnabled before using this. */ -export function getE2BDocFormat(fileName: string): E2BDocFormat | null { +export async function getE2BDocFormat(fileName: string): Promise { const l = fileName.toLowerCase() if (l.endsWith('.pptx')) return { @@ -79,10 +79,10 @@ export function getE2BDocFormat(fileName: string): E2BDocFormat | null { contentType: PDF_MIME, sourceMime: PYTHON_PDF_SOURCE_MIME, } - // xlsx is gated behind the mothership beta flag (like plans/changelog): the + // xlsx is gated behind the mothership-beta feature flag (like plans/changelog): the // skill + prompt are gated on the Go side, and this is the single Sim chokepoint // that keeps the compile/serve/check/recalc paths off for xlsx when beta is off. - if (l.endsWith('.xlsx') && isMothershipBetaFeaturesEnabled) + if (l.endsWith('.xlsx') && (await isFeatureEnabled('mothership-beta'))) return { ext: 'xlsx', engine: 'python', @@ -385,7 +385,7 @@ export async function compileDoc( args: CompileArgs ): Promise<{ buffer: Buffer; contentType: string }> { const { source, fileName, workspaceId } = args - const fmt = getE2BDocFormat(fileName) + const fmt = await getE2BDocFormat(fileName) if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) const existing = await loadCompiledDoc(workspaceId, source, fmt.ext) @@ -409,7 +409,7 @@ export async function loadCompiledDocByExt( source: string, ext: string ): Promise<{ buffer: Buffer; contentType: string } | null> { - const fmt = getE2BDocFormat(`x.${ext}`) + const fmt = await getE2BDocFormat(`x.${ext}`) if (!fmt) return null const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext) return buffer ? { buffer, contentType: fmt.contentType } : null diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index e272146045a..8bedce47b41 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -5,7 +5,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getE2BDocFormat } from './doc-compile' import { consumeLatestFileIntent } from './file-intent-store' @@ -64,7 +64,7 @@ export const editContentServerTool: BaseServerTool { const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args const docInfo = getDocumentFormatInfo(fileName) - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(fileName) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileName) : null if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) { return { ok: false, message: isE2BDocEnabled - ? 'Excel (.xlsx) generation is currently behind a beta flag (MOTHERSHIP_BETA_FEATURES) and is not available.' + ? 'Excel (.xlsx) generation is currently behind the mothership-beta feature flag and is not available.' : 'Excel (.xlsx) generation requires the E2B document sandbox, which is not enabled in this environment.', } } diff --git a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts index 261a4f69065..bdead9b45bc 100644 --- a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts +++ b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { jobExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' -import { GetJobLogs } from '@/lib/copilot/generated/tool-catalog-v1' +import { GetScheduledTaskLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import type { TraceSpan } from '@/lib/logs/types' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -124,7 +124,7 @@ function extractOutputAndError( } export const getJobLogsServerTool: BaseServerTool = { - name: GetJobLogs.id, + name: GetScheduledTaskLogs.id, async execute(rawArgs: GetJobLogsArgs, context?: ServerToolContext): Promise { const withMessageId = (message: string) => context?.messageId ? `${message} [messageId:${context.messageId}]` : message diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 8ad2e0d6b2b..7d90e9c00a3 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -56,26 +56,39 @@ vi.mock('@/enrichments/registry', () => ({ })) vi.mock('@/lib/table/service', () => ({ - addTableColumn: vi.fn(), - addWorkflowGroup: mockAddWorkflowGroup, - batchInsertRows: mockBatchInsertRows, - batchUpdateRows: vi.fn(), createTable: mockCreateTable, + deleteTable: mockDeleteTable, + getTableById: mockGetTableById, + renameTable: vi.fn(), +})) + +vi.mock('@/lib/table/workflow-groups/service', () => ({ + addWorkflowGroup: mockAddWorkflowGroup, + addWorkflowGroupOutput: vi.fn(), + deleteWorkflowGroup: vi.fn(), + deleteWorkflowGroupOutput: vi.fn(), + updateWorkflowGroup: vi.fn(), +})) + +vi.mock('@/lib/table/columns/service', () => ({ + addTableColumn: vi.fn(), deleteColumn: vi.fn(), deleteColumns: vi.fn(), + renameColumn: vi.fn(), + updateColumnConstraints: vi.fn(), + updateColumnType: vi.fn(), +})) + +vi.mock('@/lib/table/rows/service', () => ({ + batchInsertRows: mockBatchInsertRows, + batchUpdateRows: vi.fn(), deleteRow: vi.fn(), deleteRowsByFilter: vi.fn(), deleteRowsByIds: vi.fn(), - deleteTable: mockDeleteTable, getRowById: vi.fn(), - getTableById: mockGetTableById, insertRow: vi.fn(), queryRows: vi.fn(), - renameColumn: vi.fn(), - renameTable: vi.fn(), replaceTableRows: mockReplaceTableRows, - updateColumnConstraints: vi.fn(), - updateColumnType: vi.fn(), updateRow: vi.fn(), updateRowsByFilter: vi.fn(), })) 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 07ca1ee82e1..7614dc63aea 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -30,32 +30,26 @@ import { import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' import { addTableColumn, - addWorkflowGroup, - addWorkflowGroupOutput, - batchInsertRows, - batchUpdateRows, - createTable, deleteColumn, deleteColumns, + renameColumn, + updateColumnConstraints, + updateColumnType, +} from '@/lib/table/columns/service' +import { + batchInsertRows, + batchUpdateRows, deleteRow, deleteRowsByFilter, deleteRowsByIds, - deleteTable, - deleteWorkflowGroup, - deleteWorkflowGroupOutput, getRowById, - getTableById, insertRow, queryRows, - renameColumn, - renameTable, replaceTableRows, - updateColumnConstraints, - updateColumnType, updateRow, updateRowsByFilter, - updateWorkflowGroup, -} from '@/lib/table/service' +} from '@/lib/table/rows/service' +import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service' import type { ColumnDefinition, RowData, @@ -67,6 +61,13 @@ import type { WorkflowGroupOutput, } from '@/lib/table/types' import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' +import { + addWorkflowGroup, + addWorkflowGroupOutput, + deleteWorkflowGroup, + deleteWorkflowGroupOutput, + updateWorkflowGroup, +} from '@/lib/table/workflow-groups/service' import { fetchWorkspaceFileBuffer, resolveWorkspaceFileReference, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 5493727f72d..9c929988030 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalizeConditionRouterIds } from './builders' @@ -92,7 +92,7 @@ vi.mock('@/lib/copilot/validation/selector-validator', () => ({ validateSelectorIds: mockValidateSelectorIds, })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@/providers/utils', () => ({ getHostedModels: () => [], diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index bf43f411f29..58d04785611 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -949,7 +949,7 @@ export async function preValidateCredentialInputs( context: { userId: string; workspaceId?: string }, workflowState?: Record ): Promise<{ filteredOperations: EditWorkflowOperation[]; errors: ValidationError[] }> { - const { isHosted } = await import('@/lib/core/config/feature-flags') + const { isHosted } = await import('@/lib/core/config/env-flags') const { getHostedModels } = await import('@/providers/utils') const logger = createLogger('PreValidateCredentials') diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index f2d65252a27..26388d5621a 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -9,6 +9,7 @@ import { import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { recordFileRead } from '@/lib/copilot/request/metrics' import { markSpanForError } from '@/lib/copilot/request/otel' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -284,7 +285,8 @@ export interface FileReadResult { * nests underneath for the image-resize path. */ export async function readFileRecord(record: WorkspaceFileRecord): Promise { - return getVfsTracer().startActiveSpan( + const startedAt = Date.now() + const result = await getVfsTracer().startActiveSpan( TraceSpan.CopilotVfsReadFile, { attributes: { @@ -414,4 +416,9 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { - if (!isMothershipBetaFeaturesEnabled) return null + if (!(await isFeatureEnabled('mothership-beta'))) return null if (!isPlanAliasPath(args.path)) return null let canonicalPath: string diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 8f710b75130..3b289b41081 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -27,6 +27,7 @@ import { import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' +import { recordVfsMaterialize } from '@/lib/copilot/request/metrics' import { markSpanForError } from '@/lib/copilot/request/otel' import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract' @@ -88,7 +89,8 @@ import { workspacePlanBackingPath, workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' -import { isE2BDocEnabled, isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -121,7 +123,7 @@ import { } from '@/lib/workspaces/permissions/utils' import { computeNeedsRedeployment } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' @@ -378,6 +380,7 @@ function getStaticComponentFiles(): Map { export class WorkspaceVFS { private files: Map = new Map() private _workspaceId = '' + private _betaEnabled = false get workspaceId(): string { return this._workspaceId @@ -392,6 +395,7 @@ export class WorkspaceVFS { const start = Date.now() this.files = new Map() this._workspaceId = workspaceId + this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) // Per-phase wall-clock, stamped on the span so a slow materialize in a // trace names its bottleneck instead of showing up as unattributed dead @@ -472,15 +476,30 @@ export class WorkspaceVFS { markSpanForError(span, err) throw err } finally { + // Record on success AND failure: a mid-phase failure (e.g. a DB + // timeout) still belongs in copilot.vfs.materialize.duration, else + // p50/p99 skew toward successes only. phaseMs holds whatever phases + // completed before the failure. + for (const [phase, ms] of Object.entries(phaseMs)) { + recordVfsMaterialize(phase, ms) + } + recordVfsMaterialize('total', Date.now() - start) span.end() } } ) + // Durable Grafana signal for "how long does VFS materialize" — total plus + // per-phase (bounded phase set). getOrMaterializeVFS runs per VFS tool call + // with no cross-request cache, so this reveals whether materialize is the + // bottleneck (observability only; not a fix). Recorded inside the span's + // finally above so a failed materialize is captured too, not just successes. + const totalMs = Date.now() - start + logger.info('VFS materialized', { workspaceId, fileCount: this.files.size, - durationMs: Date.now() - start, + durationMs: totalMs, phaseMs, }) } @@ -575,7 +594,7 @@ export class WorkspaceVFS { path: string, suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' ): Promise { - if (!isMothershipBetaFeaturesEnabled && isWorkflowAliasBackingPath(path)) { + if (!this._betaEnabled && isWorkflowAliasBackingPath(path)) { return null } const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) @@ -626,7 +645,7 @@ export class WorkspaceVFS { totalLines: 1, } } - if (isE2BDocEnabled && getE2BDocFormat(record.name)) { + if (isE2BDocEnabled && (await getE2BDocFormat(record.name))) { bin = ( await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) ).buffer @@ -679,7 +698,7 @@ export class WorkspaceVFS { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(record.name) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] if (!e2bFmt && !taskId) return null @@ -874,7 +893,7 @@ export class WorkspaceVFS { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(record.name) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) return null @@ -962,7 +981,7 @@ export class WorkspaceVFS { .replace(/\/content$/, '') .replace(/^\/+/, '') - if (!isMothershipBetaFeaturesEnabled && isWorkflowAliasBackingPath(fileReference)) { + if (!this._betaEnabled && isWorkflowAliasBackingPath(fileReference)) { return null } if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null @@ -972,7 +991,7 @@ export class WorkspaceVFS { try { const files = await listWorkspaceFiles(this._workspaceId, { scope, - includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + includeReservedSystemFiles: this._betaEnabled, }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null @@ -1005,7 +1024,7 @@ export class WorkspaceVFS { * Returns a summary for WORKSPACE.md generation. */ private async materializeWorkflows(workspaceId: string): Promise { - const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled + const workflowArtifactsEnabled = this._betaEnabled const [workflowRows, folderRows] = await Promise.all([ listWorkflows(workspaceId), listFolders(workspaceId), @@ -1388,7 +1407,7 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled + const workflowArtifactsEnabled = this._betaEnabled const folders = await listWorkspaceFileFolders(workspaceId, { includeReservedSystemFolders: true, }) diff --git a/apps/sim/lib/core/async-jobs/config.ts b/apps/sim/lib/core/async-jobs/config.ts index 5d32dc6fcd8..7f1e3797b34 100644 --- a/apps/sim/lib/core/async-jobs/config.ts +++ b/apps/sim/lib/core/async-jobs/config.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { taskContext } from '@trigger.dev/core/v3' import type { AsyncBackendType, JobQueueBackend } from '@/lib/core/async-jobs/types' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('AsyncJobsConfig') diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts new file mode 100644 index 00000000000..8fcdb841552 --- /dev/null +++ b/apps/sim/lib/core/config/env-flags.ts @@ -0,0 +1,317 @@ +/** + * Environment utility functions for consistent environment detection across the application + */ +import { env, getEnv, isFalsy, isTruthy } from './env' + +/** + * Is the application running in production mode + */ +export const isProd = env.NODE_ENV === 'production' + +/** + * Is the application running in development mode + */ +export const isDev = env.NODE_ENV === 'development' + +/** + * Is the application running in test mode + */ +export const isTest = env.NODE_ENV === 'test' + +/** + * Is this the hosted version of the application. + * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). + */ +const appUrl = getEnv('NEXT_PUBLIC_APP_URL') +let appHostname = '' +try { + appHostname = appUrl ? new URL(appUrl).hostname : '' +} catch { + // invalid URL — isHosted stays false +} +export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') + +/** + * Is billing enforcement enabled + */ +export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) + +/** + * Block free-plan accounts from programmatic workflow execution (API key, public + * API, MCP server, A2A agent server, generic webhooks, cross-origin chat embeds). + * Gated behind {@link isBillingEnabled}; off by default so the paywall can ship + * dark and be enabled per-deployment once verified. + */ +export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_ENABLED) + +/** + * Is email verification enabled + */ +export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED) + +/** + * Is authentication disabled (for self-hosted deployments behind private networks) + * This flag is blocked when isHosted is true. + */ +export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted + +if (isTruthy(env.DISABLE_AUTH)) { + import('@sim/logger') + .then(({ createLogger }) => { + const logger = createLogger('EnvFlags') + if (isHosted) { + logger.error( + 'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.' + ) + } else { + logger.warn( + 'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.' + ) + } + }) + .catch(() => { + // Fallback during config compilation when logger is unavailable + }) +} + +/** + * Is user registration disabled + */ +export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION) + +/** + * Is email/password authentication enabled (defaults to true) + */ +export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED) + +/** + * Is signup email validation enabled (disposable email blocking via better-auth-harmony) + */ +export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED) + +/** + * Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam + * mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on + * self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments. + */ +export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED) + +/** + * Is AWS AppConfig the source of truth for the signup/login gating lists. + * Hosted-only and requires both AppConfig identifiers (injected by the infra + * stack). Self-hosted/OSS deployments always use the env-var fallback, so the + * AppConfig client is never reached off-hosted. + */ +export const isAppConfigEnabled = + isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) + +/** + * Is Trigger.dev enabled for async job processing + */ +export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) + +/** + * Is SSO enabled for enterprise authentication + */ +export const isSsoEnabled = isTruthy(env.SSO_ENABLED) + +/** + * Is credential sets (email polling) enabled via env var override + * This bypasses plan requirements for self-hosted deployments + */ +export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED) + +/** + * Is access control (permission groups) enabled via env var override + * This bypasses plan requirements for self-hosted deployments + */ +export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED) + +/** + * Is organizations enabled + * True if billing is enabled (orgs come with billing), OR explicitly enabled via env var, + * OR if access control is enabled (access control requires organizations) + */ +export const isOrganizationsEnabled = + isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled + +/** + * Is inbox (Sim Mailer) enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) + +/** + * Is whitelabeling enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED) + +/** + * Is audit logs enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED) + +/** + * Is data retention enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED) + +/** + * Is data drains enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) + +/** + * Is E2B enabled for remote code execution + */ +export const isE2bEnabled = isTruthy(env.E2B_ENABLED) + +/** + * Whether the E2B document-generation sandbox is enabled. + * + * Requires E2B (with an API key) AND a dedicated doc-generation template id. + * When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node + * (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python + * (reportlab/openpyxl). When false, compilation stays on the JavaScript + * (isolated-vm) path, byte-identical to its prior behavior (and xlsx is + * unavailable). Drives both the Sim compile backend and the `docCompiler` flag + * sent to the copilot file subagent so the agent's output and compiler agree. + */ +export const isE2BDocEnabled = + isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) + +/** + * Whether Ollama is configured (OLLAMA_URL is set). + * When true, models that are not in the static cloud model list and have no + * slash-prefixed provider namespace are assumed to be Ollama models + * and do not require an API key. + */ +export const isOllamaConfigured = Boolean(env.OLLAMA_URL) + +/** + * Whether Azure OpenAI / Azure Anthropic credentials are pre-configured at the server level + * (via AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_ANTHROPIC_ENDPOINT, etc.). + * When true, the endpoint, API key, and API version fields are hidden in the Agent block UI. + * Set NEXT_PUBLIC_AZURE_CONFIGURED=true in self-hosted deployments on Azure. + */ +export const isAzureConfigured = isTruthy(getEnv('NEXT_PUBLIC_AZURE_CONFIGURED')) + +/** + * Whether a Cohere API key is pre-configured server-side for the Knowledge block reranker + * (`COHERE_API_KEY` or `COHERE_API_KEY_1/2/3`). When true, the Cohere API Key field is hidden + * in the Knowledge block UI. + * Set NEXT_PUBLIC_COHERE_CONFIGURED=true in self-hosted deployments that ship a Cohere key. + */ +export const isCohereConfigured = isTruthy(getEnv('NEXT_PUBLIC_COHERE_CONFIGURED')) + +/** + * Are invitations disabled globally + * When true, workspace invitations are disabled for all users + */ +export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS) + +/** + * Is public API access disabled globally + * When true, the public API toggle is hidden and public API access is blocked + */ +export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API) + +/** + * Is Google OAuth login disabled + * When true, the Google OAuth login button is hidden even when credentials are configured + */ +export const isGoogleAuthDisabled = isTruthy(env.DISABLE_GOOGLE_AUTH) + +/** + * Is GitHub OAuth login disabled + * When true, the GitHub OAuth login button is hidden even when credentials are configured + */ +export const isGithubAuthDisabled = isTruthy(env.DISABLE_GITHUB_AUTH) + +/** + * Is Microsoft OAuth login disabled + * When true, the Microsoft OAuth login button is hidden even when credentials are configured + */ +export const isMicrosoftAuthDisabled = isTruthy(env.DISABLE_MICROSOFT_AUTH) + +/** + * Is email/password signup disabled + * When true, new registrations via email/password are blocked at the server level. + * Existing users can still sign in with email/password. + */ +export const isEmailSignupDisabled = isTruthy(env.DISABLE_EMAIL_SIGNUP) + +/** + * Is React Grab enabled for UI element debugging + * When true and in development mode, enables React Grab for copying UI element context to clipboard + */ +export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) + +/** + * Is React Scan enabled for performance debugging + * When true and in development mode, enables React Scan for detecting render performance issues + */ +export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) + +/** + * Returns the parsed allowlist of integration block types from the environment variable. + * If not set or empty, returns null (meaning all integrations are allowed). + */ +export function getAllowedIntegrationsFromEnv(): string[] | null { + if (!env.ALLOWED_INTEGRATIONS) return null + const parsed = env.ALLOWED_INTEGRATIONS.split(',') + .map((i) => i.trim().toLowerCase()) + .filter(Boolean) + return parsed.length > 0 ? parsed : null +} + +/** + * Returns the list of blacklisted provider IDs from the environment variable. + * If not set or empty, returns an empty array (meaning no providers are blacklisted). + */ +export function getBlacklistedProvidersFromEnv(): string[] { + if (!env.BLACKLISTED_PROVIDERS) return [] + return env.BLACKLISTED_PROVIDERS.split(',') + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) +} + +/** + * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. + * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). + * Extracts the hostname in either case. + */ +function normalizeDomainEntry(entry: string): string { + const trimmed = entry.trim().toLowerCase() + if (!trimmed) return '' + if (trimmed.includes('://')) { + try { + return new URL(trimmed).hostname + } catch { + return trimmed + } + } + return trimmed +} + +/** + * Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var. + * Returns null if not set (all domains allowed), or parsed array of lowercase hostnames. + * Accepts both bare hostnames and full URLs in the env var value. + */ +export function getAllowedMcpDomainsFromEnv(): string[] | null { + if (!env.ALLOWED_MCP_DOMAINS) return null + const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean) + return parsed.length > 0 ? parsed : null +} + +/** + * Get cost multiplier based on environment + */ +export function getCostMultiplier(): number { + return isProd ? (env.COST_MULTIPLIER ?? 1) : 1 +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 378ad933d15..293c7560e57 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -248,6 +248,7 @@ export const env = createEnv({ ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod API_MAX_JSON_BODY_BYTES: z.string().optional().default('52428800'),// Default max JSON request body size for contract routes (50 MB) CHAT_MAX_REQUEST_BYTES: z.string().optional().default('230686720'),// Max request body size for the public deployed-chat endpoint (220 MB; covers 15 base64 file attachments) + WEBHOOK_MAX_REQUEST_BYTES: z.string().optional().default('10485760'),// Max request body size for public webhook receiver endpoints (10 MB; provider payloads rarely exceed a few MB) // Rate Limiting Configuration RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute) @@ -317,6 +318,8 @@ export const env = createEnv({ GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret DISABLE_GOOGLE_AUTH: z.boolean().optional(), // Disable Google OAuth login even when credentials are configured DISABLE_GITHUB_AUTH: z.boolean().optional(), // Disable GitHub OAuth login even when credentials are configured + DISABLE_MICROSOFT_AUTH: z.boolean().optional(), // Disable Microsoft OAuth login even when credentials are configured + DISABLE_EMAIL_SIGNUP: z.boolean().optional(), // Block new email/password registrations while keeping email login working X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts new file mode 100644 index 00000000000..ac5fa766a87 --- /dev/null +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FeatureFlagContext, FeatureFlagName } from '@/lib/core/config/feature-flags' + +const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockIsPlatformAdmin: vi.fn(), + envRef: { + APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, + APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, + }, + flagRef: { isAppConfigEnabled: false }, +})) + +vi.mock('@/lib/core/config/appconfig', () => ({ + fetchAppConfigProfile: mockFetch, +})) + +vi.mock('@/lib/core/config/env', () => ({ + isTruthy: (v: unknown) => Boolean(v), + get env() { + return envRef + }, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + get isAppConfigEnabled() { + return flagRef.isAppConfigEnabled + }, +})) + +vi.mock('@/lib/permissions/super-user', () => ({ + isPlatformAdmin: mockIsPlatformAdmin, +})) + +import { getFeatureFlags, isFeatureEnabled } from '@/lib/core/config/feature-flags' + +/** Make `getFeatureFlags` resolve to `doc` via the AppConfig path (also exercises parseConfig). */ +function withAppConfig(doc: unknown) { + flagRef.isAppConfigEnabled = true + mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) +} + +/** + * `isFeatureEnabled` only accepts registered `FeatureFlagName`s. The registry is + * empty in this PR, so tests reference flags through the AppConfig document and + * cast their throwaway names through this helper. + */ +const enabled = (flag: string, ctx?: FeatureFlagContext) => + isFeatureEnabled(flag as FeatureFlagName, ctx) + +describe('getFeatureFlags', () => { + beforeEach(() => { + vi.clearAllMocks() + flagRef.isAppConfigEnabled = false + }) + + it('derives flags from fallback secrets when AppConfig is disabled, without fetching', async () => { + const flags = await getFeatureFlags() + // All registered flags should be present, disabled (env vars unset in test env) + expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) + expect(flags['mothership-beta']).toEqual({ enabled: false }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('reads the feature-flags profile and normalizes the payload when enabled', async () => { + withAppConfig({ + a: { enabled: true }, + b: { orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' }, + c: 'not-an-object', + }) + + const flags = await getFeatureFlags() + expect(flags.a).toEqual({ enabled: true }) + expect(flags.b).toEqual({ orgIds: ['Org_1', 'org_1', 'org_2'] }) + expect(flags.c).toBeUndefined() + expect(mockFetch).toHaveBeenCalledWith( + { application: 'sim-staging', environment: 'staging', profile: 'feature-flags' }, + expect.any(Function) + ) + }) + + it('falls back to the secret-derived document when the fetch yields null', async () => { + flagRef.isAppConfigEnabled = true + mockFetch.mockResolvedValue(null) + const flags = await getFeatureFlags() + expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) + expect(flags['mothership-beta']).toEqual({ enabled: false }) + }) + + it('degrades gracefully on a malformed document', async () => { + withAppConfig('not-an-object') + expect(await getFeatureFlags()).toMatchObject({}) + withAppConfig(null) + expect(await getFeatureFlags()).toMatchObject({}) + }) +}) + +describe('isFeatureEnabled', () => { + beforeEach(() => { + vi.clearAllMocks() + flagRef.isAppConfigEnabled = false + }) + + it('returns false for an unknown flag', async () => { + withAppConfig({}) + expect(await enabled('missing', { userId: 'u1' })).toBe(false) + }) + + it('matches the global enabled clause', async () => { + withAppConfig({ f: { enabled: true } }) + expect(await enabled('f')).toBe(true) + }) + + it('matches the userId allowlist', async () => { + withAppConfig({ f: { userIds: ['u1'] } }) + expect(await enabled('f', { userId: 'u1' })).toBe(true) + expect(await enabled('f', { userId: 'u2' })).toBe(false) + expect(await enabled('f', {})).toBe(false) + }) + + it('matches the orgId allowlist', async () => { + withAppConfig({ f: { orgIds: ['o1'] } }) + expect(await enabled('f', { orgId: 'o1' })).toBe(true) + expect(await enabled('f', { orgId: 'o2' })).toBe(false) + }) + + describe('admin clause (lazy resolution)', () => { + it('resolves admin from userId when adminEnabled is the deciding clause', async () => { + withAppConfig({ f: { adminEnabled: true } }) + mockIsPlatformAdmin.mockResolvedValue(true) + expect(await enabled('f', { userId: 'u1' })).toBe(true) + expect(mockIsPlatformAdmin).toHaveBeenCalledWith('u1') + + mockIsPlatformAdmin.mockResolvedValue(false) + expect(await enabled('f', { userId: 'u2' })).toBe(false) + }) + + it('uses the isAdmin override without querying', async () => { + withAppConfig({ f: { adminEnabled: true } }) + expect(await enabled('f', { userId: 'u1', isAdmin: true })).toBe(true) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('resolves to false without querying when userId is absent', async () => { + withAppConfig({ f: { adminEnabled: true } }) + expect(await enabled('f', { orgId: 'o1' })).toBe(false) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('does not query when an earlier clause already matched', async () => { + withAppConfig({ f: { enabled: true, adminEnabled: true } }) + expect(await enabled('f', { userId: 'u1' })).toBe(true) + + withAppConfig({ g: { userIds: ['u1'], adminEnabled: true } }) + expect(await enabled('g', { userId: 'u1' })).toBe(true) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('does not query when the rule has no adminEnabled clause', async () => { + withAppConfig({ f: { userIds: ['u2'] } }) + expect(await enabled('f', { userId: 'u1' })).toBe(false) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 7c10e6fe927..d3bc89ad794 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,326 +1,183 @@ -/** - * Environment utility functions for consistent environment detection across the application - */ -import { env, getEnv, isFalsy, isTruthy } from './env' - -/** - * Is the application running in production mode - */ -export const isProd = env.NODE_ENV === 'production' - -/** - * Is the application running in development mode - */ -export const isDev = env.NODE_ENV === 'development' +import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' +import { env, isTruthy } from '@/lib/core/config/env' +import { isAppConfigEnabled } from '@/lib/core/config/env-flags' /** - * Is the application running in test mode + * Name of the AppConfig configuration profile holding the gated feature flags. + * Cross-repo contract: must match the `CfnConfigurationProfile` name created by + * the infra stack. */ -export const isTest = env.NODE_ENV === 'test' +const FEATURE_FLAGS_PROFILE = 'feature-flags' /** - * Is this the hosted version of the application. - * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). + * A single flag's gating rule. A flag is ON for a context when ANY clause matches: + * the global `enabled` default, the org/user allowlists, or `admins` for platform + * admins. An absent clause never matches. */ -const appUrl = getEnv('NEXT_PUBLIC_APP_URL') -let appHostname = '' -try { - appHostname = appUrl ? new URL(appUrl).hostname : '' -} catch { - // invalid URL — isHosted stays false +export interface FeatureFlagRule { + enabled?: boolean + orgIds?: string[] + userIds?: string[] + adminEnabled?: boolean } -export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') - -/** - * Is billing enforcement enabled - */ -export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) - -/** - * Block free-plan accounts from programmatic workflow execution (API key, public - * API, MCP server, A2A agent server, generic webhooks, cross-origin chat embeds). - * Gated behind {@link isBillingEnabled}; off by default so the paywall can ship - * dark and be enabled per-deployment once verified. - */ -export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_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) +export type FeatureFlagsConfig = Record /** - * Is email verification enabled + * Per-request evaluation context. Pass only the ids you have — a missing id skips + * its clause. Admin status is resolved internally from `userId`; `isAdmin` is an + * optional fast-path override for callers that already know it (e.g. admin routes). */ -export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED) - -/** - * Is authentication disabled (for self-hosted deployments behind private networks) - * This flag is blocked when isHosted is true. - */ -export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted - -if (isTruthy(env.DISABLE_AUTH)) { - import('@sim/logger') - .then(({ createLogger }) => { - const logger = createLogger('FeatureFlags') - if (isHosted) { - logger.error( - 'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.' - ) - } else { - logger.warn( - 'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.' - ) - } - }) - .catch(() => { - // Fallback during config compilation when logger is unavailable - }) +export interface FeatureFlagContext { + userId?: string | null + orgId?: string | null + isAdmin?: boolean } /** - * Is user registration disabled - */ -export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION) - -/** - * Is email/password authentication enabled (defaults to true) - */ -export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED) - -/** - * Is signup email validation enabled (disposable email blocking via better-auth-harmony) - */ -export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED) - -/** - * Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam - * mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on - * self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments. - */ -export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED) - -/** - * Is AWS AppConfig the source of truth for the signup/login gating lists. - * Hosted-only and requires both AppConfig identifiers (injected by the infra - * stack). Self-hosted/OSS deployments always use the env-var fallback, so the - * AppConfig client is never reached off-hosted. - */ -export const isAppConfigEnabled = - isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) - -/** - * Is Trigger.dev enabled for async job processing - */ -export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) - -/** - * Is SSO enabled for enterprise authentication - */ -export const isSsoEnabled = isTruthy(env.SSO_ENABLED) - -/** - * Is credential sets (email polling) enabled via env var override - * This bypasses plan requirements for self-hosted deployments - */ -export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED) - -/** - * Is access control (permission groups) enabled via env var override - * This bypasses plan requirements for self-hosted deployments - */ -export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED) - -/** - * Is organizations enabled - * True if billing is enabled (orgs come with billing), OR explicitly enabled via env var, - * OR if access control is enabled (access control requires organizations) - */ -export const isOrganizationsEnabled = - isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled - -/** - * Is inbox (Sim Mailer) enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) - -/** - * Is whitelabeling enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED) - -/** - * Is audit logs enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED) - -/** - * Is data retention enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED) - -/** - * Is data drains enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) - -/** - * Are workflow output columns enabled in user tables. - * Defaults to false; set NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED=true to show - * the "Workflow" column type in the new-column dropdown. - */ -export const isWorkflowColumnsEnabledClient = isTruthy( - getEnv('NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED') -) - -/** - * Enables beta Mothership plan/changelog artifact surfaces. - */ -export const isMothershipBetaFeaturesEnabled = isTruthy(env.MOTHERSHIP_BETA_FEATURES) - -/** - * Is E2B enabled for remote code execution - */ -export const isE2bEnabled = isTruthy(env.E2B_ENABLED) - -/** - * Whether the E2B document-generation sandbox is enabled. + * Registry of known feature flags. Each maps to the secret consulted ONLY when + * AppConfig is not the source of truth (self-hosted/OSS, local dev, or hosted + * without APPCONFIG_*). A truthy secret turns the flag on globally. * - * Requires E2B (with an API key) AND a dedicated doc-generation template id. - * When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node - * (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python - * (reportlab/openpyxl). When false, compilation stays on the JavaScript - * (isolated-vm) path, byte-identical to its prior behavior (and xlsx is - * unavailable). Drives both the Sim compile backend and the `docCompiler` flag - * sent to the copilot file subagent so the agent's output and compiler agree. - */ -export const isE2BDocEnabled = - isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) - -/** - * Whether Ollama is configured (OLLAMA_URL is set). - * When true, models that are not in the static cloud model list and have no - * slash-prefixed provider namespace are assumed to be Ollama models - * and do not require an API key. + * Gating by org/user/admin is available ONLY through the hosted AppConfig document + * — it deliberately cannot be expressed here, so no environment can grant (e.g.) + * admin access from a code literal. To add a flag, register its name and the secret + * to fall back on. */ -export const isOllamaConfigured = Boolean(env.OLLAMA_URL) - -/** - * Whether Azure OpenAI / Azure Anthropic credentials are pre-configured at the server level - * (via AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_ANTHROPIC_ENDPOINT, etc.). - * When true, the endpoint, API key, and API version fields are hidden in the Agent block UI. - * Set NEXT_PUBLIC_AZURE_CONFIGURED=true in self-hosted deployments on Azure. - */ -export const isAzureConfigured = isTruthy(getEnv('NEXT_PUBLIC_AZURE_CONFIGURED')) - -/** - * Whether a Cohere API key is pre-configured server-side for the Knowledge block reranker - * (`COHERE_API_KEY` or `COHERE_API_KEY_1/2/3`). When true, the Cohere API Key field is hidden - * in the Knowledge block UI. - * Set NEXT_PUBLIC_COHERE_CONFIGURED=true in self-hosted deployments that ship a Cohere key. - */ -export const isCohereConfigured = isTruthy(getEnv('NEXT_PUBLIC_COHERE_CONFIGURED')) - /** - * Are invitations disabled globally - * When true, workspace invitations are disabled for all users - */ -export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS) - -/** - * Is public API access disabled globally - * When true, the public API toggle is hidden and public API access is blocked - */ -export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API) - -/** - * Is Google OAuth login disabled - * When true, the Google OAuth login button is hidden even when credentials are configured - */ -export const isGoogleAuthDisabled = isTruthy(env.DISABLE_GOOGLE_AUTH) + * The single definition of a feature flag. Everything about a flag lives in one + * place: its name (the registry key), a human-readable `description`, and the + * `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on + * globally). + * + * Gating by org/user/admin is deliberately NOT part of a definition — it lives only + * in the hosted AppConfig document, so no environment can grant access from a code + * literal. + */ +interface FeatureFlagDefinition { + description: string + /** Env/secret key consulted when AppConfig isn't the source of truth. Truthy ⇒ on. */ + fallback: keyof typeof env +} -/** - * Is GitHub OAuth login disabled - * When true, the GitHub OAuth login button is hidden even when credentials are configured - */ -export const isGithubAuthDisabled = isTruthy(env.DISABLE_GITHUB_AUTH) +/** The single registry of known flags. To add a flag, add one entry here. */ +const FEATURE_FLAGS = { + 'tables-fractional-ordering': { + description: 'Order table rows by fractional order_key instead of legacy integer position', + fallback: 'TABLES_FRACTIONAL_ORDERING', + }, + 'mothership-beta': { + description: + 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler. ' + + 'Note: userId/orgId targeting only works for WorkspaceVfs (resolved in materialize). ' + + 'getE2BDocFormat, resolveInputFiles, and resolveWorkflowAliasForWorkspace evaluate without ' + + 'user context — use enabled:true for global rollout rather than per-user targeting.', + fallback: 'MOTHERSHIP_BETA_FEATURES', + }, +} satisfies Record + +/** + * The closed set of known feature flags. Derived from the registry, so a flag + * cannot exist — or be checked — without a definition (and its mandatory fallback). + */ +export type FeatureFlagName = keyof typeof FEATURE_FLAGS + +/** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */ +function fallbackFlags(): FeatureFlagsConfig { + const flags: FeatureFlagsConfig = {} + for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array< + [string, FeatureFlagDefinition] + >) { + flags[name] = { enabled: isTruthy(env[def.fallback]) } + } + return flags +} -/** - * Is React Grab enabled for UI element debugging - * When true and in development mode, enables React Grab for copying UI element context to clipboard - */ -export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) +function normalizeIds(values: unknown): string[] | undefined { + if (!Array.isArray(values)) return undefined + const ids = Array.from(new Set(values.map((v) => String(v).trim()).filter(Boolean))) + return ids.length > 0 ? ids : undefined +} -/** - * Is React Scan enabled for performance debugging - * When true and in development mode, enables React Scan for detecting render performance issues - */ -export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) +function normalizeRule(value: unknown): FeatureFlagRule | null { + if (!value || typeof value !== 'object') return null + const obj = value as Record + const rule: FeatureFlagRule = {} + if (typeof obj.enabled === 'boolean') rule.enabled = obj.enabled + if (typeof obj.adminEnabled === 'boolean') rule.adminEnabled = obj.adminEnabled + const orgIds = normalizeIds(obj.orgIds) + if (orgIds) rule.orgIds = orgIds + const userIds = normalizeIds(obj.userIds) + if (userIds) rule.userIds = userIds + return rule +} -/** - * Returns the parsed allowlist of integration block types from the environment variable. - * If not set or empty, returns null (meaning all integrations are allowed). - */ -export function getAllowedIntegrationsFromEnv(): string[] | null { - if (!env.ALLOWED_INTEGRATIONS) return null - const parsed = env.ALLOWED_INTEGRATIONS.split(',') - .map((i) => i.trim().toLowerCase()) - .filter(Boolean) - return parsed.length > 0 ? parsed : null +/** Coerce an arbitrary AppConfig/JSON value into a config, dropping malformed entries. */ +function parseConfig(json: unknown): FeatureFlagsConfig { + const obj = (json && typeof json === 'object' ? json : {}) as Record + const flags: FeatureFlagsConfig = {} + for (const [name, value] of Object.entries(obj)) { + const rule = normalizeRule(value) + if (rule) flags[name] = rule + } + return flags } /** - * Returns the list of blacklisted provider IDs from the environment variable. - * If not set or empty, returns an empty array (meaning no providers are blacklisted). + * Resolve platform-admin status lazily. Dynamically imported so the DB-backed + * helper (and `@sim/db`) stay out of this config module's load graph for callers + * that never reach an admin-gated flag. */ -export function getBlacklistedProvidersFromEnv(): string[] { - if (!env.BLACKLISTED_PROVIDERS) return [] - return env.BLACKLISTED_PROVIDERS.split(',') - .map((p) => p.trim().toLowerCase()) - .filter(Boolean) +async function resolveAdmin(userId: string): Promise { + const { isPlatformAdmin } = await import('@/lib/permissions/super-user') + return isPlatformAdmin(userId) } /** - * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. - * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). - * Extracts the hostname in either case. - */ -function normalizeDomainEntry(entry: string): string { - const trimmed = entry.trim().toLowerCase() - if (!trimmed) return '' - if (trimmed.includes('://')) { - try { - return new URL(trimmed).hostname - } catch { - return trimmed - } + * The admin clause is resolved last and lazily: a global/userId/orgId match + * short-circuits before any DB read, a rule without `admins` never queries, and a + * missing `userId` resolves to `false` without a query. + */ +async function evaluate( + rule: FeatureFlagRule | undefined, + ctx: FeatureFlagContext +): Promise { + if (!rule) return false + if (rule.enabled) return true + if (ctx.userId && rule.userIds?.includes(ctx.userId)) return true + if (ctx.orgId && rule.orgIds?.includes(ctx.orgId)) return true + if (rule.adminEnabled) { + const admin = ctx.isAdmin ?? (ctx.userId ? await resolveAdmin(ctx.userId) : false) + if (admin) return true } - return trimmed + return false } /** - * Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var. - * Returns null if not set (all domains allowed), or parsed array of lowercase hostnames. - * Accepts both bare hostnames and full URLs in the env var value. + * Resolve the full flag document. Reads from AWS AppConfig on hosted deployments + * (cached, ~30s TTL, never blocks after the first fetch), otherwise derives each + * flag's on/off state from its registered fallback secret ({@link fallbackFlags}). */ -export function getAllowedMcpDomainsFromEnv(): string[] | null { - if (!env.ALLOWED_MCP_DOMAINS) return null - const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean) - return parsed.length > 0 ? parsed : null +export async function getFeatureFlags(): Promise { + if (!isAppConfigEnabled) return fallbackFlags() + + const value = await fetchAppConfigProfile( + { + application: env.APPCONFIG_APPLICATION as string, + environment: env.APPCONFIG_ENVIRONMENT as string, + profile: FEATURE_FLAGS_PROFILE, + }, + parseConfig + ) + + return value ?? fallbackFlags() } -/** - * Get cost multiplier based on environment - */ -export function getCostMultiplier(): number { - return isProd ? (env.COST_MULTIPLIER ?? 1) : 1 +/** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */ +export async function isFeatureEnabled( + flag: FeatureFlagName, + ctx: FeatureFlagContext = {} +): Promise { + const flags = await getFeatureFlags() + return evaluate(flags[flag], ctx) } diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index 774e87ac1ea..ae1174aadb3 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -1,6 +1,6 @@ import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { env } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' interface ExecutionTimeoutConfig { diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index 23f0f90b892..a53a29b77ae 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -3,7 +3,7 @@ import { RateLimiter } from './rate-limiter' import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage' import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types' -vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) interface MockAdapter { consumeTokens: Mock diff --git a/apps/sim/lib/core/rate-limiter/types.ts b/apps/sim/lib/core/rate-limiter/types.ts index 268b5e87805..dbf023af410 100644 --- a/apps/sim/lib/core/rate-limiter/types.ts +++ b/apps/sim/lib/core/rate-limiter/types.ts @@ -1,6 +1,6 @@ import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { env } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { CoreTriggerType } from '@/stores/logs/filters/types' import type { TokenBucketConfig } from './storage' diff --git a/apps/sim/lib/core/security/csp.test.ts b/apps/sim/lib/core/security/csp.test.ts index 9f315502262..a91c97d6501 100644 --- a/apps/sim/lib/core/security/csp.test.ts +++ b/apps/sim/lib/core/security/csp.test.ts @@ -1,4 +1,4 @@ -import { createEnvMock, featureFlagsMock } from '@sim/testing' +import { createEnvMock, envFlagsMock } from '@sim/testing' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => @@ -17,7 +17,7 @@ vi.mock('@/lib/core/config/env', () => }) ) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) import { addCSPSource, diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 2dc60412c62..01ba261f8f7 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -1,5 +1,5 @@ import { env, getEnv } from '../config/env' -import { isDev, isHosted, isReactGrabEnabled } from '../config/feature-flags' +import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags' /** * Content Security Policy (CSP) configuration builder diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index aabab928be7..7bc03b2537b 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -3,7 +3,7 @@ import { sha256Hex } from '@sim/security/hash' import { hmacSha256Hex } from '@sim/security/hmac' import type { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' -import { isDev } from '@/lib/core/config/feature-flags' +import { isDev } from '@/lib/core/config/env-flags' /** * Shared authentication utilities for deployed chat endpoints. diff --git a/apps/sim/lib/core/security/empty-node-fallback.browser.ts b/apps/sim/lib/core/security/empty-node-fallback.browser.ts deleted file mode 100644 index f42bee26752..00000000000 --- a/apps/sim/lib/core/security/empty-node-fallback.browser.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Browser fallback for Node-only builtins (e.g. `dns/promises`) that get pulled - * into the client bundle by server-only code which never executes in the - * browser — notably the connector registry, whose `ConnectorConfig` objects are - * imported by client UI for metadata while their `listDocuments`/`getDocument` - * fetch logic (which transitively imports `input-validation.server`) only ever - * runs in server API routes. - * - * Wired in via `turbopack.resolveAlias` with the `browser` condition only, so - * the real Node module is still resolved on the server and SSRF validation - * remains fully intact. See `next.config.ts`. - */ -export default {} diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index cdf5f28a9a1..bd344e38997 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -5,7 +5,8 @@ import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' -import { isHosted } from '@/lib/core/config/feature-flags' +import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' +import { isHosted } from '@/lib/core/config/env-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -400,6 +401,40 @@ export function createPinnedLookup(resolvedIP: string): LookupFunction { } } +/** + * Builds a standard `fetch`-compatible function that pins every outbound + * connection to `resolvedIP`, preventing DNS-rebinding (TOCTOU) between URL + * validation and connection. The original hostname is preserved for TLS SNI and + * the `Host` header so it still matches the certificate. This is the single + * source of truth for pinned outbound fetches — both the LLM providers and the + * MCP transport consume it. + * + * Pass the returned function as the `fetch` option to the OpenAI/Anthropic SDKs + * (or call it directly) after validating the URL with {@link validateUrlWithDNS} + * and capturing `resolvedIP`. Because the pinned lookup always returns + * `resolvedIP` regardless of hostname, any redirect the server returns also + * connects to the validated IP — an attacker cannot rebind a redirect target to + * an internal address. + * + * The `Agent` is captured for the lifetime of the returned function, so repeated + * calls (e.g. a provider tool loop) reuse its keep-alive connections. + */ +export function createPinnedFetch(resolvedIP: string): typeof fetch { + const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(resolvedIP) } }) + + const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + // double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici) + const undiciInput = input as unknown as Parameters[0] + // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ + const undiciInit: UndiciRequestInit = { ...(init as unknown as UndiciRequestInit), dispatcher } + const response = await undiciFetch(undiciInput, undiciInit) + // double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime + return response as unknown as Response + } + + return pinned +} + /** * Performs a fetch with IP pinning to prevent DNS rebinding attacks. * Uses the pre-resolved IP address while preserving the original hostname for TLS SNI. diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 55b07a72db0..7e8be4caa12 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,4 +1,4 @@ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { validateAirtableId, @@ -32,7 +32,7 @@ import { } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) describe('validatePathSegment', () => { describe('valid inputs', () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 98ac9e1c982..fb73b300560 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import * as ipaddr from 'ipaddr.js' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('InputValidation') @@ -627,6 +627,27 @@ export function validateJiraCloudId( }) } +/** + * Validates an Atlassian Assets workspace ID (a UUID-shaped, hyphenated + * alphanumeric identifier) before it is interpolated into an API path. + * + * @param value - The Assets workspace ID to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + */ +export function validateAssetsWorkspaceId( + value: string | null | undefined, + paramName = 'workspaceId' +): ValidationResult { + return validatePathSegment(value, { + paramName, + allowHyphens: true, + allowUnderscores: false, + allowDots: false, + maxLength: 100, + }) +} + /** * Validates Jira issue keys (format: PROJECT-123 or PROJECT-KEY-123) * diff --git a/apps/sim/lib/core/security/pinned-fetch.server.test.ts b/apps/sim/lib/core/security/pinned-fetch.server.test.ts new file mode 100644 index 00000000000..e07d0d412dd --- /dev/null +++ b/apps/sim/lib/core/security/pinned-fetch.server.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { envFlagsMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoisted(() => { + const capturedAgentOptions: unknown[] = [] + const agentCloses: unknown[] = [] + class MockAgent { + constructor(options: unknown) { + capturedAgentOptions.push(options) + } + close() { + agentCloses.push(this) + return Promise.resolve() + } + } + return { + mockAgent: MockAgent, + mockUndiciFetch: vi.fn(), + capturedAgentOptions, + agentCloses, + } +}) + +vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) + +import { createPinnedFetch } from '@/lib/core/security/input-validation.server' + +type LookupCallback = (err: Error | null, address: string, family: number) => void +type PinnedLookup = (hostname: string, options: { all?: boolean }, callback: LookupCallback) => void + +describe('createPinnedFetch', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedAgentOptions.length = 0 + agentCloses.length = 0 + mockUndiciFetch.mockResolvedValue(new Response('ok')) + }) + + it('builds an undici Agent whose pinned lookup always resolves to the validated IP', async () => { + createPinnedFetch('203.0.113.10') + + expect(capturedAgentOptions).toHaveLength(1) + const { connect } = capturedAgentOptions[0] as { connect: { lookup: PinnedLookup } } + expect(typeof connect.lookup).toBe('function') + + const resolved = await new Promise<{ address: string; family: number }>((resolve) => { + connect.lookup('rebind.attacker.tld', {}, (_err, address, family) => + resolve({ address, family }) + ) + }) + expect(resolved).toEqual({ address: '203.0.113.10', family: 4 }) + }) + + it('uses IPv6 family when the validated IP is IPv6', async () => { + createPinnedFetch('2606:4700:4700::1111') + const { connect } = capturedAgentOptions[0] as { connect: { lookup: PinnedLookup } } + const resolved = await new Promise<{ address: string; family: number }>((resolve) => { + connect.lookup('example.com', {}, (_err, address, family) => resolve({ address, family })) + }) + expect(resolved).toEqual({ address: '2606:4700:4700::1111', family: 6 }) + }) + + it('forwards the pinned dispatcher on every call while preserving init options', async () => { + const pinned = createPinnedFetch('203.0.113.10') + const controller = new AbortController() + + await pinned('https://myresource.openai.azure.com/openai/v1/responses', { + method: 'POST', + headers: { 'api-key': 'secret' }, + body: '{}', + signal: controller.signal, + }) + + expect(mockUndiciFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockUndiciFetch.mock.calls[0] + expect(url).toBe('https://myresource.openai.azure.com/openai/v1/responses') + const typedInit = init as RequestInit & { dispatcher?: unknown } + expect(typedInit.dispatcher).toBeInstanceOf(mockAgent) + expect(typedInit.method).toBe('POST') + expect(typedInit.headers).toEqual({ 'api-key': 'secret' }) + expect(typedInit.body).toBe('{}') + expect(typedInit.signal).toBe(controller.signal) + }) + + it('handles an undefined init by still attaching the dispatcher', async () => { + const pinned = createPinnedFetch('203.0.113.10') + await pinned('https://example.com') + const init = mockUndiciFetch.mock.calls[0][1] as { dispatcher?: unknown } + expect(init.dispatcher).toBeInstanceOf(mockAgent) + }) + + it('reuses one captured dispatcher across all calls of a single instance', async () => { + const pinned = createPinnedFetch('203.0.113.10') + await pinned('https://example.com/a') + await pinned('https://example.com/b') + + expect(capturedAgentOptions).toHaveLength(1) + const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher + const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher + expect(d1).toBe(d2) + }) + + it('creates an independent dispatcher per instance', async () => { + const a = createPinnedFetch('203.0.113.10') + const b = createPinnedFetch('203.0.113.10') + await a('https://example.com/a') + await b('https://example.com/b') + + expect(capturedAgentOptions).toHaveLength(2) + const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher + const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher + expect(d1).not.toBe(d2) + }) + + it('returns the response produced by undici fetch', async () => { + mockUndiciFetch.mockResolvedValueOnce(new Response('pong', { status: 201 })) + const pinned = createPinnedFetch('203.0.113.10') + const response = await pinned('https://example.com') + expect(response.status).toBe(201) + expect(await response.text()).toBe('pong') + }) +}) diff --git a/apps/sim/lib/core/security/same-origin.test.ts b/apps/sim/lib/core/security/same-origin.test.ts new file mode 100644 index 00000000000..6b4c9f4f993 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin' + +function makeRequest(headers: Record): NextRequest { + return { headers: new Headers(headers) } as unknown as NextRequest +} + +describe('isCrossSiteSessionRequest', () => { + it('rejects cross-site requests', () => { + expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true) + }) + + it('allows same-origin browser fetches', () => { + expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(false) + }) + + it('allows same-site fetches (sibling subdomains, e.g. www. -> )', () => { + expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(false) + }) + + it('allows user-initiated requests (Sec-Fetch-Site: none)', () => { + expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(false) + }) + + it('allows requests with no Sec-Fetch-Site header (older clients)', () => { + expect(isCrossSiteSessionRequest(makeRequest({}))).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/security/same-origin.ts b/apps/sim/lib/core/security/same-origin.ts new file mode 100644 index 00000000000..1fb605ef297 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.ts @@ -0,0 +1,23 @@ +import type { NextRequest } from 'next/server' + +/** + * Returns true when a request is provably cross-site — a browser fetch driven + * from a different site than our own. Used to reject session-cookie CSRF on + * state-changing routes. + * + * `Sec-Fetch-Site` is browser-set and a forbidden header, so page JavaScript + * cannot forge it. A cross-site browser request (the CSRF threat) always reports + * `cross-site`. We deliberately accept `same-origin`, `same-site`, and `none`: + * the app is served across sibling subdomains (e.g. `www.` calling + * ``), so a legitimate `same-site` fetch must NOT be blocked — rejecting + * it 403s real "Run" requests on those origins. An absent header (older clients) + * is also allowed; the conventional CSRF posture is to reject only a provable + * cross-site request. + * + * This is CSRF protection only. It does not defend against a non-browser client + * that forges headers directly (no header check can); that surface is covered by + * the credit and execution rate-limit gates. + */ +export function isCrossSiteSessionRequest(req: NextRequest): boolean { + return req.headers.get('sec-fetch-site') === 'cross-site' +} diff --git a/apps/sim/lib/core/utils/urls.test.ts b/apps/sim/lib/core/utils/urls.test.ts index 689f5b51a9b..2878a49feb1 100644 --- a/apps/sim/lib/core/utils/urls.test.ts +++ b/apps/sim/lib/core/utils/urls.test.ts @@ -12,7 +12,7 @@ vi.mock('@/lib/core/config/env', () => ({ getEnv: mockGetEnv, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isProd: false, })) diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 1c2da16ec5b..1a014b295d6 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,5 +1,5 @@ import { env, getEnv } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/feature-flags' +import { isProd } from '@/lib/core/config/env-flags' /** Canonical base URL for the public-facing marketing site. No trailing slash. */ export const SITE_URL = 'https://www.sim.ai' diff --git a/apps/sim/lib/data-drains/access.ts b/apps/sim/lib/data-drains/access.ts index 6a36170f6f5..5a1db375444 100644 --- a/apps/sim/lib/data-drains/access.ts +++ b/apps/sim/lib/data-drains/access.ts @@ -4,7 +4,7 @@ import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/env-flags' interface DrainAccessSession { user: { diff --git a/apps/sim/lib/data-drains/dispatcher.test.ts b/apps/sim/lib/data-drains/dispatcher.test.ts index ffffac51472..0d94887b7dd 100644 --- a/apps/sim/lib/data-drains/dispatcher.test.ts +++ b/apps/sim/lib/data-drains/dispatcher.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsEnterprise, })) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) import { dispatchDueDrains, reapOrphanedRuns } from '@/lib/data-drains/dispatcher' diff --git a/apps/sim/lib/data-drains/dispatcher.ts b/apps/sim/lib/data-drains/dispatcher.ts index c7021ed9a6c..aec56a3bf3a 100644 --- a/apps/sim/lib/data-drains/dispatcher.ts +++ b/apps/sim/lib/data-drains/dispatcher.ts @@ -5,7 +5,7 @@ import { toError } from '@sim/utils/errors' import { and, eq, isNull, lt, or } from 'drizzle-orm' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getJobQueue } from '@/lib/core/async-jobs' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('DataDrainsDispatcher') diff --git a/apps/sim/lib/execution/files.ts b/apps/sim/lib/execution/files.ts index c0b69446fcf..e23582aba18 100644 --- a/apps/sim/lib/execution/files.ts +++ b/apps/sim/lib/execution/files.ts @@ -57,7 +57,7 @@ export async function processExecutionFile( if (file.type === 'url' && file.data) { const { downloadFileFromUrl } = await import('@/lib/uploads/utils/file-utils.server') - const buffer = await downloadFileFromUrl(file.data) + const buffer = await downloadFileFromUrl(file.data, { userId }) if (buffer.length > MAX_FILE_SIZE) { const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2) diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 64e1d3ebc1c..1c8b0a9a867 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-06-15", + "updatedAt": "2026-06-16", "integrations": [ { "type": "onepassword", @@ -5901,9 +5901,29 @@ { "name": "Invite Attendees", "description": "Invite attendees to an existing Google Calendar event" + }, + { + "name": "Check Free/Busy", + "description": "Query free/busy information for one or more Google Calendars" + }, + { + "name": "Create Calendar", + "description": "Create a new secondary calendar" + }, + { + "name": "Share Calendar", + "description": "Grant a user, group, or domain access to a calendar by creating an ACL rule" + }, + { + "name": "List Sharing", + "description": "List the access control rules (sharing) for a calendar" + }, + { + "name": "Remove Sharing", + "description": "Revoke an access control rule (sharing) from a calendar" } ], - "operationCount": 10, + "operationCount": 15, "triggers": [ { "id": "google_calendar_poller", @@ -6812,7 +6832,7 @@ "name": "Grafana", "description": "Interact with Grafana dashboards, alerts, and annotations", "longDescription": "Integrate Grafana into workflows. Manage dashboards, alerts, annotations, data sources, folders, and monitor health status.", - "bgColor": "#FFFFFF", + "bgColor": "#F46800", "iconName": "GrafanaIcon", "docsUrl": "https://docs.sim.ai/integrations/grafana", "operations": [ @@ -6860,6 +6880,10 @@ "name": "List Contact Points", "description": "List all alert notification contact points" }, + { + "name": "Create Contact Point", + "description": "Create a notification contact point (e.g., Slack, email, PagerDuty)" + }, { "name": "Create Annotation", "description": "Create an annotation on a dashboard or as a global annotation" @@ -6884,6 +6908,10 @@ "name": "Get Data Source", "description": "Get a data source by its ID or UID" }, + { + "name": "Check Data Source Health", + "description": "Test connectivity to a data source by its UID" + }, { "name": "List Folders", "description": "List all folders in Grafana" @@ -6891,9 +6919,25 @@ { "name": "Create Folder", "description": "Create a new folder in Grafana" + }, + { + "name": "Get Folder", + "description": "Get a folder by its UID" + }, + { + "name": "Update Folder", + "description": "Update (rename) a folder. Fetches the current folder and merges your changes." + }, + { + "name": "Delete Folder", + "description": "Delete a folder by its UID" + }, + { + "name": "Get Health", + "description": "Check the health of the Grafana instance (version, database status)" } ], - "operationCount": 19, + "operationCount": 25, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -8445,9 +8489,45 @@ { "name": "Copy Forms", "description": "Copy forms from one Jira issue to another" + }, + { + "name": "List Asset Schemas", + "description": "List Assets (Insight/CMDB) object schemas in Jira Service Management" + }, + { + "name": "Get Asset Schema", + "description": "Get a single Assets (Insight/CMDB) object schema by ID" + }, + { + "name": "List Asset Object Types", + "description": "List object types within an Assets (Insight/CMDB) object schema" + }, + { + "name": "Get Asset Object Type Attributes", + "description": "Get the attribute definitions for an Assets (Insight/CMDB) object type. Use the returned attribute IDs to build create/update payloads or map columns." + }, + { + "name": "Search Assets (AQL)", + "description": "Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. objectType = " + }, + { + "name": "Get Asset Object", + "description": "Get a single Assets (Insight/CMDB) object by ID, including its attribute values" + }, + { + "name": "Create Asset Object", + "description": "Create an Assets (Insight/CMDB) object of a given object type. Attributes use objectTypeAttributeId values from the object type definition." + }, + { + "name": "Update Asset Object", + "description": "Update an existing Assets (Insight/CMDB) object. Provide the attributes to change using their objectTypeAttributeId values." + }, + { + "name": "Delete Asset Object", + "description": "Delete an Assets (Insight/CMDB) object by ID" } ], - "operationCount": 34, + "operationCount": 43, "triggers": [ { "id": "jsm_request_created", diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 2ac35c77c01..02cfaa0b2cc 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -50,7 +50,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 122cf5d6b5b..fb2fa173886 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -26,7 +26,7 @@ import { } from '@/lib/billing/organizations/membership' import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 0e6494bbfe4..b0de83c82be 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -26,7 +26,7 @@ vi.mock('@/background/knowledge-connector-sync', () => ({ const mockMapTags = vi.fn() -vi.mock('@/connectors/registry', () => ({ +vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { jira: { mapTags: mockMapTags, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 7da5d4b956f..f74f21c6794 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -25,7 +25,7 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata' import { extractStorageKey } from '@/lib/uploads/utils/file-utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { knowledgeConnectorSync } from '@/background/knowledge-connector-sync' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { ConnectorAuthConfig, DocumentTags, diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 33ee06eb11b..63f0bb7eae4 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -295,7 +295,7 @@ async function parseDocument( if (isPDF && (hasAzureMistralOCR || hasMistralOCR)) { if (hasAzureMistralOCR) { logger.info(`Using Azure Mistral OCR: ${filename}`) - return parseWithAzureMistralOCR(fileUrl, filename, mimeType) + return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId) } if (hasMistralOCR) { @@ -305,7 +305,7 @@ async function parseDocument( } logger.info(`Using file parser: ${filename}`) - return parseWithFileParser(fileUrl, filename, mimeType) + return parseWithFileParser(fileUrl, filename, mimeType, userId) } async function handleFileForOCR( @@ -321,7 +321,7 @@ async function handleFileForOCR( if (mimeType === 'application/pdf') { logger.info(`handleFileForOCR: Downloading external PDF to check page count`) try { - const buffer = await downloadFileWithTimeout(fileUrl) + const buffer = await downloadFileWithTimeout(fileUrl, userId) logger.info(`handleFileForOCR: Downloaded external PDF: ${buffer.length} bytes`) return { httpsUrl: fileUrl, buffer } } catch (error) { @@ -340,7 +340,7 @@ async function handleFileForOCR( logger.info(`Uploading "${filename}" to cloud storage for OCR`) - const buffer = await downloadFileWithTimeout(fileUrl) + const buffer = await downloadFileWithTimeout(fileUrl, userId) logger.info(`Downloaded ${filename}: ${buffer.length} bytes`) @@ -380,11 +380,11 @@ async function handleFileForOCR( } } -async function downloadFileWithTimeout(fileUrl: string): Promise { - return downloadFileFromUrl(fileUrl, TIMEOUTS.FILE_DOWNLOAD) +async function downloadFileWithTimeout(fileUrl: string, userId?: string): Promise { + return downloadFileFromUrl(fileUrl, { timeoutMs: TIMEOUTS.FILE_DOWNLOAD, userId }) } -async function downloadFileForBase64(fileUrl: string): Promise { +async function downloadFileForBase64(fileUrl: string, userId?: string): Promise { if (/^data:/i.test(fileUrl)) { const [, base64Data] = fileUrl.split(',') if (!base64Data) { @@ -393,7 +393,7 @@ async function downloadFileForBase64(fileUrl: string): Promise { return Buffer.from(base64Data, 'base64') } if (/^https?:\/\//i.test(fileUrl)) { - return downloadFileWithTimeout(fileUrl) + return downloadFileWithTimeout(fileUrl, userId) } throw new Error('Unsupported fileUrl scheme: only data: URIs and http(s):// URLs are allowed') } @@ -468,7 +468,12 @@ async function makeOCRRequest( } } -async function parseWithAzureMistralOCR(fileUrl: string, filename: string, mimeType: string) { +async function parseWithAzureMistralOCR( + fileUrl: string, + filename: string, + mimeType: string, + userId?: string +) { validateOCRConfig( env.OCR_AZURE_API_KEY, env.OCR_AZURE_ENDPOINT, @@ -476,7 +481,7 @@ async function parseWithAzureMistralOCR(fileUrl: string, filename: string, mimeT 'Azure Mistral OCR' ) - const fileBuffer = await downloadFileForBase64(fileUrl) + const fileBuffer = await downloadFileForBase64(fileUrl, userId) if (mimeType === 'application/pdf') { const pageCount = await getPdfPageCount(fileBuffer) @@ -485,7 +490,7 @@ async function parseWithAzureMistralOCR(fileUrl: string, filename: string, mimeT `PDF has ${pageCount} pages, exceeds Azure OCR limit of ${MISTRAL_MAX_PAGES}. ` + `Falling back to file parser.` ) - return parseWithFileParser(fileUrl, filename, mimeType) + return parseWithFileParser(fileUrl, filename, mimeType, userId) } logger.info(`Azure Mistral OCR: PDF page count for ${filename}: ${pageCount}`) } @@ -529,7 +534,7 @@ async function parseWithAzureMistralOCR(fileUrl: string, filename: string, mimeT }) logger.info(`Falling back to file parser: ${filename}`) - return parseWithFileParser(fileUrl, filename, mimeType) + return parseWithFileParser(fileUrl, filename, mimeType, userId) } } @@ -589,7 +594,7 @@ async function parseWithMistralOCR( }) logger.info(`Falling back to file parser: ${filename}`) - return parseWithFileParser(fileUrl, filename, mimeType) + return parseWithFileParser(fileUrl, filename, mimeType, userId) } } @@ -773,7 +778,12 @@ async function processMistralOCRInBatches( } } -async function parseWithFileParser(fileUrl: string, filename: string, mimeType: string) { +async function parseWithFileParser( + fileUrl: string, + filename: string, + mimeType: string, + userId?: string +) { try { let content: string let metadata: FileParseMetadata = {} @@ -781,7 +791,7 @@ async function parseWithFileParser(fileUrl: string, filename: string, mimeType: if (/^data:/i.test(fileUrl)) { content = await parseDataURI(fileUrl, filename, mimeType) } else if (/^https?:\/\//i.test(fileUrl)) { - const result = await parseHttpFile(fileUrl, filename, mimeType) + const result = await parseHttpFile(fileUrl, filename, mimeType, userId) content = result.content metadata = result.metadata || {} } else { @@ -820,9 +830,10 @@ async function parseDataURI(fileUrl: string, filename: string, mimeType: string) async function parseHttpFile( fileUrl: string, filename: string, - mimeType?: string + mimeType?: string, + userId?: string ): Promise<{ content: string; metadata?: FileParseMetadata }> { - const buffer = await downloadFileWithTimeout(fileUrl) + const buffer = await downloadFileWithTimeout(fileUrl, userId) const extension = resolveParserExtension(filename, mimeType) const result = await parseBuffer(buffer, extension) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 307db69bc7c..bce2932ebf5 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -33,7 +33,7 @@ import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { env, envNumber } from '@/lib/core/config/env' -import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { processDocument } from '@/lib/knowledge/documents/document-processor' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' @@ -515,7 +515,6 @@ export async function processDocumentAsync( // KB config + workspace billing + doc tags in one JOIN (was 3 SELECTs). const contextRows = await db .select({ - userId: knowledgeBase.userId, workspaceId: knowledgeBase.workspaceId, chunkingConfig: knowledgeBase.chunkingConfig, embeddingModel: knowledgeBase.embeddingModel, @@ -644,7 +643,12 @@ export async function processDocumentAsync( kbConfig.maxSize, kbConfig.overlap, kbConfig.minSize, - ctx.userId, + // Authorize the source file (and run OCR/processing) as the billed + // actor — the uploader when known, else the workspace billed account — + // the same principal embeddings are billed to. Using the KB owner here + // would authorize an attacker-supplied internal fileUrl against the + // owner, letting a KB write-member ingest a file only the owner can read. + billingUserId, ctx.workspaceId, rawConfig?.strategy, rawConfig?.strategyOptions diff --git a/apps/sim/lib/knowledge/reranker.ts b/apps/sim/lib/knowledge/reranker.ts index e9b0fea8500..4c20847f535 100644 --- a/apps/sim/lib/knowledge/reranker.ts +++ b/apps/sim/lib/knowledge/reranker.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getBYOKKey } from '@/lib/api-key/byok' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' import { isSupportedRerankerModel } from '@/lib/knowledge/reranker-models' diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 3acb14dcae6..923542fde12 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -1,4 +1,4 @@ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { beforeEach, describe, expect, test, vi } from 'vitest' import { recordUsage } from '@/lib/billing/core/usage-log' import { ExecutionLogger } from '@/lib/logs/execution/logger' @@ -60,7 +60,7 @@ vi.mock('@/lib/billing/threshold-billing', () => ({ checkAndBillOverageThreshold: vi.fn(() => Promise.resolve()), })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) // Mock security module vi.mock('@/lib/core/security/redaction', () => ({ diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 9733a5550d6..e8c6edd7c55 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -25,7 +25,7 @@ import { stableEventKey, } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { filterForDisplay } from '@/lib/core/utils/display-filters' import { diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index bef88182c9e..569320ef882 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -11,8 +11,8 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { createPinnedFetch } from '@/lib/core/security/input-validation.server' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' -import { createMcpPinnedFetch } from '@/lib/mcp/pinned-fetch' import { type McpClientOptions, McpConnectionError, @@ -70,7 +70,7 @@ export class McpClient { this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: useOauth ? this.authProvider : undefined, requestInit: { headers: this.config.headers }, - ...(resolvedIP ? { fetch: createMcpPinnedFetch(resolvedIP) } : {}), + ...(resolvedIP ? { fetch: createPinnedFetch(resolvedIP) } : {}), }) this.client = new Client( diff --git a/apps/sim/lib/mcp/connection-manager.test.ts b/apps/sim/lib/mcp/connection-manager.test.ts index 8b48392e52f..f4845b099ec 100644 --- a/apps/sim/lib/mcp/connection-manager.test.ts +++ b/apps/sim/lib/mcp/connection-manager.test.ts @@ -40,7 +40,7 @@ const { mockGetOrCreateOauthRow: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isTest: false })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTest: false })) vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: { onToolsChanged: mockOnToolsChanged, diff --git a/apps/sim/lib/mcp/connection-manager.ts b/apps/sim/lib/mcp/connection-manager.ts index 158983739b2..78b8acc265f 100644 --- a/apps/sim/lib/mcp/connection-manager.ts +++ b/apps/sim/lib/mcp/connection-manager.ts @@ -12,7 +12,7 @@ import { createLogger } from '@sim/logger' import { backoffWithJitter } from '@sim/utils/retry' -import { isTest } from '@/lib/core/config/feature-flags' +import { isTest } from '@/lib/core/config/env-flags' import { McpClient } from '@/lib/mcp/client' import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index ff559caa8cf..22497bfe540 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -10,7 +10,7 @@ const { mockGetAllowedMcpDomainsFromEnv, mockDnsLookup, hostedFlag } = vi.hoiste hostedFlag: { value: false }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv, get isHosted() { return hostedFlag.value diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 9e57b23c7f4..fcc721203c2 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -2,7 +2,7 @@ import dns from 'dns/promises' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' -import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/feature-flags' +import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags' import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { createEnvVarPattern } from '@/executor/utils/reference-validation' diff --git a/apps/sim/lib/mcp/oauth/probe.test.ts b/apps/sim/lib/mcp/oauth/probe.test.ts index 34e7d6199e4..d691f1178c7 100644 --- a/apps/sim/lib/mcp/oauth/probe.test.ts +++ b/apps/sim/lib/mcp/oauth/probe.test.ts @@ -3,24 +3,22 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCreateMcpPinnedFetch, - mockCreateSsrfGuardedMcpFetch, - mockPinnedFetch, - mockGuardedFetch, -} = vi.hoisted(() => { - const mockPinnedFetch = vi.fn() - const mockGuardedFetch = vi.fn() - return { - mockPinnedFetch, - mockGuardedFetch, - mockCreateMcpPinnedFetch: vi.fn(() => mockPinnedFetch), - mockCreateSsrfGuardedMcpFetch: vi.fn(() => mockGuardedFetch), - } -}) +const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, mockGuardedFetch } = + vi.hoisted(() => { + const mockPinnedFetch = vi.fn() + const mockGuardedFetch = vi.fn() + return { + mockPinnedFetch, + mockGuardedFetch, + mockCreatePinnedFetch: vi.fn(() => mockPinnedFetch), + mockCreateSsrfGuardedMcpFetch: vi.fn(() => mockGuardedFetch), + } + }) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + createPinnedFetch: mockCreatePinnedFetch, +})) vi.mock('@/lib/mcp/pinned-fetch', () => ({ - createMcpPinnedFetch: mockCreateMcpPinnedFetch, createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch, })) @@ -50,7 +48,7 @@ describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () = const authType = await detectMcpAuthType('https://rebind.example.com/mcp', '203.0.113.10') expect(authType).toBe('none') - expect(mockCreateMcpPinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled() expect(mockPinnedFetch).toHaveBeenCalledTimes(1) // The unpinned global fetch must never be used — that was the SSRF sink. @@ -64,7 +62,7 @@ describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () = expect(authType).toBe('none') expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1) - expect(mockCreateMcpPinnedFetch).not.toHaveBeenCalled() + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() expect(mockGuardedFetch).toHaveBeenCalledTimes(1) expect(globalFetchSpy).not.toHaveBeenCalled() }) @@ -90,7 +88,7 @@ describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () = const authType = await detectMcpAuthType('http://example.com/mcp', '203.0.113.10') expect(authType).toBe('headers') - expect(mockCreateMcpPinnedFetch).not.toHaveBeenCalled() + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled() expect(globalFetchSpy).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/mcp/oauth/probe.ts b/apps/sim/lib/mcp/oauth/probe.ts index 887ba8ce971..4343f27870a 100644 --- a/apps/sim/lib/mcp/oauth/probe.ts +++ b/apps/sim/lib/mcp/oauth/probe.ts @@ -1,8 +1,9 @@ import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js' import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' +import { createPinnedFetch } from '@/lib/core/security/input-validation.server' import { isLoopbackHostname } from '@/lib/core/utils/urls' -import { createMcpPinnedFetch, createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' +import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' import type { McpAuthType } from '@/lib/mcp/types' const logger = createLogger('McpOauthProbe') @@ -33,7 +34,7 @@ export async function detectMcpAuthType( } const probeFetch: FetchLike = resolvedIP - ? createMcpPinnedFetch(resolvedIP) + ? createPinnedFetch(resolvedIP) : createSsrfGuardedMcpFetch() const controller = new AbortController() diff --git a/apps/sim/lib/mcp/oauth/revoke.test.ts b/apps/sim/lib/mcp/oauth/revoke.test.ts index d8f6342568b..ccd0caba98b 100644 --- a/apps/sim/lib/mcp/oauth/revoke.test.ts +++ b/apps/sim/lib/mcp/oauth/revoke.test.ts @@ -15,33 +15,23 @@ const PUBLIC_SERVER_URL = 'https://mcp.attacker.com' const PUBLIC_SERVER_IP = '203.0.113.10' const { - MockAgent, mockUndiciFetch, mockValidateMcpServerSsrf, mockDiscoverOAuthServerInfo, mockLoadOauthRow, mockDecryptSecret, mockDbSelect, -} = vi.hoisted(() => { - class MockAgent { - close() { - return Promise.resolve() - } - } - return { - MockAgent, - mockUndiciFetch: vi.fn(), - mockValidateMcpServerSsrf: vi.fn(), - mockDiscoverOAuthServerInfo: vi.fn(), - mockLoadOauthRow: vi.fn(), - mockDecryptSecret: vi.fn(), - mockDbSelect: vi.fn(), - } -}) +} = vi.hoisted(() => ({ + mockUndiciFetch: vi.fn(), + mockValidateMcpServerSsrf: vi.fn(), + mockDiscoverOAuthServerInfo: vi.fn(), + mockLoadOauthRow: vi.fn(), + mockDecryptSecret: vi.fn(), + mockDbSelect: vi.fn(), +})) -vi.mock('undici', () => ({ Agent: MockAgent, fetch: mockUndiciFetch })) vi.mock('@/lib/core/security/input-validation.server', () => ({ - createPinnedLookup: vi.fn(() => 'pinned-lookup-fn'), + createPinnedFetch: vi.fn(() => mockUndiciFetch), })) vi.mock('@/lib/mcp/domain-check', () => ({ validateMcpServerSsrf: mockValidateMcpServerSsrf, @@ -59,7 +49,6 @@ vi.mock('@sim/db', () => ({ db: { select: mockDbSelect }, })) -import { __resetPinnedAgentsForTests } from '@/lib/mcp/pinned-fetch' import { revokeMcpOauthTokens } from './revoke' function wireServerRow(row: Record) { @@ -74,7 +63,6 @@ function wireServerRow(row: Record) { describe('revokeMcpOauthTokens — SSRF guard', () => { beforeEach(() => { vi.clearAllMocks() - __resetPinnedAgentsForTests() mockLoadOauthRow.mockResolvedValue({ tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret' }, diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 9f6b5919bf2..64354ee708b 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -3,147 +3,26 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAgent, mockCreatePinnedLookup, mockUndiciFetch, capturedAgentOptions, agentCloses } = - vi.hoisted(() => { - const capturedAgentOptions: unknown[] = [] - const agentCloses: unknown[] = [] - class MockAgent { - constructor(options: unknown) { - capturedAgentOptions.push(options) - } - close() { - agentCloses.push(this) - return Promise.resolve() - } - } - return { - mockAgent: MockAgent, - mockCreatePinnedLookup: vi.fn(), - mockUndiciFetch: vi.fn(), - capturedAgentOptions, - agentCloses, - } - }) - -const { mockValidateMcpServerSsrf } = vi.hoisted(() => ({ +const { mockCreatePinnedFetch, mockValidateMcpServerSsrf, sentinelFetch } = vi.hoisted(() => ({ + mockCreatePinnedFetch: vi.fn(), mockValidateMcpServerSsrf: vi.fn(), + sentinelFetch: vi.fn(), })) -vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) vi.mock('@/lib/core/security/input-validation.server', () => ({ - createPinnedLookup: mockCreatePinnedLookup, + createPinnedFetch: mockCreatePinnedFetch, })) vi.mock('@/lib/mcp/domain-check', () => ({ validateMcpServerSsrf: mockValidateMcpServerSsrf, })) -import { - __resetPinnedAgentsForTests, - createMcpPinnedFetch, - createSsrfGuardedMcpFetch, -} from '@/lib/mcp/pinned-fetch' - -describe('createMcpPinnedFetch', () => { - beforeEach(() => { - vi.clearAllMocks() - capturedAgentOptions.length = 0 - agentCloses.length = 0 - __resetPinnedAgentsForTests() - mockCreatePinnedLookup.mockReturnValue('pinned-lookup-fn') - mockUndiciFetch.mockResolvedValue(new Response('ok')) - }) - - it('builds an undici Agent with the pinned lookup for the resolved IP', () => { - createMcpPinnedFetch('203.0.113.10') - expect(mockCreatePinnedLookup).toHaveBeenCalledWith('203.0.113.10') - expect(capturedAgentOptions).toHaveLength(1) - expect(capturedAgentOptions[0]).toEqual({ connect: { lookup: 'pinned-lookup-fn' } }) - }) - - it('forwards the dispatcher on every fetch call', async () => { - const fetchLike = createMcpPinnedFetch('203.0.113.10') - await fetchLike('https://example.com/mcp', { method: 'POST' }) - expect(mockUndiciFetch).toHaveBeenCalledTimes(1) - const [url, init] = mockUndiciFetch.mock.calls[0] - expect(url).toBe('https://example.com/mcp') - expect((init as { dispatcher?: unknown }).dispatcher).toBeInstanceOf(mockAgent) - expect((init as { method?: string }).method).toBe('POST') - }) - - it('preserves caller-provided init options (headers, signal)', async () => { - const fetchLike = createMcpPinnedFetch('203.0.113.10') - const controller = new AbortController() - await fetchLike('https://example.com/mcp', { - method: 'GET', - headers: { 'x-test': '1' }, - signal: controller.signal, - }) - const init = mockUndiciFetch.mock.calls[0][1] as RequestInit & { dispatcher?: unknown } - expect(init.headers).toEqual({ 'x-test': '1' }) - expect(init.signal).toBe(controller.signal) - expect(init.dispatcher).toBeInstanceOf(mockAgent) - }) - - it('handles undefined init gracefully', async () => { - const fetchLike = createMcpPinnedFetch('203.0.113.10') - await fetchLike('https://example.com/mcp') - const init = mockUndiciFetch.mock.calls[0][1] as { dispatcher?: unknown } - expect(init.dispatcher).toBeInstanceOf(mockAgent) - }) - - it('reuses the same dispatcher across calls within a fetch instance', async () => { - const fetchLike = createMcpPinnedFetch('203.0.113.10') - await fetchLike('https://example.com/a') - await fetchLike('https://example.com/b') - expect(capturedAgentOptions).toHaveLength(1) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher - expect(d1).toBe(d2) - }) - - it('pools agents by resolvedIP across createMcpPinnedFetch calls', async () => { - const a = createMcpPinnedFetch('203.0.113.10') - const b = createMcpPinnedFetch('203.0.113.10') - await a('https://example.com/a') - await b('https://example.com/b') - expect(capturedAgentOptions).toHaveLength(1) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher - expect(d1).toBe(d2) - }) - - it('creates separate agents for different resolved IPs', async () => { - const a = createMcpPinnedFetch('203.0.113.10') - const b = createMcpPinnedFetch('198.51.100.20') - await a('https://example.com/a') - await b('https://example.com/b') - expect(capturedAgentOptions).toHaveLength(2) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher - expect(d1).not.toBe(d2) - }) - - it('does not close evicted agents — captured closures keep working', async () => { - // Build an early closure whose agent will get evicted by later IPs. - const earlyClient = createMcpPinnedFetch('10.0.0.1') - // Fill the cache past its 64-entry limit so the early entry is evicted. - for (let i = 0; i < 64; i++) createMcpPinnedFetch(`10.1.${Math.floor(i / 256)}.${i % 256}`) - - // Eviction must NOT have closed any agents. - expect(agentCloses).toHaveLength(0) - // The early closure's captured dispatcher is still callable. - await earlyClient('https://example.com/still-works') - expect(mockUndiciFetch).toHaveBeenCalledTimes(1) - }) -}) +import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' describe('createSsrfGuardedMcpFetch', () => { beforeEach(() => { vi.clearAllMocks() - capturedAgentOptions.length = 0 - __resetPinnedAgentsForTests() - mockCreatePinnedLookup.mockReturnValue('pinned-lookup-fn') - mockUndiciFetch.mockResolvedValue(new Response('ok')) + mockCreatePinnedFetch.mockReturnValue(sentinelFetch) + sentinelFetch.mockResolvedValue(new Response('ok')) }) it('validates each request URL and pins to the resolved IP', async () => { @@ -152,11 +31,10 @@ describe('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') + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', { + method: 'POST', + }) }) it('rejects URLs that resolve to blocked IPs without issuing the request', async () => { @@ -166,7 +44,8 @@ describe('createSsrfGuardedMcpFetch', () => { await expect( fetchLike('http://169.254.169.254/latest/meta-data/', { method: 'POST' }) ).rejects.toThrow('blocked') - expect(mockUndiciFetch).not.toHaveBeenCalled() + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(sentinelFetch).not.toHaveBeenCalled() }) it('accepts URL objects and validates their href', async () => { @@ -175,6 +54,14 @@ describe('createSsrfGuardedMcpFetch', () => { await fetchLike(new URL('https://attacker.example/discover')) expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover') - expect(mockUndiciFetch).toHaveBeenCalledTimes(1) + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + }) + + it('falls back to global fetch when validation returns no IP', async () => { + mockValidateMcpServerSsrf.mockResolvedValue(null) + const fetchLike = createSsrfGuardedMcpFetch() + await fetchLike('https://allowed.internal/mcp') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index f395d6b03c5..3184a0da7a9 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -1,61 +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 { createPinnedFetch } 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 - * between URL validation and connection. Hostname is preserved so TLS SNI and - * the Host header still match the certificate. - * - * Agents are pooled by `resolvedIP` so back-to-back calls to the same server - * reuse the same keep-alive connection pool instead of opening a fresh TCP + - * TLS connection per McpClient instance. - */ -const MAX_POOLED_AGENTS = 64 -const pinnedAgents = new Map() - -function getPinnedAgent(resolvedIP: string): Agent { - const existing = pinnedAgents.get(resolvedIP) - if (existing) { - // LRU touch — re-insert to mark as most recently used. - pinnedAgents.delete(resolvedIP) - pinnedAgents.set(resolvedIP, existing) - return existing - } - if (pinnedAgents.size >= MAX_POOLED_AGENTS) { - // Drop the oldest entry WITHOUT closing it — existing `createMcpPinnedFetch` - // closures may still hold a reference and have in-flight requests. The - // dispatcher is GC'd (and its sockets cleaned up) when the last closure - // releases it; undici closes idle keep-alive connections after its own - // timeout (default 4s). - const oldestKey = pinnedAgents.keys().next().value - if (oldestKey !== undefined) pinnedAgents.delete(oldestKey) - } - const agent = new Agent({ connect: { lookup: createPinnedLookup(resolvedIP) } }) - pinnedAgents.set(resolvedIP, agent) - return agent -} - -export function __resetPinnedAgentsForTests(): void { - pinnedAgents.clear() -} - -export function createMcpPinnedFetch(resolvedIP: string): FetchLike { - const dispatcher = getPinnedAgent(resolvedIP) - - return (async (url, init) => { - const undiciInit: UndiciRequestInit = { - // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime (Node's global fetch IS undici) but the TS types differ - ...(init as unknown as UndiciRequestInit), - dispatcher, - } - const response = await undiciFetch(url as string | URL, undiciInit) - // double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime; bridging the types is required to satisfy the FetchLike contract - 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 @@ -79,7 +25,7 @@ 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 + const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch return pinnedFetch(url, init) }) satisfies FetchLike } diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 4ef53382483..12c30e7c8c3 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { and, eq, isNull } from 'drizzle-orm' -import { isTest } from '@/lib/core/config/feature-flags' +import { isTest } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' diff --git a/apps/sim/lib/messaging/lifecycle.ts b/apps/sim/lib/messaging/lifecycle.ts index 5d6da198810..95b109d9cb6 100644 --- a/apps/sim/lib/messaging/lifecycle.ts +++ b/apps/sim/lib/messaging/lifecycle.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' import { env } from '@/lib/core/config/env' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('LifecycleEmail') diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 1e259072117..e82025d853f 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -16,7 +16,7 @@ import { chatPubSub } from '@/lib/copilot/chat-status' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index dc8eed7cc8c..a9a71270042 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -547,6 +547,12 @@ export const OAUTH_PROVIDERS: Record = { 'write:request.participant:jira-service-management', 'read:request.approval:jira-service-management', 'write:request.approval:jira-service-management', + 'read:cmdb-object:jira', + 'write:cmdb-object:jira', + 'delete:cmdb-object:jira', + 'read:cmdb-schema:jira', + 'read:cmdb-type:jira', + 'read:cmdb-attribute:jira', ], }, }, diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index d9c4d76a61e..28276bc28fd 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -204,6 +204,12 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Add and remove participants from customer requests', 'read:request.approval:jira-service-management': 'View approvals on customer requests', 'write:request.approval:jira-service-management': 'Approve or decline customer requests', + 'read:cmdb-object:jira': 'View Assets objects and run AQL searches', + 'write:cmdb-object:jira': 'Create and update Assets objects', + 'delete:cmdb-object:jira': 'Delete Assets objects', + 'read:cmdb-schema:jira': 'View Assets object schemas', + 'read:cmdb-type:jira': 'View Assets object types', + 'read:cmdb-attribute:jira': 'View Assets object type attributes', // Microsoft scopes 'User.Read': 'Read Microsoft user', diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 22d8e205302..2853a82a2d3 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -7,7 +7,10 @@ export const PERMISSION_GROUP_CONSTRAINTS = { export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { groupUser: 'permission_group_member_group_user_unique', - organizationUser: 'permission_group_member_organization_user_unique', +} as const + +export const PERMISSION_GROUP_WORKSPACE_CONSTRAINTS = { + groupWorkspace: 'permission_group_workspace_group_workspace_unique', } as const export const permissionGroupConfigSchema = z.object({ diff --git a/apps/sim/lib/permissions/super-user.ts b/apps/sim/lib/permissions/super-user.ts index 953a2ea1578..597ca135e4c 100644 --- a/apps/sim/lib/permissions/super-user.ts +++ b/apps/sim/lib/permissions/super-user.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, dbReplica } from '@sim/db' import { settings, user } from '@sim/db/schema' import { eq } from 'drizzle-orm' @@ -35,3 +35,19 @@ export async function verifyEffectiveSuperUser(userId: string): Promise<{ superUserModeEnabled, } } + +/** + * True when the user is a platform admin (`role === 'admin'`). A single-column read + * served from the replica: this gates features, not security-critical auth, so it + * tolerates the replica's bounded staleness (admin role rarely changes). Falls back + * to the primary when no replica is configured. + */ +export async function isPlatformAdmin(userId: string): Promise { + const [row] = await dbReplica + .select({ role: user.role }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + return row?.role === 'admin' +} diff --git a/apps/sim/lib/table/__tests__/find-row-matches.test.ts b/apps/sim/lib/table/__tests__/find-row-matches.test.ts index cbc1276888e..076a50686df 100644 --- a/apps/sim/lib/table/__tests__/find-row-matches.test.ts +++ b/apps/sim/lib/table/__tests__/find-row-matches.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/table/validation', () => ({ checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), })) -import { findRowMatches } from '@/lib/table/service' +import { findRowMatches } from '@/lib/table/rows/service' import { buildFilterClause, buildSortClause } from '@/lib/table/sql' const COLUMNS: ColumnDefinition[] = [ diff --git a/apps/sim/lib/table/__tests__/lock-order.test.ts b/apps/sim/lib/table/__tests__/lock-order.test.ts index cc7f4ec9726..cca52fadcee 100644 --- a/apps/sim/lib/table/__tests__/lock-order.test.ts +++ b/apps/sim/lib/table/__tests__/lock-order.test.ts @@ -10,13 +10,13 @@ import { userTableDefinitions } from '@sim/db/schema' import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { importAppendRows } from '@/lib/table/service' +import { importAppendRows } from '@/lib/table/import-data' import type { TableDefinition } from '@/lib/table/types' vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/config/feature-flags', () => ({ - isTablesFractionalOrderingEnabled: false, + isFeatureEnabled: vi.fn().mockResolvedValue(false), })) vi.mock('@/lib/table/validation', () => ({ diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index a09d9630edf..c0ade663506 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -43,7 +43,7 @@ vi.mock('@/lib/table/validation', () => ({ checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), })) -import { deleteRowsByFilter, queryRows, updateRowsByFilter } from '@/lib/table/service' +import { deleteRowsByFilter, queryRows, updateRowsByFilter } from '@/lib/table/rows/service' const COLUMNS: ColumnDefinition[] = [ { name: 'name', type: 'string' }, diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 38a74695c3d..a8919924aa9 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -3,15 +3,14 @@ */ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { deleteColumn, renameColumn } from '@/lib/table/columns/service' import { batchInsertRows, - deleteColumn, insertRow, - renameColumn, replaceTableRows, updateRow, upsertRow, -} from '@/lib/table/service' +} from '@/lib/table/rows/service' import type { TableDefinition } from '@/lib/table/types' import { getUniqueColumns } from '@/lib/table/validation' @@ -20,7 +19,7 @@ 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, + isFeatureEnabled: vi.fn().mockResolvedValue(false), })) vi.mock('@/lib/table/validation', () => ({ diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index 3c9a139f7a8..4ebfe9a6ffa 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { TABLE_LIMITS } from '../constants' +import { TABLE_LIMITS } from '@/lib/table/constants' import { type ColumnDefinition, coerceRowToSchema, @@ -15,7 +15,7 @@ import { validateTableName, validateTableSchema, validateUniqueConstraints, -} from '../validation' +} from '@/lib/table/validation' describe('Validation', () => { describe('validateTableName', () => { diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index cbaf6e640f2..387c89e145c 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -4,26 +4,26 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, count, eq, gt, inArray } from 'drizzle-orm' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { appendTableEvent } from '@/lib/table/events' import { - batchUpdateRows, - getTableById, markJobFailed, markJobReady, markTableJobRunning, updateJobProgress, -} from '@/lib/table/service' +} from '@/lib/table/jobs/service' +import { pluckByPath } from '@/lib/table/pluck' +import { batchUpdateRows } from '@/lib/table/rows/service' +import { getTableById } from '@/lib/table/service' import type { RowData, TableBackfillJobPayload, TableDefinition, WorkflowGroupOutput, } from '@/lib/table/types' -import { pluckByPath } from './pluck' const logger = createLogger('TableBackfillRunner') @@ -330,7 +330,7 @@ export async function maybeBackfillGroupOutputs(opts: { // Release the claim so a ghost `running` job doesn't block imports/deletes. // Swallowed (warn only): a failed backfill never fails the schema change — // the data stays backfillable. - const { releaseJobClaim } = await import('./service') + const { releaseJobClaim } = await import('@/lib/table/jobs/service') await releaseJobClaim(table.id, jobId).catch(() => {}) logger.warn( `[${requestId}] Backfill dispatch failed for table ${table.id} group ${groupId}; skipping`, diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index 2efbe5f781a..2dfbc30cd93 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -7,8 +7,8 @@ import { createLogger } from '@sim/logger' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' +import { getTablePlanLimits, type PlanName, type TablePlanLimits } from '@/lib/table/constants' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' -import { getTablePlanLimits, type PlanName, type TablePlanLimits } from './constants' const logger = createLogger('TableBilling') diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index c5caf6dc2dc..2d64a186938 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -44,7 +44,8 @@ export async function writeWorkflowGroupState( ): Promise<'wrote' | 'skipped'> { const { tableId, rowId, workspaceId, groupId, executionId } = ctx const requestId = ctx.requestId ?? `wfgrp-${executionId}` - const { getTableById, getRowById, updateRow } = await import('@/lib/table/service') + const { getTableById } = await import('@/lib/table/service') + const { getRowById, updateRow } = await import('@/lib/table/rows/service') const table = await getTableById(tableId) if (!table) { diff --git a/apps/sim/lib/table/column-keys.ts b/apps/sim/lib/table/column-keys.ts index 4e54d324656..ec770b047ea 100644 --- a/apps/sim/lib/table/column-keys.ts +++ b/apps/sim/lib/table/column-keys.ts @@ -9,7 +9,14 @@ */ import { generateId } from '@sim/utils/id' -import type { ColumnDefinition, Filter, RowData, Sort, TableSchema, WorkflowGroup } from './types' +import type { + ColumnDefinition, + Filter, + RowData, + Sort, + TableSchema, + WorkflowGroup, +} from '@/lib/table/types' /** * Resolves a column's stable storage key. Falls back to `name` for legacy diff --git a/apps/sim/lib/table/column-naming.ts b/apps/sim/lib/table/column-naming.ts index 9d993c346a1..1124e8da2df 100644 --- a/apps/sim/lib/table/column-naming.ts +++ b/apps/sim/lib/table/column-naming.ts @@ -6,7 +6,7 @@ * get from the sidebar. */ -import type { ColumnDefinition } from './types' +import type { ColumnDefinition } from '@/lib/table/types' /** * Slugifies a string into a `NAME_PATTERN`-safe column name. Lowercase, diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts new file mode 100644 index 00000000000..4eafabd456d --- /dev/null +++ b/apps/sim/lib/table/columns/service.ts @@ -0,0 +1,668 @@ +/** + * Column and schema-management service for user tables. + * + * Standalone column-mutation operations (add, rename, delete, type change, + * constraint change) extracted from the table service. Each acquires the + * table's advisory lock via {@link withLockedTable} from `@/lib/table/service`. + * + * Use this for: workflow executor, background jobs, testing business logic. + * Use API routes for: HTTP requests, frontend clients. + */ + +import { db } from '@sim/db' +import { userTableDefinitions, userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, eq, sql } from 'drizzle-orm' +import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' +import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { stripGroupExecutions } from '@/lib/table/rows/executions' +import { withLockedTable } from '@/lib/table/service' +import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx' +import type { + DeleteColumnData, + RenameColumnData, + RowData, + TableDefinition, + TableMetadata, + TableSchema, + UpdateColumnConstraintsData, + UpdateColumnTypeData, +} from '@/lib/table/types' +import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns' + +const logger = createLogger('TableColumnService') + +/** + * Adds a column to an existing table's schema. + * + * @param tableId - Table ID to update + * @param column - Column definition to add + * @param requestId - Request ID for logging + * @returns Updated table definition + * @throws Error if table not found or column name already exists + */ +export async function addTableColumn( + tableId: string, + column: { + id?: string + name: string + type: string + required?: boolean + unique?: boolean + position?: number + }, + requestId: string +): Promise { + return withLockedTable(tableId, async (table, trx) => { + if (!NAME_PATTERN.test(column.name)) { + throw new Error( + `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` + ) + } + + if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + throw new Error( + `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` + ) + } + + if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { + throw new Error( + `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` + ) + } + + const schema = table.schema + if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { + throw new Error(`Column "${column.name}" already exists`) + } + + if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new Error( + `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` + ) + } + + const newColumn: TableSchema['columns'][number] = { + // Honor a caller-provided id (undo of a delete reuses the original id); + // otherwise mint a fresh one. + id: column.id ?? generateColumnId(), + name: column.name, + type: column.type as TableSchema['columns'][number]['type'], + required: column.required ?? false, + unique: column.unique ?? false, + } + const newColumnId = getColumnId(newColumn) + + const columns = [...schema.columns] + if (column.position !== undefined && column.position >= 0 && column.position < columns.length) { + columns.splice(column.position, 0, newColumn) + } else { + columns.push(newColumn) + } + + const updatedSchema: TableSchema = { ...schema, columns } + + // Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the + // new column's id at the same index we used in `columns` keeps display + // ordering aligned with the user's intent for `position`-based inserts. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) { + let insertIdx = existingOrder.length + if (column.position !== undefined && column.position >= 0) { + // Anchor on the column previously at `position` — that column shifted + // right by one in `columns`, so the new id slots in at its old spot. + const anchor = schema.columns[column.position] + if (anchor) { + const anchorIdx = existingOrder.indexOf(getColumnId(anchor)) + if (anchorIdx !== -1) insertIdx = anchorIdx + } + } + const nextOrder = [...existingOrder] + nextOrder.splice(insertIdx, 0, newColumnId) + updatedMetadata = { ...table.metadata, columnOrder: nextOrder } + } + + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + + const now = new Date() + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, tableId)) + + logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) + + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }) +} + +/** + * Renames a column in a table's schema and updates all row data keys. + * + * @param data - Rename column data + * @param requestId - Request ID for logging + * @returns Updated table definition + * @throws Error if table not found, column not found, or new name conflicts + */ +export async function renameColumn( + data: RenameColumnData, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + if (!NAME_PATTERN.test(data.newName)) { + throw new Error( + `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` + ) + } + + if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + throw new Error( + `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` + ) + } + + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) + if (columnIndex === -1) { + throw new Error(`Column "${data.oldName}" not found`) + } + + if ( + schema.columns.some( + (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() + ) + ) { + throw new Error(`Column "${data.newName}" already exists`) + } + + const targetColumn = schema.columns[columnIndex] + const actualOldName = targetColumn.name + + // Rename is metadata-only: stored rows, metadata, and workflow-group refs all + // key on the column's stable id, which a rename never changes — so this is a + // pure schema write, no per-row JSONB rewrite or group/metadata cascade. + // Stamp the current storage key as the id (for any not-yet-backfilled column) + // so existing rows stay reachable as the display name changes. + const columnId = targetColumn.id ?? actualOldName + const updatedColumns = schema.columns.map((c, i) => + i === columnIndex ? { ...c, id: columnId, name: data.newName } : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + assertValidSchema(updatedSchema, table.metadata?.columnOrder) + + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` + ) + return { ...table, schema: updatedSchema, updatedAt: now } + }) +} + +/** Removes the given column-id keys from a metadata blob (widths/order/pinned). */ +function stripColumnIdsFromMetadata( + metadata: TableMetadata | null, + ids: ReadonlySet +): TableMetadata | null { + if (!metadata) return metadata + let next = metadata + if (metadata.columnWidths) { + const widths = { ...metadata.columnWidths } + let changed = false + for (const id of ids) + if (id in widths) { + delete widths[id] + changed = true + } + if (changed) next = { ...next, columnWidths: widths } + } + if (metadata.columnOrder?.some((id) => ids.has(id))) { + next = { ...next, columnOrder: metadata.columnOrder.filter((id) => !ids.has(id)) } + } + if (metadata.pinnedColumns?.some((id) => ids.has(id))) { + next = { ...next, pinnedColumns: metadata.pinnedColumns.filter((id) => !ids.has(id)) } + } + return next +} + +/** + * Fire-and-forget reclamation of a deleted column's row storage. The column is + * already gone from the schema, so reads never surface the orphaned id — + * dropping the JSONB key just frees space. Runs in its own transaction with a + * row-count-scaled timeout; failures are logged, not propagated. + */ +function stripColumnDataInBackground( + tableId: string, + columnIds: string[], + rowCount: number, + requestId: string +): void { + if (columnIds.length === 0) return + void (async () => { + try { + await db.transaction(async (trx) => { + const statementMs = scaledStatementTimeoutMs(rowCount, { + baseMs: 60_000, + perRowMs: 2 * columnIds.length, + }) + await setTableTxTimeouts(trx, { statementMs }) + for (const id of columnIds) { + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${tableId} AND data ? ${id}::text` + ) + } + }) + logger.info( + `[${requestId}] Background-stripped deleted column data [${columnIds.join(', ')}] from table ${tableId}` + ) + } catch (err) { + logger.error( + `[${requestId}] Background column-data strip failed for table ${tableId} [${columnIds.join(', ')}]:`, + err + ) + } + })() +} + +/** + * Deletes a column from a table's schema. When id-keyed, returns once the schema + * is updated and reclaims the column's row-data storage in the background + * (fire-and-forget); the legacy path strips the row key synchronously. + * + * @param data - Delete column data + * @param requestId - Request ID for logging + * @returns Updated table definition + * @throws Error if table not found, column not found, or it's the last column + */ +export async function deleteColumn( + data: DeleteColumnData, + requestId: string +): Promise { + const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new Error(`Column "${data.columnName}" not found`) + } + + if (schema.columns.length <= 1) { + throw new Error('Cannot delete the last column in a table') + } + + const targetColumn = schema.columns[columnIndex] + const actualName = targetColumn.name + const columnId = getColumnId(targetColumn) + const ownerGroupId = targetColumn.workflowGroupId + + // Drop this column's reference (by id) from every group's outputs and + // `columns` dependency. If the column is the last output of its parent + // group, the group itself is also removed (a group with zero outputs is + // invalid). + let groupRemovedId: string | null = null + const updatedGroups = (schema.workflowGroups ?? []) + .map((group) => { + let next = group + if (ownerGroupId && group.id === ownerGroupId) { + const remaining = group.outputs.filter((o) => o.columnName !== columnId) + if (remaining.length === 0) { + groupRemovedId = group.id + } + next = { ...next, outputs: remaining } + } + return stripGroupDeps(next, new Set([columnId])) + }) + .filter((g) => g.id !== groupRemovedId) + + const updatedSchema: TableSchema = { + ...schema, + columns: schema.columns.filter((_, i) => i !== columnIndex), + ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), + } + const updatedMetadata = stripColumnIdsFromMetadata( + table.metadata as TableMetadata | null, + new Set([columnId]) + ) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + + const now = new Date() + + // Schema/metadata update commits now; the column's row-data storage is + // reclaimed in the background (fire-and-forget) — reads never surface the + // orphaned id since the column is already gone from the schema. + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId]) + + logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) + + return { + def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, + stripKey: columnId, + } + }) + + stripColumnDataInBackground(data.tableId, [stripKey], def.rowCount ?? 0, requestId) + return def +} + +/** + * Deletes multiple columns from a table in a single transaction. + * Avoids the race condition of calling deleteColumn multiple times in parallel. + */ +export async function deleteColumns( + data: { tableId: string; columnNames: string[] }, + requestId: string +): Promise { + const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const namesToDelete = new Set() + const idsToDelete = new Set() + const notFound: string[] = [] + + for (const name of data.columnNames) { + const col = schema.columns.find((c) => columnMatchesRef(c, name)) + if (!col) { + notFound.push(name) + } else { + namesToDelete.add(col.name) + idsToDelete.add(getColumnId(col)) + } + } + + if (notFound.length > 0) { + throw new Error(`Columns not found: ${notFound.join(', ')}`) + } + + const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) + if (remaining.length === 0) { + throw new Error('Cannot delete all columns from a table') + } + + // For each group, drop outputs whose column (by id) is being deleted. Groups + // that end up with zero outputs are removed entirely (they'd be invalid). + // Then any remaining group's dependencies referencing a removed column are + // cleaned up. + const removedGroupIds = new Set() + let updatedGroups = (schema.workflowGroups ?? []).map((group) => { + const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName)) + if (remainingOutputs.length === 0) { + removedGroupIds.add(group.id) + } + return remainingOutputs.length === group.outputs.length + ? group + : { ...group, outputs: remainingOutputs } + }) + updatedGroups = updatedGroups + .filter((g) => !removedGroupIds.has(g.id)) + .map((group) => stripGroupDeps(group, idsToDelete)) + const updatedSchema: TableSchema = { + ...schema, + columns: remaining, + ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), + } + const updatedMetadata = stripColumnIdsFromMetadata( + table.metadata as TableMetadata | null, + idsToDelete + ) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + + const now = new Date() + + // Schema/metadata commit now; row storage for the deleted columns is + // reclaimed in the background (fire-and-forget). + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + await stripGroupExecutions(trx, data.tableId, removedGroupIds) + + logger.info( + `[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}` + ) + + return { + def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, + stripKeys: Array.from(idsToDelete), + } + }) + + if (stripKeys.length > 0) { + stripColumnDataInBackground(data.tableId, stripKeys, def.rowCount ?? 0, requestId) + } + return def +} + +/** + * Changes the type of a column. Validates that existing data is compatible. + * + * @param data - Update column type data + * @param requestId - Request ID for logging + * @returns Updated table definition + * @throws Error if table not found, column not found, or existing data is incompatible + */ +export async function updateColumnType( + data: UpdateColumnTypeData, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + // Scale both statement and idle timeouts to row count: the compatibility + // check below iterates every row in Node between the row SELECT and the + // schema UPDATE, leaving the transaction idle for that gap. The default 5s + // `idle_in_transaction_session_timeout` would abort a valid type change on + // a large table. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + + if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { + throw new Error( + `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` + ) + } + + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new Error(`Column "${data.columnName}" not found`) + } + + const column = schema.columns[columnIndex] + if (column.type === data.newType) { + return table + } + const columnKey = getColumnId(column) + + // Validate existing data is compatible with the new type + const rows = await trx + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + sql`${userTableRows.data} ? ${columnKey}`, + sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` + ) + ) + + let incompatibleCount = 0 + for (const row of rows) { + const rowData = row.data as RowData + const value = rowData[columnKey] + if (value === null || value === undefined) continue + + if (!isValueCompatibleWithType(value, data.newType)) { + incompatibleCount++ + } + } + + if (incompatibleCount > 0) { + throw new Error( + `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` + ) + } + + const updatedColumns = schema.columns.map((c, i) => + i === columnIndex ? { ...c, type: data.newType } : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, updatedAt: now } + }) +} + +/** + * Updates constraints (required, unique) on a column. + * + * @param data - Update column constraints data + * @param requestId - Request ID for logging + * @returns Updated table definition + * @throws Error if table not found, column not found, or existing data violates the constraint + */ +export async function updateColumnConstraints( + data: UpdateColumnConstraintsData, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + // Scale both statement and idle timeouts to row count: the required/unique + // validation runs between separate queries inside this transaction, leaving + // it briefly idle. Match `updateColumnType` so the default 5s + // `idle_in_transaction_session_timeout` can't abort a valid change on a + // large table. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new Error(`Column "${data.columnName}" not found`) + } + + const column = schema.columns[columnIndex] + const columnKey = getColumnId(column) + if (column.workflowGroupId) { + throw new Error( + `Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.` + ) + } + if (data.required === true && !column.required) { + const [result] = await trx + .select({ count: count() }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + sql`(NOT (${userTableRows.data} ? ${columnKey}) OR ${userTableRows.data}->>${columnKey}::text IS NULL)` + ) + ) + + if (result.count > 0) { + throw new Error( + `Cannot set column "${column.name}" as required: ${result.count} row(s) have null or missing values` + ) + } + } + + if (data.unique === true && !column.unique) { + const duplicates = (await trx.execute( + sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` + )) as { val: string; cnt: number }[] + + if (duplicates.length > 0) { + throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`) + } + } + + const updatedColumns = schema.columns.map((c, i) => + i === columnIndex + ? { + ...c, + ...(data.required !== undefined ? { required: data.required } : {}), + ...(data.unique !== undefined ? { unique: data.unique } : {}), + } + : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, updatedAt: now } + }) +} + +/** + * Checks if a value is compatible with a target column type. + */ +function isValueCompatibleWithType( + value: unknown, + targetType: (typeof COLUMN_TYPES)[number] +): boolean { + if (value === null || value === undefined) return true + + switch (targetType) { + case 'string': + return true + case 'number': { + if (typeof value === 'number') return Number.isFinite(value) + if (typeof value === 'string') { + const num = Number(value) + return Number.isFinite(num) && value.trim() !== '' + } + return false + } + case 'boolean': { + if (typeof value === 'boolean') return true + if (typeof value === 'string') + return ['true', 'false', '1', '0'].includes(value.toLowerCase()) + if (typeof value === 'number') return value === 0 || value === 1 + return false + } + case 'date': { + if (value instanceof Date) return !Number.isNaN(value.getTime()) + if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) + return false + } + case 'json': + return true + default: + return false + } +} diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts index 1259e76aa85..d06285d51a7 100644 --- a/apps/sim/lib/table/delete-runner.test.ts +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -27,13 +27,17 @@ const { vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById, +})) +vi.mock('@/lib/table/jobs/service', () => ({ getJobProgress: mockGetJobProgress, - selectRowIdPage: mockSelectRowIdPage, - deletePageByIds: mockDeletePageByIds, updateJobProgress: mockUpdateJobProgress, markJobReady: mockMarkJobReady, markJobFailed: mockMarkJobFailed, })) +vi.mock('@/lib/table/rows/ordering', () => ({ + selectRowIdPage: mockSelectRowIdPage, + deletePageByIds: mockDeletePageByIds, +})) vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) vi.mock('@/lib/table/sql', () => ({ buildFilterClause: mockBuildFilterClause })) vi.mock('@/lib/table/constants', () => ({ diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index 0065b60ba5a..5fb4a6e3708 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -6,14 +6,13 @@ import type { Filter } from '@/lib/table' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { - deletePageByIds, getJobProgress, - getTableById, markJobFailed, markJobReady, - selectRowIdPage, updateJobProgress, -} from '@/lib/table/service' +} from '@/lib/table/jobs/service' +import { deletePageByIds, selectRowIdPage } from '@/lib/table/rows/ordering' +import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' const logger = createLogger('TableDeleteRunner') diff --git a/apps/sim/lib/table/deps.ts b/apps/sim/lib/table/deps.ts index a8e2e246fd2..55a5db6b9a1 100644 --- a/apps/sim/lib/table/deps.ts +++ b/apps/sim/lib/table/deps.ts @@ -5,7 +5,13 @@ */ import { createLogger } from '@sim/logger' -import type { RowData, RowExecutionMetadata, RowExecutions, TableRow, WorkflowGroup } from './types' +import type { + RowData, + RowExecutionMetadata, + RowExecutions, + TableRow, + WorkflowGroup, +} from '@/lib/table/types' const logger = createLogger('OptimisticCascade') diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 99593f4097c..449bc531914 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -30,7 +30,7 @@ import { TABLE_CONCURRENCY_LIMIT, toTableRow, type WorkflowGroupCellPayload, -} from './workflow-columns' +} from '@/lib/table/workflow-columns' const logger = createLogger('TableRunDispatcher') @@ -375,7 +375,7 @@ export async function dispatcherStep(dispatchId: string): Promise ({ getTableById: mockGetTableById, +})) +vi.mock('@/lib/table/jobs/service', () => ({ selectExportRowPage: mockSelectExportRowPage, updateJobProgress: mockUpdateJobProgress, markJobReady: mockMarkJobReady, diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index f2df9d4e1c2..e6f6f834f1f 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -10,13 +10,13 @@ import { toCsvRow, } from '@/lib/table/export-format' import { - getTableById, markJobFailed, markJobReady, selectExportRowPage, setJobResultKey, updateJobProgress, -} from '@/lib/table/service' +} from '@/lib/table/jobs/service' +import { getTableById } from '@/lib/table/service' import { deleteFile, uploadFile } from '@/lib/uploads/core/storage-service' const logger = createLogger('TableExportRunner') diff --git a/apps/sim/lib/table/hooks/index.ts b/apps/sim/lib/table/hooks/index.ts index ffa84c9ced3..9597c85c51f 100644 --- a/apps/sim/lib/table/hooks/index.ts +++ b/apps/sim/lib/table/hooks/index.ts @@ -1 +1 @@ -export * from './use-table-columns' +export * from '@/lib/table/hooks/use-table-columns' diff --git a/apps/sim/lib/table/hooks/use-table-columns.ts b/apps/sim/lib/table/hooks/use-table-columns.ts index 3501a4ee35a..4d882f66587 100644 --- a/apps/sim/lib/table/hooks/use-table-columns.ts +++ b/apps/sim/lib/table/hooks/use-table-columns.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' +import type { ColumnOption } from '@/lib/table/types' import { useTable } from '@/hooks/queries/tables' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { ColumnOption } from '../types' interface UseTableColumnsOptions { tableId: string | null | undefined diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts new file mode 100644 index 00000000000..bf25f2e15ca --- /dev/null +++ b/apps/sim/lib/table/import-data.ts @@ -0,0 +1,200 @@ +/** + * Import-job table-data write operations — bulk insert, schema setup, and + * append/replace used by `import-runner.ts` and the import route. Distinct from + * `import.ts` (CSV parsing) and `import-runner.ts` (the job runner). + */ + +import { db } from '@sim/db' +import { userTableDefinitions, userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' +import { nKeysBetween } from '@/lib/table/order-key' +import { acquireRowOrderLock } from '@/lib/table/rows/ordering' +import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service' +import { addTableColumnsWithTx } from '@/lib/table/service' +import type { + ReplaceRowsResult, + RowData, + TableDefinition, + TableRow, + TableSchema, +} from '@/lib/table/types' +import { + checkBatchUniqueConstraintsDb, + coerceRowToSchema, + getUniqueColumns, + validateRowSize, +} from '@/lib/table/validation' + +const logger = createLogger('TableImportData') + +/** 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, + } +} + +/** 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)) +} + +/** + * 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)) +} + +/** 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)) +} + +/** + * 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: { id?: string; 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) { + // Take the row-order lock before creating columns so this path uses the + // same rows_pos → user_table_definitions order as plain inserts. Creating + // columns first would lock the definition row before rows_pos, inverting + // the order and deadlocking concurrent inserts on this table. The lock is + // re-entrant, so the per-batch acquire below is a no-op. + await acquireRowOrderLock(trx, table.id) + 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: { id?: string; 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) { + await acquireRowOrderLock(trx, table.id) + working = await addTableColumnsWithTx(trx, table, additions, requestId) + } + return replaceTableRowsWithTx( + trx, + { tableId: working.id, rows: data.rows, workspaceId: data.workspaceId, userId: data.userId }, + working, + requestId + ) + }) +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 44a7d3ec693..5391d22a238 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -23,14 +23,11 @@ import { addImportColumns, bulkInsertImportBatch, deleteAllTableRows, - getTableById, - markJobFailed, - markJobReady, - nextImportStartOrderKey, - nextImportStartPosition, setTableSchemaForImport, - updateJobProgress, -} from '@/lib/table/service' +} from '@/lib/table/import-data' +import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' +import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering' +import { getTableById } from '@/lib/table/service' import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' import { normalizeColumn } from '@/app/api/table/utils' diff --git a/apps/sim/lib/table/index.ts b/apps/sim/lib/table/index.ts index 32f487cb9dd..c79a13f182c 100644 --- a/apps/sim/lib/table/index.ts +++ b/apps/sim/lib/table/index.ts @@ -5,13 +5,18 @@ * Import hooks directly from '@/lib/table/hooks' in client components. */ -export * from './billing' -export * from './column-keys' -export * from './constants' -export * from './import' -export * from './llm' -export * from './query-builder' -export * from './service' -export * from './sql' -export * from './types' -export * from './validation' +export * from '@/lib/table/billing' +export * from '@/lib/table/column-keys' +export * from '@/lib/table/columns/service' +export * from '@/lib/table/constants' +export * from '@/lib/table/import' +export * from '@/lib/table/import-data' +export * from '@/lib/table/jobs/service' +export * from '@/lib/table/llm' +export * from '@/lib/table/query-builder' +export * from '@/lib/table/rows/service' +export * from '@/lib/table/service' +export * from '@/lib/table/sql' +export * from '@/lib/table/types' +export * from '@/lib/table/validation' +export * from '@/lib/table/workflow-groups/service' diff --git a/apps/sim/lib/table/jobs/service.ts b/apps/sim/lib/table/jobs/service.ts new file mode 100644 index 00000000000..124f3d95c04 --- /dev/null +++ b/apps/sim/lib/table/jobs/service.ts @@ -0,0 +1,383 @@ +/** + * Table background-job service for user tables. + * + * The `table_jobs` state machine (claim / progress / terminal transitions), the + * latest-job reads that enrich a {@link TableDefinition}, and the export-job read + * paths — extracted from the table service. Operates purely on the `table_jobs` + * table (plus `selectExportRowPage`, which pages rows through the shared + * `pendingDeleteMask`), so it never imports the table-root service. + * + * Use this for: workflow executor, background jobs, testing business logic. + * Use API routes for: HTTP requests, frontend clients. + */ + +import { db } from '@sim/db' +import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' +import { and, asc, desc, eq, gt, inArray, ne, or, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { pendingDeleteMask } from '@/lib/table/rows/service' +import type { + RowData, + TableDefinition, + TableDeleteJobPayload, + TableExportJobPayload, + TableJobType, +} from '@/lib/table/types' + +/** Job fields projected onto a {@link TableDefinition}, derived from its latest `table_jobs` row. */ +interface DerivedJobFields { + jobStatus: TableDefinition['jobStatus'] + jobId: string | null + jobType: TableDefinition['jobType'] + jobError: string | null + jobRowsProcessed: number + /** + * Rows a running delete job still has to remove (its doomed estimate minus + * deletions so far). Internal to count adjustment — callers subtract it from + * the raw `row_count` so list/detail counts match the read path's delete + * mask (a mid-delete refresh must not resurrect the count). Not on the wire. + */ + pendingDeleteRemaining: number +} + +export const EMPTY_JOB_FIELDS: DerivedJobFields = { + jobStatus: null, + jobId: null, + jobType: null, + jobError: null, + jobRowsProcessed: 0, + pendingDeleteRemaining: 0, +} + +function mapJobRow( + row: + | { + id: string + type: string + status: string + rowsProcessed: number + error: string | null + payload: unknown + } + | undefined +): DerivedJobFields { + if (!row) return EMPTY_JOB_FIELDS + const doomedCount = + row.type === 'delete' && row.status === 'running' + ? ((row.payload as TableDeleteJobPayload | null)?.doomedCount ?? 0) + : 0 + return { + jobStatus: row.status as TableDefinition['jobStatus'], + jobId: row.id, + jobType: row.type as TableDefinition['jobType'], + jobError: row.error, + jobRowsProcessed: row.rowsProcessed, + pendingDeleteRemaining: Math.max(0, doomedCount - row.rowsProcessed), + } +} + +const JOB_PROJECTION = { + id: tableJobs.id, + type: tableJobs.type, + status: tableJobs.status, + rowsProcessed: tableJobs.rowsProcessed, + error: tableJobs.error, + payload: tableJobs.payload, +} as const + +/** + * The latest job for one table (the running one if present, else the most recent terminal). + * Exports are excluded: they're read-only, run concurrently with other jobs, and have their own + * client surface — surfacing one here would clobber the import/delete/backfill status the tray + * and SSE consumer derive from these fields. + */ +export async function latestJobForTable( + tableId: string, + executor: DbOrTx = db +): Promise { + const [row] = await executor + .select(JOB_PROJECTION) + .from(tableJobs) + .where(and(eq(tableJobs.tableId, tableId), ne(tableJobs.type, 'export'))) + .orderBy(desc(tableJobs.startedAt)) + .limit(1) + return mapJobRow(row) +} + +/** Latest non-export job per table for a batch of ids, via `DISTINCT ON (table_id)`. */ +export async function latestJobsForTables( + tableIds: string[] +): Promise> { + const map = new Map() + if (tableIds.length === 0) return map + const rows = await db + .selectDistinctOn([tableJobs.tableId], { tableId: tableJobs.tableId, ...JOB_PROJECTION }) + .from(tableJobs) + .where(and(inArray(tableJobs.tableId, tableIds), ne(tableJobs.type, 'export'))) + .orderBy(tableJobs.tableId, desc(tableJobs.startedAt)) + for (const row of rows) map.set(row.tableId, mapJobRow(row)) + return map +} + +/** + * Atomically claims a table's single background-job slot by inserting a `running` row into + * `table_jobs`. The partial-unique index on `table_id WHERE status = 'running'` is the + * concurrency gate: a second insert while a job runs hits `ON CONFLICT DO NOTHING` and returns no + * row, so import and delete (and two imports) are mutually exclusive for free. Returns whether it + * claimed the slot; the caller returns 409 when it didn't. + */ +export async function markTableJobRunning( + tableId: string, + jobId: string, + type: TableJobType, + /** Type-specific scope persisted to `table_jobs.payload` (e.g. {@link TableDeleteJobPayload}) + * so read paths can mask the job's effect while it runs. */ + payload?: unknown +): Promise { + // workspace_id is immutable; the atomic gate is the INSERT's conflict, not this read. + const [def] = await db + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where(eq(userTableDefinitions.id, tableId)) + .limit(1) + if (!def) return false + const inserted = await db + .insert(tableJobs) + .values({ + id: jobId, + tableId, + workspaceId: def.workspaceId, + type, + status: 'running', + payload: payload ?? null, + }) + .onConflictDoNothing() + .returning({ id: tableJobs.id }) + return inserted.length > 0 +} + +/** + * Releases a claim taken by {@link markTableJobRunning} for a synchronous job — deletes the + * transient claim row. Scoped to `jobId` + still-running so it only clears its own claim, never a + * newer run. A sync route claims, writes, then releases here in a `finally`. + */ +export async function releaseJobClaim(tableId: string, jobId: string): Promise { + await db + .delete(tableJobs) + .where( + and(eq(tableJobs.id, jobId), eq(tableJobs.tableId, tableId), eq(tableJobs.status, 'running')) + ) +} + +/** + * Records job progress (rows processed so far) and bumps `updated_at` so the stale-job janitor + * (`cleanup-stale-executions`) sees a live heartbeat. + * + * Scoped to `jobId` AND `status = 'running'`: a stale/superseded worker no longer matches (its + * write is a no-op), and once the job is terminal (e.g. canceled) the match fails too — so this + * returning `false` is the worker's signal to stop. Returns whether this worker still owns an + * in-flight job. + */ +export async function updateJobProgress( + tableId: string, + rowsProcessed: number, + jobId: string +): Promise { + const updated = await db + .update(tableJobs) + .set({ rowsProcessed, updatedAt: new Date() }) + .where(ownsActiveJob(tableId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + +/** + * Reads the persisted progress of an in-flight job this worker still owns (`null` when the job + * was canceled/superseded). A retried run seeds its counter from this so progress stays + * cumulative — earlier attempts' batches are already committed, and restarting from zero would + * clobber `rows_processed` (and every count derived from it) with the retry's smaller number. + */ +export async function getJobProgress(tableId: string, jobId: string): Promise { + const [job] = await db + .select({ rowsProcessed: tableJobs.rowsProcessed }) + .from(tableJobs) + .where(ownsActiveJob(tableId, jobId)) + .limit(1) + return job ? job.rowsProcessed : null +} + +/** + * One keyset page of rows for the export worker, ordered by `(position, id)`. Keyset (not + * OFFSET) keeps each page O(page) — offset paging re-scans every prior row per page, which is + * O(N²) across a large export. `(position, id)` is total (position exists on every row; id breaks + * ties) and served by the `(table_id, position)` index; under fractional ordering a manually + * reordered table may export in near-grid rather than exact grid order — the right trade for a + * bulk dump. The delete-job visibility mask applies, like every user-facing read. + */ +export async function selectExportRowPage( + table: TableDefinition, + after: { position: number; id: string } | null, + limit: number +): Promise> { + const deleteMask = await pendingDeleteMask(table) + const rows = await db + .select({ id: userTableRows.id, data: userTableRows.data, position: userTableRows.position }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), + deleteMask, + after + ? sql`(${userTableRows.position}, ${userTableRows.id}) > (${after.position}, ${after.id})` + : undefined + ) + ) + .orderBy(asc(userTableRows.position), asc(userTableRows.id)) + .limit(limit) + return rows as Array<{ id: string; data: RowData; position: number }> +} + +/** How long a terminal export stays listable (and re-downloadable from the tray). */ +const EXPORT_JOB_VISIBILITY_MS = 10 * 60 * 1000 + +export interface WorkspaceExportJob { + jobId: string + tableId: string + tableName: string + status: string + rowsProcessed: number + format: 'csv' | 'json' + hasResult: boolean + error: string | null +} + +/** + * Export jobs the tray surfaces for a workspace: everything running, plus terminals from the last + * {@link EXPORT_JOB_VISIBILITY_MS} so a just-finished export stays re-downloadable. Exports live + * outside the table-level job derivation (which excludes them), so this is their read path. + */ +export async function listWorkspaceExportJobs(workspaceId: string): Promise { + const visibilityCutoff = new Date(Date.now() - EXPORT_JOB_VISIBILITY_MS) + const rows = await db + .select({ + jobId: tableJobs.id, + tableId: tableJobs.tableId, + tableName: userTableDefinitions.name, + status: tableJobs.status, + rowsProcessed: tableJobs.rowsProcessed, + payload: tableJobs.payload, + error: tableJobs.error, + }) + .from(tableJobs) + .innerJoin(userTableDefinitions, eq(userTableDefinitions.id, tableJobs.tableId)) + .where( + and( + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.type, 'export'), + or(eq(tableJobs.status, 'running'), gt(tableJobs.updatedAt, visibilityCutoff)) + ) + ) + .orderBy(desc(tableJobs.startedAt)) + return rows.map((r) => { + const payload = r.payload as TableExportJobPayload | null + return { + jobId: r.jobId, + tableId: r.tableId, + tableName: r.tableName, + status: r.status, + rowsProcessed: r.rowsProcessed, + format: payload?.format ?? 'csv', + hasResult: Boolean(payload?.resultKey), + error: r.error, + } + }) +} + +/** Reads one job row (type/status/payload) scoped to its table. Null when absent. */ +export async function getTableJob( + tableId: string, + jobId: string +): Promise<{ id: string; type: string; status: string; payload: unknown } | null> { + const [job] = await db + .select({ + id: tableJobs.id, + type: tableJobs.type, + status: tableJobs.status, + payload: tableJobs.payload, + }) + .from(tableJobs) + .where(and(eq(tableJobs.id, jobId), eq(tableJobs.tableId, tableId))) + .limit(1) + return job ?? null +} + +/** + * Stamps an export job's generated-file storage key onto its payload (`{ resultKey }` merge). + * Scoped to the still-running job so a superseded attempt can't clobber a newer run's result. + * The download route reads it; the janitor deletes the file when the terminal job is pruned. + */ +export async function setJobResultKey( + tableId: string, + jobId: string, + resultKey: string +): Promise { + await db + .update(tableJobs) + .set({ + payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || jsonb_build_object('resultKey', ${resultKey}::text)`, + updatedAt: new Date(), + }) + .where(ownsActiveJob(tableId, jobId)) +} + +/** Shared WHERE for terminal transitions: this job run, and still in-flight (write-once). */ +function ownsActiveJob(tableId: string, jobId: string) { + return and( + eq(tableJobs.id, jobId), + eq(tableJobs.tableId, tableId), + eq(tableJobs.status, 'running') + ) +} + +/** + * Marks a job complete. 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 async function markJobReady(tableId: string, jobId: string): Promise { + const now = new Date() + const updated = await db + .update(tableJobs) + .set({ status: 'ready', error: null, completedAt: now, updatedAt: now }) + .where(ownsActiveJob(tableId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + +/** + * Marks a job failed, leaving any already-committed work in place. No-op unless it's still this + * in-flight run (so a stale worker can't clobber a newer job or a cancel). + */ +export async function markJobFailed(tableId: string, jobId: string, error: string): Promise { + const now = new Date() + await db + .update(tableJobs) + .set({ status: 'failed', error: error.slice(0, 2000), completedAt: now, updatedAt: now }) + .where(ownsActiveJob(tableId, jobId)) +} + +/** + * Marks an in-flight job canceled (user-initiated). No-op unless it's still running. The + * worker's next ownership check then returns `false` and it stops; committed work is left in + * place (no rollback). Returns whether a running job was actually canceled. + */ +export async function markJobCanceled(tableId: string, jobId: string): Promise { + const now = new Date() + const updated = await db + .update(tableJobs) + .set({ status: 'canceled', completedAt: now, updatedAt: now }) + .where(ownsActiveJob(tableId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} diff --git a/apps/sim/lib/table/llm/enrichment.ts b/apps/sim/lib/table/llm/enrichment.ts index 98e13bfd286..2d9cb7d5d67 100644 --- a/apps/sim/lib/table/llm/enrichment.ts +++ b/apps/sim/lib/table/llm/enrichment.ts @@ -5,7 +5,7 @@ * with table-specific information so LLMs can construct proper queries. */ -import type { TableSummary } from '../types' +import type { TableSummary } from '@/lib/table/types' /** * Operations that use filters and need filter-specific enrichment. diff --git a/apps/sim/lib/table/llm/index.ts b/apps/sim/lib/table/llm/index.ts index 35e73a4299c..4ec60ea4baf 100644 --- a/apps/sim/lib/table/llm/index.ts +++ b/apps/sim/lib/table/llm/index.ts @@ -1 +1 @@ -export * from './enrichment' +export * from '@/lib/table/llm/enrichment' diff --git a/apps/sim/lib/table/llm/wand.ts b/apps/sim/lib/table/llm/wand.ts index 30c036c64ed..d90220c8073 100644 --- a/apps/sim/lib/table/llm/wand.ts +++ b/apps/sim/lib/table/llm/wand.ts @@ -6,7 +6,7 @@ import { db } from '@sim/db' import { userTableDefinitions } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' -import type { TableSchema } from '../types' +import type { TableSchema } from '@/lib/table/types' const logger = createLogger('TableWandEnricher') diff --git a/apps/sim/lib/table/query-builder/constants.ts b/apps/sim/lib/table/query-builder/constants.ts index d7e57c80b0a..ada8bc8c882 100644 --- a/apps/sim/lib/table/query-builder/constants.ts +++ b/apps/sim/lib/table/query-builder/constants.ts @@ -2,7 +2,7 @@ * Constants for table query builder UI (filtering and sorting). */ -export type { FilterRule, SortRule } from '../types' +export type { FilterRule, SortRule } from '@/lib/table/types' export const COMPARISON_OPERATORS = [ { value: 'eq', label: 'equals' }, diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index f0b96ce82b6..4dc29f4b0ae 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -4,7 +4,14 @@ import { generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import type { Filter, FilterRule, JsonValue, Sort, SortDirection, SortRule } from '../types' +import type { + Filter, + FilterRule, + JsonValue, + Sort, + SortDirection, + SortRule, +} from '@/lib/table/types' /** Converts UI filter rules to a Filter object for API queries. */ export function filterRulesToFilter(rules: FilterRule[]): Filter | null { diff --git a/apps/sim/lib/table/query-builder/index.ts b/apps/sim/lib/table/query-builder/index.ts index a6b9a46fec4..7ad04c58aa1 100644 --- a/apps/sim/lib/table/query-builder/index.ts +++ b/apps/sim/lib/table/query-builder/index.ts @@ -2,6 +2,6 @@ * Query builder UI utilities for filtering and sorting tables. */ -export * from './constants' -export * from './converters' -export * from './use-query-builder' +export * from '@/lib/table/query-builder/constants' +export * from '@/lib/table/query-builder/converters' +export * from '@/lib/table/query-builder/use-query-builder' diff --git a/apps/sim/lib/table/query-builder/use-query-builder.ts b/apps/sim/lib/table/query-builder/use-query-builder.ts index 79b2548086d..623ec4cd8fd 100644 --- a/apps/sim/lib/table/query-builder/use-query-builder.ts +++ b/apps/sim/lib/table/query-builder/use-query-builder.ts @@ -4,14 +4,14 @@ import { useCallback } from 'react' import { generateShortId } from '@sim/utils/id' -import type { ColumnOption } from '../types' import { COMPARISON_OPERATORS, type FilterRule, LOGICAL_OPERATORS, SORT_DIRECTIONS, type SortRule, -} from './constants' +} from '@/lib/table/query-builder/constants' +import type { ColumnOption } from '@/lib/table/types' const comparisonOptions: ColumnOption[] = COMPARISON_OPERATORS.map((op) => ({ value: op.value, diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts new file mode 100644 index 00000000000..5555b856178 --- /dev/null +++ b/apps/sim/lib/table/rows/executions.ts @@ -0,0 +1,298 @@ +/** + * Row-executions (workflow-group results) internals for the table service layer. + * + * Internal module: not exposed via the `@/lib/table` barrel. Consumers import + * directly from `@/lib/table/rows/executions`. + */ + +import { tableRowExecutions } from '@sim/db/schema' +import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { getColumnId } from '@/lib/table/column-keys' +import { areGroupDepsSatisfied } from '@/lib/table/deps' +import type { + RowData, + RowExecutionMetadata, + RowExecutions, + TableRow, + TableSchema, +} from '@/lib/table/types' + +/** + * Loads `tableRowExecutions` rows for the given row ids and groups them into a + * `Map` suitable for plugging into `TableRow.executions`. + */ +export 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. */ +export async function loadExecutionsForRow(trx: DbOrTx, rowId: string): Promise { + const byRow = await loadExecutionsByRow(trx, [rowId]) + return byRow.get(rowId) ?? {} +} + +/** + * Derive automatic clears + cancellation candidates from a row's data patch. + * + * Walks `schema.workflowGroups` left-to-right with a propagating `dirtied` + * column set. For each group whose deps overlap the dirty set, decide to + * clear (terminal exec) or cancel+rerun (in-flight exec), then add the + * group's outputs to the dirty set so later groups in the chain see them + * as dirty too. This models transitive dep chains as a single forward pass — + * editing column A propagates through group 1 (deps on A) to group 2 (deps + * on group 1's output) without explicit DAG traversal. + * + * Returns: + * - `executionsPatch`: caller's patch + nulls for cleared groups (or + * undefined if nothing applied). + * - `inFlightDownstreamGroups`: groups whose dep was dirtied and that are + * currently in-flight. Cancel-and-restart is the caller's job. + * + * Assumption: `workflowGroups[]` is in topological order — a group's deps + * may only reference columns to its left (enforced by `workflow-sidebar`'s + * "Run after" picker + the reorder scrub via `stripGroupDeps`). Violating + * this would silently miss the propagation. + */ +export function deriveExecClearsForDataPatch( + dataPatch: RowData, + schema: TableSchema, + existingExecutions: RowExecutions, + callerPatch: Record | undefined, + mergedData: RowData +): { + executionsPatch: Record | undefined + inFlightDownstreamGroups: string[] +} { + const dirtied = new Set(Object.keys(dataPatch)) + const groupsToClear = new Set() + const inFlightDownstreamGroups: string[] = [] + + // Own-output clears: when the user wipes a workflow output column, drop + // that group's exec entry so the auto-fire reactor re-arms the cell. + // Also flags the cleared output column as dirty so transitive downstream + // groups see it. + for (const [columnId, value] of Object.entries(dataPatch)) { + const cleared = value === null || value === undefined || value === '' + if (!cleared) continue + const col = schema.columns.find((c) => getColumnId(c) === columnId) + if (col?.workflowGroupId) groupsToClear.add(col.workflowGroupId) + } + + // Left-to-right walk, propagating dirty columns forward. + const groups = schema.workflowGroups ?? [] + const afterRow = { data: mergedData } as TableRow + for (const group of groups) { + const deps = group.dependencies?.columns ?? [] + const depMatched = deps.some((d) => dirtied.has(d)) + if (!depMatched) continue + + // A dep column changed, but if the group's deps are no longer satisfied + // after the patch — a checkbox was unchecked or a text dep cleared — there's + // nothing to recompute. Leave the prior result alone instead of re-arming or + // cancelling it; only checking a box / filling a dep drives downstream work. + if (!areGroupDepsSatisfied(group, afterRow)) continue + + const exec = existingExecutions[group.id] + if (exec) { + const status = exec.status + if (status === 'completed' || status === 'error' || status === 'cancelled') { + groupsToClear.add(group.id) + } else if (status === 'queued' || status === 'running' || status === 'pending') { + inFlightDownstreamGroups.push(group.id) + } + } else { + // No exec entry yet — `mode: 'new'` already covers this group. We + // still propagate the dirty signal forward so later groups in the + // chain see this group's outputs as dirty too. + groupsToClear.add(group.id) + } + + // Propagate: this group is about to be re-computed, so groups whose + // deps reference its output columns are also dirty. + for (const out of group.outputs) dirtied.add(out.columnName) + } + + if (groupsToClear.size === 0) { + return { executionsPatch: callerPatch, inFlightDownstreamGroups } + } + const merged: Record = { ...(callerPatch ?? {}) } + for (const gid of groupsToClear) { + if (!(gid in merged)) merged[gid] = null + } + return { executionsPatch: merged, inFlightDownstreamGroups } +} + +/** Merges an `executionsPatch` into the row's existing executions blob. */ +export function applyExecutionsPatch( + existing: RowExecutions, + patch: Record | undefined +): RowExecutions { + if (!patch) return existing + const next: RowExecutions = { ...existing } + for (const [gid, value] of Object.entries(patch)) { + if (value === null) { + delete next[gid] + } else { + next[gid] = value + } + } + return next +} + +/** + * 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 + * `guard` is set, the upsert is gated to: + * - reject if a `cancelled` row for the same execution already exists, and + * - reject if the row exists but is owned by a different executionId + * (with carve-outs for missing rows and null executionIds — the dispatcher's + * pre-batch `pending` stamp leaves executionId unset so the first cell-task + * can claim). + * + * Returns `'guard-rejected'` when the guarded group's upsert affected 0 rows + * (callers signal failure to the cell-task path). Returns `'wrote'` otherwise. + */ +export async function writeExecutionsPatch( + trx: DbOrTx, + tableId: string, + rowId: string, + patch: Record | undefined, + guard?: { groupId: string; executionId: string } +): Promise<'wrote' | 'guard-rejected'> { + if (!patch) return 'wrote' + const entries = Object.entries(patch) + if (entries.length === 0) return 'wrote' + + for (const [gid, value] of entries) { + if (value === null) { + await trx + .delete(tableRowExecutions) + .where(and(eq(tableRowExecutions.rowId, rowId), eq(tableRowExecutions.groupId, gid)) as SQL) + continue + } + const insertValues = { + tableId, + rowId, + groupId: gid, + status: value.status, + executionId: value.executionId, + jobId: value.jobId, + workflowId: value.workflowId, + error: value.error, + runningBlockIds: value.runningBlockIds ?? [], + blockErrors: value.blockErrors ?? {}, + cancelledAt: value.cancelledAt ? new Date(value.cancelledAt) : null, + updatedAt: new Date(), + } as const + + const isGuarded = guard && guard.groupId === gid + if (isGuarded) { + // Gate by guard semantics. The original JSONB guard had two AND'd + // clauses; we collapse them onto the upsert's WHERE so a non-matching + // existing row leaves the table untouched and we observe 0 affected. + const guardExecutionId = guard.executionId + const updated = await trx + .insert(tableRowExecutions) + .values(insertValues) + .onConflictDoUpdate({ + target: [tableRowExecutions.rowId, tableRowExecutions.groupId], + set: { + status: insertValues.status, + executionId: insertValues.executionId, + jobId: insertValues.jobId, + workflowId: insertValues.workflowId, + error: insertValues.error, + runningBlockIds: insertValues.runningBlockIds, + blockErrors: insertValues.blockErrors, + cancelledAt: insertValues.cancelledAt, + updatedAt: insertValues.updatedAt, + }, + where: and( + // Reject any guarded worker write when the cell is `cancelled` — a + // stop click wrote it authoritatively. SQL mirror of `isExecCancelled` + // (deps.ts). Status-only (not executionId-scoped): the cancel can + // only carry the pre-stamp's executionId (often null), so matching on + // id would let the worker's real-id claim resurrect a killed cell. + sql`${tableRowExecutions.status} <> 'cancelled'`, + // Stale-worker: the cell's active run has moved on. Carve-outs + // permit a fresh worker to take over when the row's executionId + // is unset (dispatcher's pre-batch `pending` stamp). + sql`(${tableRowExecutions.executionId} IS NULL OR ${tableRowExecutions.executionId} = ${guardExecutionId})` + ) as SQL, + }) + .returning({ rowId: tableRowExecutions.rowId }) + if (updated.length === 0) return 'guard-rejected' + continue + } + + await trx + .insert(tableRowExecutions) + .values(insertValues) + .onConflictDoUpdate({ + target: [tableRowExecutions.rowId, tableRowExecutions.groupId], + set: { + status: insertValues.status, + executionId: insertValues.executionId, + jobId: insertValues.jobId, + workflowId: insertValues.workflowId, + error: insertValues.error, + runningBlockIds: insertValues.runningBlockIds, + blockErrors: insertValues.blockErrors, + cancelledAt: insertValues.cancelledAt, + updatedAt: insertValues.updatedAt, + }, + }) + } + + return 'wrote' +} + +/** + * Strips the given workflow group ids from every row's executions on a table — + * used by the column / group delete paths so stale running/queued exec records + * don't linger and inflate counters after the group is gone. The caller wraps + * in their own transaction. + */ +export async function stripGroupExecutions( + trx: DbOrTx, + tableId: string, + groupIds: Iterable +): Promise { + const ids = Array.from(new Set(groupIds)) + if (ids.length === 0) return + await trx + .delete(tableRowExecutions) + .where( + and(eq(tableRowExecutions.tableId, tableId), inArray(tableRowExecutions.groupId, ids)) as SQL + ) +} diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts new file mode 100644 index 00000000000..ccddfd51d3f --- /dev/null +++ b/apps/sim/lib/table/rows/ordering.ts @@ -0,0 +1,565 @@ +/** + * Row position / fractional-ordering internals for the table service layer. + * + * Internal module: only the import/delete-runner entry points are exposed via + * the `@/lib/table/rows/ordering` path. Not re-exported through the + * `@/lib/table` barrel. + */ + +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { and, asc, desc, eq, gt, gte, inArray, lt, lte, type SQL, sql } from 'drizzle-orm' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import type { DbOrTx } from '@/lib/db/types' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { keyBetween, nKeysBetween } from '@/lib/table/order-key' +import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' +import { setTableTxTimeouts } from '@/lib/table/tx' +import type { RowData } from '@/lib/table/types' + +/** + * 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. + */ +export async function nextImportStartPosition(tableId: string): Promise { + const [{ maxPos }] = await db + .select({ + maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), + }) + .from(userTableRows) + .where(eq(userTableRows.tableId, tableId)) + return maxPos + 1 +} + +/** + * Append anchor `order_key` for an import — `max(order_key)`, or null when empty. Read once, + * unlocked, before streaming (the import worker is the table's sole writer); each batch threads + * the previous batch's last key forward so no per-batch max scan is needed. + */ +export async function nextImportStartOrderKey(tableId: string): Promise { + return maxOrderKey(db, tableId) +} + +/** + * 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. + */ +export 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). */ +export 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. */ +export 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`). */ +export 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`). */ +export 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. + */ +export 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. + */ +export 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}. + */ +export 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 + `) +} + +/** + * 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. + */ +export async function resolveInsertOrderKey( + trx: DbTransaction, + tableId: string, + requestedPosition?: number +): Promise { + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + const orderKeyAtSlot = async (slot: number): Promise => { + if (slot < 0) return null + if (fractionalOrdering) { + 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. + */ +export 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). + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + if (anchorKey === null && fractionalOrdering) { + throw new Error(`Row ${anchorId} has no order_key yet (table not backfilled)`) + } + + if (afterRowId) { + // hi = the smallest key strictly GREATER than the anchor key. Comparing keys + // (not the `(order_key, id)` row tuple) skips past any sibling that shares the + // anchor's key, so `keyBetween` always gets strictly-ordered bounds and can't + // throw on a stray duplicate. Identical to the row tuple when keys are distinct. + // A null anchorKey (flag off, un-backfilled) has no key to compare — leave the + // upper bound open, matching the prior best-effort behavior. + let nextKey: string | null = null + if (anchorKey !== null) { + const [next] = await trx + .select({ orderKey: userTableRows.orderKey }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), gt(userTableRows.orderKey, anchorKey))) + .orderBy(asc(userTableRows.orderKey)) + .limit(1) + nextKey = next?.orderKey ?? null + } + return { + orderKey: keyBetween(anchorKey, nextKey), + position: anchor.position + 1, + } + } + + // beforeRowId: lo = the largest key strictly LESS than the anchor key (distinct, + // same rationale as the afterRowId branch above). + let prevKey: string | null = null + if (anchorKey !== null) { + const [prev] = await trx + .select({ orderKey: userTableRows.orderKey }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), lt(userTableRows.orderKey, anchorKey))) + .orderBy(desc(userTableRows.orderKey)) + .limit(1) + prevKey = prev?.orderKey ?? null + } + return { + orderKey: keyBetween(prevKey, 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. + */ +export async function resolveBatchInsertOrderKeys( + trx: DbTransaction, + tableId: string, + count: number, + positions?: number[] +): Promise { + if ( + !positions || + positions.length === 0 || + (await isFeatureEnabled('tables-fractional-ordering')) + ) { + 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. + */ +export 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) + + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + + // 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 (fractionalOrdering) { + // 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. + */ +export 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 (!(await isFeatureEnabled('tables-fractional-ordering'))) { + 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). + */ +export 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 (!(await isFeatureEnabled('tables-fractional-ordering')) && 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 + }) +} + +/** + * Selects one page of row ids to delete for the async delete-job worker: base scope plus a + * `created_at <= cutoff` floor (so rows inserted after the job started are never selected) and + * the caller's optional filter clause. Keyset paginated on `id` via `afterId` so excluded rows + * (which are skipped, not deleted) still advance the cursor — no OFFSET, no risk of looping on a + * fully-excluded page. + */ +export async function selectRowIdPage(params: { + tableId: string + workspaceId: string + cutoff: Date + filterClause?: SQL + afterId?: string + limit: number +}): Promise { + const { tableId, workspaceId, cutoff, filterClause, afterId, limit } = params + const selectPage = (executor: DbExecutor) => + executor + .select({ id: userTableRows.id }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + lte(userTableRows.createdAt, cutoff), + afterId ? gt(userTableRows.id, afterId) : undefined, + filterClause + ) + ) + .orderBy(asc(userTableRows.id)) + .limit(limit) + // A jsonb filter is unestimatable, so the planner would seq-scan the whole shared relation + // per page (12.6s measured) — keep it on the tenant's (table_id, id) index. + const rows = filterClause + ? await withSeqscanOff(async (trx) => selectPage(trx)) + : await selectPage(db) + return rows.map((r) => r.id) +} + +/** + * Deletes one page of rows for the async delete-job worker, committing each `DELETE_BATCH_SIZE` + * chunk in its own short transaction. One statement per transaction bounds how long the + * statement-level row_count trigger's lock on the definition row is held (a page-wide transaction + * held it for the entire page, starving concurrent inserts and overrunning `statement_timeout`), + * and a mid-page failure loses at most one uncommitted batch — the keyset walker (or a task + * retry) re-walks whatever remains. Skips legacy position compaction: under fractional ordering + * it's unnecessary, and in the legacy path `position` gaps are harmless — rows still order by + * position. Returns the count deleted. + */ +export async function deletePageByIds( + tableId: string, + workspaceId: string, + rowIds: string[] +): Promise { + let deleted = 0 + 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 db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + return trx + .delete(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + inArray(userTableRows.id, batch) + ) + ) + .returning({ id: userTableRows.id }) + }) + deleted += rows.length + } + return deleted +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts new file mode 100644 index 00000000000..c676b178130 --- /dev/null +++ b/apps/sim/lib/table/rows/service.ts @@ -0,0 +1,1680 @@ +/** + * Row CRUD + query operations for the table service layer. + * + * Holds the row-write group (`insertRow`, `batchInsertRows`, `upsertRow`, + * `updateRow`, `deleteRow`, the bulk/filter variants, `replaceTableRows`) and the + * row-read group (`queryRows`, `getRowById`, `findRowMatches`). Mirrors the + * `@/lib/table` service conventions: plain exported async functions, drizzle + * inline, no repository pattern. + * + * Re-exported through the `@/lib/table` barrel. + */ + +import { db } from '@sim/db' +import { tableJobs, userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, count, eq, inArray, lte, notInArray, type SQL, sql } from 'drizzle-orm' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { getColumnId } from '@/lib/table/column-keys' +import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' +import { nKeysBetween } from '@/lib/table/order-key' +import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' +import { + applyExecutionsPatch, + deriveExecClearsForDataPatch, + loadExecutionsByRow, + loadExecutionsForRow, + writeExecutionsPatch, +} from '@/lib/table/rows/executions' +import { + acquireRowOrderLock, + deleteOrderedRow, + deleteOrderedRowsByIds, + insertOrderedRow, + nextRowPosition, + reserveBatchPositions, + reserveInsertPosition, + resolveBatchInsertOrderKeys, + resolveInsertOrderKey, +} from '@/lib/table/rows/ordering' +import { buildFilterClause, buildSortClause, escapeLikePattern } from '@/lib/table/sql' +import { fireTableTrigger } from '@/lib/table/trigger' +import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx' +import type { + BatchInsertData, + BatchUpdateByIdData, + BulkDeleteByIdsData, + BulkDeleteByIdsResult, + BulkDeleteData, + BulkOperationResult, + BulkUpdateData, + ColumnDefinition, + Filter, + InsertRowData, + QueryOptions, + QueryResult, + ReplaceRowsData, + ReplaceRowsResult, + RowData, + RowExecutionMetadata, + RowExecutions, + Sort, + TableDefinition, + TableDeleteJobPayload, + TableRow, + UpdateRowData, + UpsertResult, + UpsertRowData, +} from '@/lib/table/types' +import { + checkBatchUniqueConstraintsDb, + checkUniqueConstraintsDb, + coerceRowToSchema, + coerceRowValues, + getUniqueColumns, + validateRowSize, +} from '@/lib/table/validation' +import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' + +const logger = createLogger('TableRowsService') + +/** + * Inserts a single row into a table. + * + * @param data - Row insertion data + * @param table - Table definition (to avoid re-fetching) + * @param requestId - Request ID for logging + * @returns Inserted row + * @throws Error if validation fails or capacity exceeded + */ +export async function insertRow( + data: InsertRowData, + table: TableDefinition, + requestId: string +): Promise { + // Validate row size + const sizeValidation = validateRowSize(data.data) + if (!sizeValidation.valid) { + throw new Error(sizeValidation.errors.join(', ')) + } + + // Validate against schema + const schemaValidation = coerceRowToSchema(data.data, table.schema) + if (!schemaValidation.valid) { + throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + } + + // Check unique constraints using optimized database query + const uniqueColumns = getUniqueColumns(table.schema) + if (uniqueColumns.length > 0) { + const uniqueValidation = await checkUniqueConstraintsDb(data.tableId, data.data, table.schema) + if (!uniqueValidation.valid) { + throw new Error(uniqueValidation.errors.join(', ')) + } + } + + const rowId = `row_${generateId().replace(/-/g, '')}` + const now = new Date() + + // Capacity enforcement lives in the `increment_user_table_row_count` trigger + // (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 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}`) + + const insertedRow: TableRow = { + id: row.id, + data: row.data as RowData, + executions: {}, + position: row.position, + orderKey: row.orderKey ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } + + void fireTableTrigger( + data.tableId, + table.name, + 'insert', + [insertedRow], + null, + table.schema, + requestId + ) + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: [insertedRow.id], + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.userId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err)) + + return insertedRow +} + +/** + * Inserts multiple rows into a table. + * + * @param data - Batch insertion data + * @param table - Table definition + * @param requestId - Request ID for logging + * @returns Array of inserted rows + * @throws Error if validation fails or capacity exceeded + */ +export async function batchInsertRows( + data: BatchInsertData, + table: TableDefinition, + requestId: string +): Promise { + const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId)) + dispatchAfterBatchInsert(table, result, requestId, data.userId) + return result +} + +/** + * Transaction-bound variant of `batchInsertRows`. Validates rows and unique + * constraints, then performs INSERTs inside the provided transaction. Caller + * is responsible for opening the transaction. Use when row inserts must be + * atomic with other writes (e.g., schema mutations) on the same tx. + * + * Capacity enforcement lives in the `increment_user_table_row_count` trigger + * (migration 0198) — fires per row and raises `Maximum row limit (%) reached ...` + * if the cap is hit mid-batch. + */ +export async function batchInsertRowsWithTx( + trx: DbTransaction, + data: BatchInsertData, + table: TableDefinition, + requestId: string +): Promise { + for (let i = 0; i < data.rows.length; i++) { + const row = data.rows[i] + + const sizeValidation = validateRowSize(row) + if (!sizeValidation.valid) { + throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + } + + const schemaValidation = coerceRowToSchema(row, 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, + trx + ) + if (!uniqueResult.valid) { + const errorMessages = uniqueResult.errors + .map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`) + .join('; ') + throw new Error(errorMessages) + } + } + + const now = new Date() + + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + + 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 acquireRowOrderLock(trx, data.tableId) + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + // 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 (fractionalOrdering) { + // 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, + actorUserId?: string | null +): 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, + triggeredByUserId: actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) +} + +/** + * Replaces all rows in a table with a new set of rows. Deletes existing rows + * and inserts the provided rows inside a single transaction so the table is + * never observed in an empty intermediate state by other readers. + * + * Validates each row against the schema, enforces unique constraints within the + * new rows (existing rows are deleted, so DB-side checks are unnecessary), and + * enforces `maxRows` before the replace executes. + * + * @param data - Replace data (rows to install) + * @param table - Table definition + * @param requestId - Request ID for logging + * @returns Count of rows deleted and inserted + * @throws Error if validation fails or capacity exceeded + */ +export async function replaceTableRows( + data: ReplaceRowsData, + table: TableDefinition, + requestId: string +): Promise { + return db.transaction((trx) => replaceTableRowsWithTx(trx, data, table, requestId)) +} + +/** + * Transaction-bound variant of `replaceTableRows`. Caller opens the transaction. + * Use when the replace must be atomic with other writes (e.g., schema mutations). + */ +export async function replaceTableRowsWithTx( + trx: DbTransaction, + data: ReplaceRowsData, + table: TableDefinition, + requestId: string +): Promise { + if (data.tableId !== table.id) { + throw new Error(`Table ID mismatch: ${data.tableId} vs ${table.id}`) + } + if (data.workspaceId !== table.workspaceId) { + throw new Error(`Workspace ID mismatch: ${data.workspaceId} does not own table ${data.tableId}`) + } + if (data.rows.length > table.maxRows) { + throw new Error( + `Cannot replace: ${data.rows.length} rows exceeds table row limit (${table.maxRows})` + ) + } + + for (let i = 0; i < data.rows.length; i++) { + const row = data.rows[i] + + const sizeValidation = validateRowSize(row) + if (!sizeValidation.valid) { + throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + } + + const schemaValidation = coerceRowToSchema(row, table.schema) + if (!schemaValidation.valid) { + throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + } + } + + const uniqueColumns = getUniqueColumns(table.schema) + if (uniqueColumns.length > 0 && data.rows.length > 0) { + const seen = new Map>() + for (const col of uniqueColumns) { + seen.set(col.name, new Map()) + } + for (let i = 0; i < data.rows.length; i++) { + const row = data.rows[i] + for (const col of uniqueColumns) { + const value = row[col.name] + if (value === null || value === undefined) continue + const normalized = typeof value === 'string' ? value.toLowerCase() : JSON.stringify(value) + const map = seen.get(col.name)! + if (map.has(normalized)) { + throw new Error( + `Row ${i + 1}: Column "${col.name}" must be unique. Value "${String(value)}" duplicates row ${map.get(normalized)! + 1} in batch` + ) + } + map.set(normalized, i) + } + } + } + + const now = new Date() + + const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length + const statementMs = scaledStatementTimeoutMs(totalRowWork, { + baseMs: 120_000, + perRowMs: 3, + }) + + await setTableTxTimeouts(trx, { statementMs }) + + // Serialize concurrent replaces (and concurrent auto-position inserts) on the + // same table. Without this, two concurrent replaces each see their own MVCC + // 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 acquireRowOrderLock(trx, data.tableId) + + const deletedRows = await trx + .delete(userTableRows) + .where(eq(userTableRows.tableId, data.tableId)) + .returning({ id: userTableRows.id }) + + 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 } : {}), + })) + + const batchSize = TABLE_LIMITS.MAX_BATCH_INSERT_SIZE + for (let i = 0; i < rowsToInsert.length; i += batchSize) { + const chunk = rowsToInsert.slice(i, i + batchSize) + const inserted = await trx.insert(userTableRows).values(chunk).returning({ + id: userTableRows.id, + }) + insertedCount += inserted.length + } + } + + logger.info( + `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedRows.length}, inserted ${insertedCount}` + ) + + return { deletedCount: deletedRows.length, insertedCount } +} + +/** + * Upserts a row: updates an existing row if a match is found on the conflict target + * column, otherwise inserts a new row. + * + * Uses a single unique column for matching (not OR across all unique columns) to avoid + * ambiguous matches when multiple unique columns exist. Capacity enforcement lives + * in the `increment_user_table_row_count` trigger (migration 0198). On the insert + * path we acquire the per-table advisory lock and re-check for an existing match + * before inserting, so a concurrent upsert racing on the same conflict target + * cannot produce a duplicate row. + * + * @param data - Upsert data including optional conflictTarget + * @param table - Table definition + * @param requestId - Request ID for logging + * @returns The upserted row and whether it was an insert or update + * @throws Error if no unique columns, ambiguous conflict target, or capacity exceeded + */ +export async function upsertRow( + data: UpsertRowData, + table: TableDefinition, + requestId: string +): Promise { + const schema = table.schema + const uniqueColumns = getUniqueColumns(schema) + + if (uniqueColumns.length === 0) { + throw new Error( + 'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.' + ) + } + + // Determine the single conflict target column, resolving to its stable + // storage id (the row-data key). `conflictTarget` may arrive as an id + // (first-party) or a name (legacy/internal) — match either. + let targetColumnKey: string + if (data.conflictTarget) { + const col = uniqueColumns.find( + (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget + ) + if (!col) { + throw new Error( + `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` + ) + } + targetColumnKey = getColumnId(col) + } else if (uniqueColumns.length === 1) { + targetColumnKey = getColumnId(uniqueColumns[0]) + } else { + throw new Error( + `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify conflictTarget to indicate which column to match on.` + ) + } + + // Validate row data + const sizeValidation = validateRowSize(data.data) + if (!sizeValidation.valid) { + throw new Error(sizeValidation.errors.join(', ')) + } + + const schemaValidation = coerceRowToSchema(data.data, schema) + if (!schemaValidation.valid) { + throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + } + + // Read the conflict-target value *after* coercion so `matchFilter` branches on + // the persisted type (e.g. a coerced `"123"` → `123` matches existing rows). + const targetValue = data.data[targetColumnKey] + if (targetValue === undefined || targetValue === null) { + // Surface the display name, not the internal id — v1 callers pass a name. + const targetColumnName = + uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey + throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) + } + + // `data->` and `data->>` accept the JSON key as a parameterized text value; + // no need for `sql.raw` interpolation. + const matchFilter = + typeof targetValue === 'string' + ? sql`${userTableRows.data}->>${targetColumnKey}::text = ${String(targetValue)}` + : sql`(${userTableRows.data}->${targetColumnKey}::text)::jsonb = ${JSON.stringify(targetValue)}::jsonb` + + // Capacity enforcement for the insert path lives in the `increment_user_table_row_count` + // trigger (migration 0198). The update path doesn't change row_count, so no check needed. + const result = await db.transaction(async (trx) => { + await setTableTxTimeouts(trx) + // The conflict lookups below match on `data->>key` — unestimatable, and an + // insert-path upsert (no existing match) can't exit early, so the planner + // would seq-scan the whole shared relation. See withSeqscanOff. + await trx.execute(sql`SET LOCAL enable_seqscan = off`) + + // Find existing row by single conflict target column + const [existingRow] = await trx + .select() + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId), + matchFilter + ) + ) + .limit(1) + + // Check uniqueness on ALL unique columns (not just the conflict target) + const uniqueValidation = await checkUniqueConstraintsDb( + data.tableId, + data.data, + schema, + existingRow?.id, // exclude the matched row on updates + trx + ) + if (!uniqueValidation.valid) { + throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + } + + const now = new Date() + + // Resolve which row (if any) we should update. If the initial SELECT missed, + // acquire the lock and re-check — a concurrent upsert may have inserted the + // matching row between our SELECT and the INSERT path; without the re-check + // both transactions would insert and bypass the app-level unique check. + let matchedRowId = existingRow?.id + let previousData = existingRow?.data as RowData | undefined + if (!matchedRowId) { + await acquireRowOrderLock(trx, data.tableId) + const [racedRow] = await trx + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId), + matchFilter + ) + ) + .limit(1) + if (racedRow) { + matchedRowId = racedRow.id + previousData = racedRow.data as RowData + } + } + + if (matchedRowId) { + const [updatedRow] = await trx + .update(userTableRows) + .set({ data: data.data, updatedAt: now }) + .where(eq(userTableRows.id, matchedRowId)) + .returning() + + const executions = await loadExecutionsForRow(trx, updatedRow.id) + return { + row: { + id: updatedRow.id, + data: updatedRow.data as RowData, + executions, + position: updatedRow.position, + orderKey: updatedRow.orderKey ?? undefined, + createdAt: updatedRow.createdAt, + updatedAt: updatedRow.updatedAt, + }, + previousData, + operation: 'update' as const, + } + } + + const [insertedRow] = await trx + .insert(userTableRows) + .values({ + id: `row_${generateId().replace(/-/g, '')}`, + tableId: data.tableId, + workspaceId: data.workspaceId, + data: data.data, + position: await reserveInsertPosition(trx, data.tableId), + orderKey: await resolveInsertOrderKey(trx, data.tableId), + createdAt: now, + updatedAt: now, + ...(data.userId ? { createdBy: data.userId } : {}), + }) + .returning() + + return { + row: { + id: insertedRow.id, + data: insertedRow.data as RowData, + executions: {}, + position: insertedRow.position, + orderKey: insertedRow.orderKey ?? undefined, + createdAt: insertedRow.createdAt, + updatedAt: insertedRow.updatedAt, + }, + operation: 'insert' as const, + } + }) + + logger.info( + `[${requestId}] Upserted (${result.operation}) row ${result.row.id} in table ${data.tableId}` + ) + + if (result.operation === 'insert') { + void fireTableTrigger( + data.tableId, + table.name, + 'insert', + [result.row], + null, + table.schema, + requestId + ) + } else if (result.operation === 'update' && result.previousData) { + const oldRows = new Map([[result.row.id, result.previousData]]) + void fireTableTrigger( + data.tableId, + table.name, + 'update', + [result.row], + oldRows, + table.schema, + requestId + ) + } + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: [result.row.id], + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.userId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err)) + + return result +} + +/** + * Canonical ORDER BY for a table's rows, shared by `queryRows` (the paginated + * list) and `findRowMatches` so a match's ordinal lines up with its index in + * the list. Order: explicit data sort (if any) → fractional `order_key` or + * legacy `position` → `id`. The `id` tiebreak is always appended so equal + * positions order deterministically — without it two separate query executions + * (a find vs a list page) could shuffle ties and misalign ordinals. + */ +function buildRowOrderBySql( + sort: Sort | undefined, + tableName: string, + columns: ColumnDefinition[], + fractionalOrderingEnabled: boolean +): SQL { + const primary = fractionalOrderingEnabled ? `${tableName}.order_key` : `${tableName}.position` + const id = `${tableName}.id` + if (sort && Object.keys(sort).length > 0) { + const sortClause = buildSortClause(sort, tableName, columns) + if (sortClause) { + return sql.join([sortClause, sql.raw(primary), sql.raw(id)], sql.raw(', ')) + } + } + return sql.raw(`${primary}, ${id}`) +} + +/** One matching cell from {@link findRowMatches}. */ +export interface FindRowMatch { + /** 0-based index of the row in the filtered+sorted view (aligns with the list query). */ + ordinal: number + rowId: string + /** Stable column id of the matching cell (the JSONB storage key), not the display name. */ + column: string +} + +/** Max matching cells returned by {@link findRowMatches}; one extra is fetched to detect truncation. */ +const FIND_MATCH_LIMIT = 1000 + +/** + * Case-insensitive substring search across every cell of a table's rows. Each + * matching cell becomes a {@link FindRowMatch} carrying its row id, column, and + * 0-based ordinal in the filtered+sorted view (so the client can page up to and + * reveal it). `filter`/`sort` mirror the active list view via + * {@link buildRowOrderBySql}, keeping ordinals aligned. + * + * Cost: one pass over the table's rows — `ILIKE` over `jsonb_each_text` cannot + * use the JSONB GIN index, and the ordinal's `row_number()` needs every row + * counted regardless. The planner can't estimate the lateral ILIKE (jsonb is + * opaque to it), so left alone it seq-scans the entire shared relation and + * disk-sorts the window input (measured 75s on a 1M-row table in a 12M-row + * relation). `SET LOCAL` planner flags keep it tenant-bounded; on the default + * order they additionally force the streaming `(table_id, order_key, id)` index + * walk where `row_number()` needs no sort at all (measured 2s). A `pg_trgm` GIN + * index on a text projection is the future accelerator if needed. + */ +export async function findRowMatches( + table: TableDefinition, + options: { q: string; filter?: Filter; sort?: Sort }, + requestId: string +): Promise<{ matches: FindRowMatch[]; truncated: boolean }> { + const tableName = USER_TABLE_ROWS_SQL_NAME + const columns = table.schema.columns + // Row data is keyed by stable column id, so scan/return JSONB keys as ids. + const columnIds = columns.map(getColumnId) + if (columnIds.length === 0) return { matches: [], truncated: false } + + // Same visibility rule as queryRows: don't surface rows a running delete job will remove. + const deleteMask = await pendingDeleteMask(table) + + const baseConditions = and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), + deleteMask + ) + let whereClause: SQL | undefined = baseConditions + if (options.filter && Object.keys(options.filter).length > 0) { + const filterClause = buildFilterClause(options.filter, tableName, columns) + if (filterClause) whereClause = and(baseConditions, filterClause) + } + + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + const orderBySql = buildRowOrderBySql(options.sort, tableName, columns, fractionalOrdering) + const pattern = `%${escapeLikePattern(options.q)}%` + + const result = await db.transaction(async (trx) => { + // Planner flags, not correctness: `enable_* = off` only penalizes a plan shape, so a + // genuinely required sort still runs. Seqscan off keeps the scan inside the tenant's rows + // (the lateral ILIKE is unestimatable, so the planner otherwise walks the whole shared + // relation). On the default order, the remaining flags steer to the already-sorted + // `(table_id, order_key, id)` index walk so the window function streams without a 100MB+ + // disk sort; a custom sort has no index to stream from, so those flags would only distort + // that plan. + await trx.execute(sql`SET LOCAL enable_seqscan = off`) + if (!options.sort) { + await trx.execute(sql`SET LOCAL enable_bitmapscan = off`) + await trx.execute(sql`SET LOCAL enable_sort = off`) + await trx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`) + } + return trx.execute<{ + ordinal: string | number + id: string + column_name: string + }>(sql` + WITH ordered AS ( + SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal + FROM ${userTableRows} + WHERE ${whereClause} + ) + SELECT o.ordinal, o.id, kv.key AS column_name + FROM ordered o + CROSS JOIN LATERAL jsonb_each_text(o.data) kv + WHERE kv.value ILIKE ${pattern} + AND ${inArray(sql`kv.key`, columnIds)} + ORDER BY o.ordinal + LIMIT ${FIND_MATCH_LIMIT + 1} + `) + }) + + const all = Array.from(result) + const truncated = all.length > FIND_MATCH_LIMIT + const sliced = truncated ? all.slice(0, FIND_MATCH_LIMIT) : all + const matches: FindRowMatch[] = sliced.map((r) => ({ + ordinal: Number(r.ordinal), + rowId: r.id, + column: r.column_name, + })) + + logger.info( + `[${requestId}] Find "${options.q}" in table ${table.id}: ${matches.length} match(es)${truncated ? ' (truncated)' : ''}` + ) + + return { matches, truncated } +} + +/** + * Queries rows from a table with filtering, sorting, and pagination. + * + * Filter cost model: equality filters (`$eq`, `$in`) compile to JSONB + * containment (`@>`) and hit the GIN (jsonb_path_ops) index on + * `user_table_rows.data`. Range operators (`$gt`, `$gte`, `$lt`, `$lte`) and + * `$contains` compile to `data->>'field'` text extraction and bypass the GIN + * index — they fall back to a sequential scan of the rows for the table + * (bounded only by the btree on `table_id`). Prefer equality on hot paths; set + * `includeTotal: false` when the caller does not need the `COUNT(*)`. + * + * @param table - Table definition (provides id, workspaceId, and column schema for type-aware filter/sort casts) + * @param options - Query options (filter, sort, limit, offset) + * @param requestId - Request ID for logging + * @returns Query result with rows and pagination info + */ +/** + * Visibility mask for a running delete job: returns a clause keeping only rows the job will NOT + * delete, or `undefined` when no delete job is running. The job's persisted scope + * ({@link TableDeleteJobPayload}) defines the doomed set — `matches(filter) AND created_at <= + * cutoff AND id NOT IN excludeRowIds` — exactly what the worker's `selectRowIdPage` selects, so + * mid-job reads (refresh, other clients, exports) are consistent with the eventual result. The + * mask lifts automatically when the job leaves `running` (done, failed, or canceled). + * + * `(doomed) IS NOT TRUE` rather than `NOT (doomed)`: JSONB predicates evaluate to NULL on missing + * cells, and those rows are NOT selected for deletion (NULL ≠ TRUE) — they must stay visible. + */ +export async function pendingDeleteMask(table: TableDefinition): Promise { + const [job] = await db + .select({ payload: tableJobs.payload }) + .from(tableJobs) + .where( + and( + eq(tableJobs.tableId, table.id), + eq(tableJobs.status, 'running'), + eq(tableJobs.type, 'delete') + ) + ) + .limit(1) + if (!job?.payload) return undefined + const scope = job.payload as TableDeleteJobPayload + + const doomedParts: SQL[] = [] + if (scope.filter && Object.keys(scope.filter).length > 0) { + try { + const clause = buildFilterClause(scope.filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) + if (clause) doomedParts.push(clause) + } catch (error) { + // Schema drifted mid-job (column renamed/deleted). Showing doomed rows briefly beats + // failing every read; the worker resolves the same way on its next page. + logger.warn(`Skipping delete-job mask for table ${table.id}: stale filter`, { + error: toError(error).message, + }) + return undefined + } + } + if (scope.cutoff) doomedParts.push(lte(userTableRows.createdAt, new Date(scope.cutoff))) + if (scope.excludeRowIds && scope.excludeRowIds.length > 0) { + doomedParts.push(notInArray(userTableRows.id, scope.excludeRowIds)) + } + if (doomedParts.length === 0) return undefined + return sql`(${and(...doomedParts)}) IS NOT TRUE` +} + +/** + * `COUNT(*)` for a filtered view, kept inside the tenant's rows: measured + * 12.7s → 1.0s counting a rare ILIKE filter on a 1M-row table inside a 12M-row + * relation (see {@link withSeqscanOff} for why the planner gets this wrong). + */ +async function countRowsTenantBounded(whereClause: SQL | undefined): Promise { + return withSeqscanOff(async (trx) => { + const [result] = await trx.select({ count: count() }).from(userTableRows).where(whereClause) + return Number(result.count) + }) +} + +export async function queryRows( + table: TableDefinition, + options: QueryOptions, + requestId: string +): Promise { + const { + filter, + sort, + limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, + offset = 0, + after, + includeTotal = true, + withExecutions = true, + } = options + + const tableName = USER_TABLE_ROWS_SQL_NAME + const columns = table.schema.columns + + // Hide rows a running delete job is about to remove — both the page and the count below share + // this clause, so totals stay consistent with the visible rows. + const [deleteMask, fractionalOrdering] = await Promise.all([ + pendingDeleteMask(table), + isFeatureEnabled('tables-fractional-ordering'), + ]) + + const baseConditions = and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), + deleteMask + ) + + let whereClause = baseConditions + if (filter && Object.keys(filter).length > 0) { + const filterClause = buildFilterClause(filter, tableName, columns) + if (filterClause) { + whereClause = and(baseConditions, filterClause) + } + } + + // Keyset page: seek past the cursor on the default `(order_key, id)` order instead of paying + // OFFSET's scan-and-discard of every prior row (O(N²) across a deep scroll / full drain). Only + // valid without a custom sort — the contract rejects `after` + `sort` together. The count below + // deliberately excludes the cursor: totals cover the whole view, not the remaining pages. + const pageWhere = + after && !sort + ? and( + whereClause, + sql`(${userTableRows.orderKey}, ${userTableRows.id}) > (${after.orderKey}, ${after.id})` + ) + : whereClause + + const buildPageQuery = (executor: DbExecutor) => { + const query = executor + .select() + .from(userTableRows) + .where(pageWhere ?? baseConditions) + .orderBy(buildRowOrderBySql(sort, tableName, columns, fractionalOrdering)) + return after ? query.limit(limit) : 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. Filtered counts + // go through the tenant-bounded variant (see countRowsTenantBounded); the + // unfiltered count already plans an index-only scan on the table_id prefix. + // Custom column sorts order by `data->>'col'` — unestimatable, so left alone + // the planner seq-scans and sorts the whole shared relation on every page + // (9.7s measured on a 1M-row table; 0.76s tenant-bounded). Default-order + // pages already stream the `(table_id, order_key, id)` index. + const hasFilter = Boolean(filter && Object.keys(filter).length > 0) + const rowsPromise = sort ? withSeqscanOff(async (trx) => buildPageQuery(trx)) : buildPageQuery(db) + const countPromise = includeTotal + ? hasFilter + ? countRowsTenantBounded(whereClause) + : db + .select({ count: count() }) + .from(userTableRows) + .where(whereClause ?? baseConditions) + .then((r) => Number(r[0].count)) + : null + + const [rows, totalCount] = await Promise.all([rowsPromise, countPromise]) + + 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})` + ) + + return { + rows: rows.map((r) => ({ + id: r.id, + data: r.data as RowData, + executions: executionsByRow?.get(r.id) ?? {}, + position: r.position, + orderKey: r.orderKey ?? undefined, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })), + rowCount: rows.length, + totalCount, + limit, + offset, + } +} + +/** + * Gets a single row by ID. + * + * @param tableId - Table ID + * @param rowId - Row ID to fetch + * @param workspaceId - Workspace ID for access control + * @returns Row or null if not found + */ +export async function getRowById( + tableId: string, + rowId: string, + workspaceId: string +): Promise { + const results = await db + .select() + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (results.length === 0) return null + + const row = results[0] + const executions = await loadExecutionsForRow(db, row.id) + return { + id: row.id, + data: row.data as RowData, + executions, + position: row.position, + orderKey: row.orderKey ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +/** Internal: thrown inside `db.transaction` to roll back when the executions + * guard rejects a write. The outer `.catch` translates it into a `null` return. */ +class GuardRejected extends Error { + constructor() { + super('cell-write guard rejected') + } +} + +/** + * Updates a single row. + * + * @param data - Update data + * @param table - Table definition + * @param requestId - Request ID for logging + * @returns Updated row + * @throws Error if row not found or validation fails + */ +export async function updateRow( + data: UpdateRowData, + table: TableDefinition, + requestId: string +): Promise { + // Get existing row + const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) + if (!existingRow) { + throw new Error('Row not found') + } + + // Merge partial update with existing row data so callers can pass only changed fields + const mergedData = { + ...(existingRow.data as RowData), + ...data.data, + } + // Auto-clear exec records for workflow output columns the user just wiped + // AND for downstream groups whose deps just changed. Surfaces the in-flight + // downstream groups so the caller can cancel + re-run them. + const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = + deriveExecClearsForDataPatch( + data.data, + table.schema, + existingRow.executions, + data.executionsPatch, + mergedData + ) + const mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch) + + // Validate size + const sizeValidation = validateRowSize(mergedData) + if (!sizeValidation.valid) { + throw new Error(sizeValidation.errors.join(', ')) + } + + // Validate against schema + const schemaValidation = coerceRowToSchema(mergedData, table.schema) + if (!schemaValidation.valid) { + throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + } + + // Check unique constraints using optimized database query + const uniqueColumns = getUniqueColumns(table.schema) + if (uniqueColumns.length > 0) { + const uniqueValidation = await checkUniqueConstraintsDb( + data.tableId, + mergedData, + table.schema, + data.rowId // Exclude current row + ) + if (!uniqueValidation.valid) { + throw new Error(uniqueValidation.errors.join(', ')) + } + } + + const now = new Date() + + // Cell-task partial writes pass `cancellationGuard` so the upsert into + // `tableRowExecutions` is a no-op when (a) a stop click already wrote + // `cancelled` for this run, or (b) a newer run has taken over the cell + // with a different executionId. Authoritative cancel writes from + // `cancelWorkflowGroupRuns` skip the guard entirely. Data + executions + // commit in one transaction so a partial write can't leave the sidecar + // and the row out of sync. + const guard = data.cancellationGuard + const guardRejected = await db + .transaction(async (trx) => { + await trx + .update(userTableRows) + .set({ data: mergedData, updatedAt: now }) + .where(eq(userTableRows.id, data.rowId)) + + const result = await writeExecutionsPatch( + trx, + data.tableId, + data.rowId, + effectiveExecutionsPatch, + guard + ) + if (result === 'guard-rejected') { + // Roll back the data update too — the worker isn't authoritative. + throw new GuardRejected() + } + return false + }) + .catch((err) => { + if (err instanceof GuardRejected) return true + throw err + }) + + if (guardRejected) { + return null + } + + logger.info(`[${requestId}] Updated row ${data.rowId} in table ${data.tableId}`) + + const updatedRow: TableRow = { + id: data.rowId, + data: mergedData, + executions: mergedExecutions, + position: existingRow.position, + createdAt: existingRow.createdAt, + updatedAt: now, + } + + const oldRows = new Map([[data.rowId, existingRow.data as RowData]]) + void fireTableTrigger( + data.tableId, + table.name, + 'update', + [updatedRow], + oldRows, + table.schema, + requestId + ) + + // Auto-fire only on user-facing data edits. Internal callers that mutate + // executions (cell-task partial/terminal writes, cancel writes) always pass + // `executionsPatch` — re-dispatching from those would recursively spawn new + // dispatches for every running/terminal write, flooding the dispatcher with + // redundant pre-stamps that strand `pending` cells. + const isInternalExecWrite = data.executionsPatch && Object.keys(data.executionsPatch).length > 0 + if (isInternalExecWrite) { + return updatedRow + } + + // Two passes: + // 1. Cancel in-flight downstream groups whose dep just changed, then + // manually re-run them — the cancel writes `cancelled` per cell and + // `mode: 'incomplete' + isManualRun: true` wipes those entries and + // re-enqueues. + // 2. `mode: 'new'` for groups that just had their exec entries cleared + // (own-output wipe OR terminal downstream dep-changed) — the + // dispatcher's `jsonb_exists_all` SQL filter lets the row through + // because at least one targeted group's exec is now missing. + if (inFlightDownstreamGroups.length > 0) { + void (async () => { + try { + await cancelWorkflowGroupRuns(data.tableId, data.rowId, { + groupIds: inFlightDownstreamGroups, + }) + await runWorkflowColumn({ + tableId: data.tableId, + workspaceId: data.workspaceId, + mode: 'incomplete', + isManualRun: true, + rowIds: [data.rowId], + groupIds: inFlightDownstreamGroups, + requestId, + triggeredByUserId: data.actorUserId, + }) + } catch (err) { + logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err) + } + })() + } + void runWorkflowColumn({ + tableId: data.tableId, + workspaceId: data.workspaceId, + rowIds: [data.rowId], + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err)) + + return updatedRow +} + +/** + * Deletes a single row (hard delete). + * + * @param tableId - Table ID + * @param rowId - Row ID to delete + * @param workspaceId - Workspace ID for access control + * @param requestId - Request ID for logging + * @throws Error if row not found + */ +export async function deleteRow( + tableId: string, + rowId: string, + workspaceId: string, + requestId: string +): Promise { + const deleted = await deleteOrderedRow({ tableId, rowId, workspaceId }) + if (!deleted) throw new Error('Row not found') + + logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`) +} + +/** + * Updates multiple rows matching a filter. + * + * @param table - Table definition (provides column schema for type-aware filter casts) + * @param data - Bulk update data + * @param requestId - Request ID for logging + * @returns Bulk operation result + */ +export async function updateRowsByFilter( + table: TableDefinition, + data: BulkUpdateData, + requestId: string +): Promise { + const tableName = USER_TABLE_ROWS_SQL_NAME + + const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) + if (!filterClause) { + throw new Error('Filter is required for bulk update') + } + + const baseConditions = and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId) + ) + + // Tenant-bounded: the jsonb filter is unestimatable and otherwise sends the planner to a + // whole-shared-relation seq scan (14.4s measured on a 1M-row table). + const matchingRows = await withSeqscanOff(async (trx) => { + let query = trx + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where(and(baseConditions, filterClause)) + if (data.limit) { + query = query.limit(data.limit) as typeof query + } + return query + }) + + if (matchingRows.length === 0) { + return { affectedCount: 0, affectedRowIds: [] } + } + + // Coerce the patch itself in place — the write below persists `data.data` + // (as `patchJson`), so coercing only the per-row merged copies would be + // discarded. The merged validation in the loop still enforces required + // fields against the full row. + coerceRowValues(data.data, table.schema) + + for (const row of matchingRows) { + const existingData = row.data as RowData + const mergedData = { ...existingData, ...data.data } + + const sizeValidation = validateRowSize(mergedData) + if (!sizeValidation.valid) { + throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) + } + + const schemaValidation = coerceRowToSchema(mergedData, table.schema) + if (!schemaValidation.valid) { + throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) + } + } + + const uniqueColumns = getUniqueColumns(table.schema) + const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) + if (uniqueColumnsInUpdate.length > 0) { + if (matchingRows.length > 1) { + throw new Error( + `Cannot set unique column values when updating multiple rows. ` + + `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + + `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` + ) + } + + // Only one row — only the touched unique columns need re-checking. + const row = matchingRows[0] + const mergedData = { ...(row.data as RowData), ...data.data } + const uniqueValidation = await checkUniqueConstraintsDb( + table.id, + mergedData, + table.schema, + row.id + ) + if (!uniqueValidation.valid) { + throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + } + } + + const now = new Date() + const ids = matchingRows.map((r) => r.id) + const patchJson = JSON.stringify(data.data) + + await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + for (let i = 0; i < ids.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { + const batchIds = ids.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) + await trx + .update(userTableRows) + .set({ + data: sql`${userTableRows.data} || ${patchJson}::jsonb`, + updatedAt: now, + }) + .where(inArray(userTableRows.id, batchIds)) + } + }) + + logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${table.id}`) + + const oldRows = new Map(matchingRows.map((r) => [r.id, r.data as RowData])) + const updatedRows: TableRow[] = matchingRows.map((r) => ({ + id: r.id, + data: { ...(r.data as RowData), ...data.data }, + executions: {}, + position: 0, + createdAt: now, + updatedAt: now, + })) + void fireTableTrigger( + table.id, + table.name, + 'update', + updatedRows, + oldRows, + table.schema, + requestId + ) + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: updatedRows.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err)) + + return { + affectedCount: matchingRows.length, + affectedRowIds: ids, + } +} + +/** + * Updates multiple rows with per-row data in a single transaction. + * Avoids the race condition of parallel update_row calls overwriting each other. + */ +export async function batchUpdateRows( + data: BatchUpdateByIdData, + table: TableDefinition, + requestId: string +): Promise { + if (data.updates.length === 0) { + return { affectedCount: 0, affectedRowIds: [] } + } + + const rowIds = data.updates.map((u) => u.rowId) + const existingRows = await db + .select({ + id: userTableRows.id, + data: userTableRows.data, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId), + inArray(userTableRows.id, rowIds) + ) + ) + + const executionsByRow = await loadExecutionsByRow( + db, + existingRows.map((r) => r.id) + ) + + type ExistingRow = { data: RowData; executions: RowExecutions } + const existingMap = new Map( + existingRows.map((r) => [ + r.id, + { data: r.data as RowData, executions: executionsByRow.get(r.id) ?? {} }, + ]) + ) + + const missing = rowIds.filter((id) => !existingMap.has(id)) + if (missing.length > 0) { + throw new Error(`Rows not found: ${missing.join(', ')}`) + } + + const mergedUpdates: Array<{ + rowId: string + mergedData: RowData + mergedExecutions: RowExecutions + executionsPatch?: Record + inFlightDownstreamGroups: string[] + }> = [] + for (const update of data.updates) { + const existing = existingMap.get(update.rowId)! + const merged = { ...existing.data, ...update.data } + // Auto-clear exec records for workflow output columns the user just + // wiped AND downstream dep-changed terminal groups — same rationale as + // `updateRow`. Per-row in-flight downstream groups are surfaced so we + // can run the cancel+rerun orchestration after the batch commits. + const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = + deriveExecClearsForDataPatch( + update.data, + table.schema, + existing.executions, + update.executionsPatch, + merged + ) + const mergedExecutions = applyExecutionsPatch(existing.executions, effectiveExecutionsPatch) + + const sizeValidation = validateRowSize(merged) + if (!sizeValidation.valid) { + throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) + } + + const schemaValidation = coerceRowToSchema(merged, table.schema) + if (!schemaValidation.valid) { + throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) + } + + mergedUpdates.push({ + rowId: update.rowId, + mergedData: merged, + mergedExecutions, + executionsPatch: effectiveExecutionsPatch, + inFlightDownstreamGroups, + }) + } + + const uniqueColumns = getUniqueColumns(table.schema) + if (uniqueColumns.length > 0) { + for (const { rowId, mergedData } of mergedUpdates) { + const uniqueValidation = await checkUniqueConstraintsDb( + data.tableId, + mergedData, + table.schema, + rowId + ) + if (!uniqueValidation.valid) { + throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`) + } + } + } + + const now = new Date() + + await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { + const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) + // Update row data in parallel; sidecar exec writes are sequential per + // row (each goes through writeExecutionsPatch's per-key upsert). + const dataPromises = batch.map(({ rowId, mergedData }) => + trx + .update(userTableRows) + .set({ data: mergedData, updatedAt: now }) + .where(eq(userTableRows.id, rowId)) + ) + await Promise.all(dataPromises) + for (const { rowId, executionsPatch } of batch) { + await writeExecutionsPatch(trx, data.tableId, rowId, executionsPatch) + } + } + }) + + logger.info(`[${requestId}] Batch updated ${mergedUpdates.length} rows in table ${data.tableId}`) + + const oldRowsForTrigger = new Map( + data.updates.map((u) => [u.rowId, existingMap.get(u.rowId)!.data]) + ) + const updatedRowsForTrigger: TableRow[] = mergedUpdates.map( + ({ rowId, mergedData, mergedExecutions }) => ({ + id: rowId, + data: mergedData, + executions: mergedExecutions, + position: 0, + createdAt: now, + updatedAt: now, + }) + ) + void fireTableTrigger( + data.tableId, + table.name, + 'update', + updatedRowsForTrigger, + oldRowsForTrigger, + table.schema, + requestId + ) + // Per-row cancel+rerun for in-flight downstream groups whose deps just + // changed — same orchestration as single-row `updateRow`. Without this, + // batch updates would leave running workflows reading stale dep values. + // Each row needs its own cancel + manual-incomplete dispatch because + // `cancelWorkflowGroupRuns`'s `groupIds` filter is per-row. + const rowsWithInFlightDownstream = mergedUpdates.filter( + (u) => u.inFlightDownstreamGroups.length > 0 + ) + if (rowsWithInFlightDownstream.length > 0) { + void (async () => { + try { + for (const { rowId, inFlightDownstreamGroups } of rowsWithInFlightDownstream) { + await cancelWorkflowGroupRuns(data.tableId, rowId, { + groupIds: inFlightDownstreamGroups, + }) + await runWorkflowColumn({ + tableId: data.tableId, + workspaceId: data.workspaceId, + mode: 'incomplete', + isManualRun: true, + rowIds: [rowId], + groupIds: inFlightDownstreamGroups, + requestId, + triggeredByUserId: data.actorUserId, + }) + } + } catch (err) { + logger.error( + `[${requestId}] cancel+rerun for in-flight downstream groups (batch) failed:`, + err + ) + } + })() + } + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: updatedRowsForTrigger.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) + + return { + affectedCount: mergedUpdates.length, + affectedRowIds: mergedUpdates.map((u) => u.rowId), + } +} + +/** + * Deletes multiple rows matching a filter. + * + * @param table - Table definition (provides column schema for type-aware filter casts) + * @param data - Bulk delete data + * @param requestId - Request ID for logging + * @returns Bulk operation result + */ +export async function deleteRowsByFilter( + table: TableDefinition, + data: BulkDeleteData, + requestId: string +): Promise { + const tableName = USER_TABLE_ROWS_SQL_NAME + + // Build filter clause + const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) + if (!filterClause) { + throw new Error('Filter is required for bulk delete') + } + + // Find matching rows + const baseConditions = and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId) + ) + + // Tenant-bounded for the same reason as updateRowsByFilter — see withSeqscanOff. + const matchingRows = await withSeqscanOff(async (trx) => { + let query = trx + .select({ id: userTableRows.id, position: userTableRows.position }) + .from(userTableRows) + .where(and(baseConditions, filterClause)) + if (data.limit) { + query = query.limit(data.limit) as typeof query + } + return query + }) + + if (matchingRows.length === 0) { + return { affectedCount: 0, affectedRowIds: [] } + } + + const rowIds = matchingRows.map((r) => r.id) + + await deleteOrderedRowsByIds({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds, + }) + + logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) + + return { + affectedCount: matchingRows.length, + affectedRowIds: rowIds, + } +} + +/** + * Deletes rows by their IDs. + * + * @param data - Row IDs and table context + * @param requestId - Request ID for logging + * @returns Deletion result with deleted/missing row IDs + */ +export async function deleteRowsByIds( + data: BulkDeleteByIdsData, + requestId: string +): Promise { + const uniqueRequestedRowIds = Array.from(new Set(data.rowIds)) + + const deletedRows = await deleteOrderedRowsByIds({ + tableId: data.tableId, + workspaceId: data.workspaceId, + rowIds: uniqueRequestedRowIds, + }) + + const deletedIds = deletedRows.map((r) => r.id) + const deletedIdSet = new Set(deletedIds) + const missingRowIds = uniqueRequestedRowIds.filter((id) => !deletedIdSet.has(id)) + + logger.info(`[${requestId}] Deleted ${deletedIds.length} rows by ID from table ${data.tableId}`) + + return { + deletedCount: deletedIds.length, + deletedRowIds: deletedIds, + requestedCount: uniqueRequestedRowIds.length, + missingRowIds, + } +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 1037f6c6526..923cc97e0c3 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -8,101 +8,27 @@ */ import { db } from '@sim/db' -import { tableJobs, tableRowExecutions, userTableDefinitions, userTableRows } from '@sim/db/schema' +import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { - and, - asc, - count, - desc, - eq, - gt, - gte, - inArray, - isNull, - lt, - lte, - ne, - notInArray, - or, - type SQL, - sql, -} from 'drizzle-orm' -import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/feature-flags' +import { and, count, eq, isNull, sql } from 'drizzle-orm' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' -import { - columnMatchesRef, - generateColumnId, - getColumnId, - remapGroupColumnRefs, - withGeneratedColumnIds, -} from './column-keys' -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 { type DbExecutor, type DbTransaction, withSeqscanOff } from './planner' -import { buildFilterClause, buildSortClause, escapeLikePattern } from './sql' -import { fireTableTrigger } from './trigger' +import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' +import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { EMPTY_JOB_FIELDS, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service' +import { nKeysBetween } from '@/lib/table/order-key' +import type { DbTransaction } from '@/lib/table/planner' +import { setTableTxTimeouts } from '@/lib/table/tx' import type { - AddWorkflowGroupData, - BatchInsertData, - BatchUpdateByIdData, - BulkDeleteByIdsData, - BulkDeleteByIdsResult, - BulkDeleteData, - BulkOperationResult, - BulkUpdateData, - ColumnDefinition, CreateTableData, - DeleteColumnData, - DeleteWorkflowGroupData, - Filter, - InsertRowData, - QueryOptions, - QueryResult, - RenameColumnData, - ReplaceRowsData, - ReplaceRowsResult, - RowData, - RowExecutionMetadata, - RowExecutions, - Sort, TableDefinition, - TableDeleteJobPayload, - TableExportJobPayload, - TableJobType, TableMetadata, - TableRow, TableSchema, - UpdateColumnConstraintsData, - UpdateColumnTypeData, - UpdateRowData, - UpdateWorkflowGroupData, - UpsertResult, - UpsertRowData, - WorkflowGroup, - WorkflowGroupOutput, -} from './types' -import { - checkBatchUniqueConstraintsDb, - checkUniqueConstraintsDb, - coerceRowToSchema, - coerceRowValues, - getUniqueColumns, - validateRowSize, - validateTableName, - validateTableSchema, -} from './validation' -import { - assertValidSchema, - cancelWorkflowGroupRuns, - runWorkflowColumn, - stripGroupDeps, -} from './workflow-columns' +} from '@/lib/table/types' +import { validateTableName, validateTableSchema } from '@/lib/table/validation' +import { stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableService') @@ -115,27 +41,6 @@ export class TableConflictError extends Error { export type TableScope = 'active' | 'archived' | 'all' -/** - * Sets per-transaction Postgres timeouts via `SET LOCAL`. - * - * `lock_timeout` is the critical one: without it, a waiter inherits the full - * `statement_timeout` clock, so one stuck writer can drain the pool. - * - * Safe under pgBouncer transaction pooling — `SET LOCAL` is transaction-scoped - * and cleared at COMMIT/ROLLBACK before the session returns to the pool. - */ -async function setTableTxTimeouts( - trx: DbTransaction, - opts?: { statementMs?: number; lockMs?: number; idleMs?: number } -) { - const s = opts?.statementMs ?? 10_000 - const l = opts?.lockMs ?? 3_000 - const i = opts?.idleMs ?? 5_000 - await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${s}ms'`)) - await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${l}ms'`)) - await trx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${i}ms'`)) -} - /** * Serializes schema/metadata read-modify-writes for a single table so * concurrent mutators can't clobber each other's `schema` JSONB @@ -151,7 +56,7 @@ async function setTableTxTimeouts( * the read both release at COMMIT/ROLLBACK; the wait is bounded by the * `statement_timeout` set in `setTableTxTimeouts`. */ -async function withLockedTable( +export async function withLockedTable( tableId: string, mutate: (table: TableDefinition, trx: DbTransaction) => Promise, opts?: { includeArchived?: boolean } @@ -169,56 +74,6 @@ async function withLockedTable( }) } -/** - * 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. - */ -export async function nextImportStartPosition(tableId: string): Promise { - const [{ maxPos }] = await db - .select({ - maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), - }) - .from(userTableRows) - .where(eq(userTableRows.tableId, tableId)) - return maxPos + 1 -} - -/** - * Append anchor `order_key` for an import — `max(order_key)`, or null when empty. Read once, - * unlocked, before streaming (the import worker is the table's sole writer); each batch threads - * the previous batch's last key forward so no per-batch max scan is needed. - */ -export async function nextImportStartOrderKey(tableId: string): Promise { - return maxOrderKey(db, tableId) -} - -const TIMEOUT_CAP_MS = 10 * 60_000 - -/** - * Scales `statement_timeout` to the expected row-count work. - * - * Bulk operations that rewrite JSONB or cascade row triggers (e.g. - * `replaceTableRows`, `deleteColumn`, `renameColumn`) scale roughly linearly - * with row count. A fixed cap would regress large-table users who never saw a - * timeout before `SET LOCAL` was introduced. This helper picks - * `max(baseMs, rowCount * perRowMs)`, capped at 10 minutes so a single - * runaway transaction cannot indefinitely pin a pool connection. - */ -function scaledStatementTimeoutMs( - rowCount: number, - opts: { baseMs: number; perRowMs: number } -): number { - const safeRowCount = Math.max(0, rowCount) - return Math.min(TIMEOUT_CAP_MS, Math.max(opts.baseMs, safeRowCount * opts.perRowMs)) -} - -/** - * Gets a table by ID with full details. - * - * @param tableId - Table ID to fetch - * @returns Table definition or null if not found - */ /** * Returns `schema` with `columns` sorted by `metadata.columnOrder` (the user- * editable visible order). Columns missing from `columnOrder` are appended at @@ -250,99 +105,12 @@ function applyColumnOrderToSchema( return { ...schema, columns: ordered } } -/** Job fields projected onto a {@link TableDefinition}, derived from its latest `table_jobs` row. */ -interface DerivedJobFields { - jobStatus: TableDefinition['jobStatus'] - jobId: string | null - jobType: TableDefinition['jobType'] - jobError: string | null - jobRowsProcessed: number - /** - * Rows a running delete job still has to remove (its doomed estimate minus - * deletions so far). Internal to count adjustment — callers subtract it from - * the raw `row_count` so list/detail counts match the read path's delete - * mask (a mid-delete refresh must not resurrect the count). Not on the wire. - */ - pendingDeleteRemaining: number -} - -const EMPTY_JOB_FIELDS: DerivedJobFields = { - jobStatus: null, - jobId: null, - jobType: null, - jobError: null, - jobRowsProcessed: 0, - pendingDeleteRemaining: 0, -} - -function mapJobRow( - row: - | { - id: string - type: string - status: string - rowsProcessed: number - error: string | null - payload: unknown - } - | undefined -): DerivedJobFields { - if (!row) return EMPTY_JOB_FIELDS - const doomedCount = - row.type === 'delete' && row.status === 'running' - ? ((row.payload as TableDeleteJobPayload | null)?.doomedCount ?? 0) - : 0 - return { - jobStatus: row.status as TableDefinition['jobStatus'], - jobId: row.id, - jobType: row.type as TableDefinition['jobType'], - jobError: row.error, - jobRowsProcessed: row.rowsProcessed, - pendingDeleteRemaining: Math.max(0, doomedCount - row.rowsProcessed), - } -} - -const JOB_PROJECTION = { - id: tableJobs.id, - type: tableJobs.type, - status: tableJobs.status, - rowsProcessed: tableJobs.rowsProcessed, - error: tableJobs.error, - payload: tableJobs.payload, -} as const - /** - * The latest job for one table (the running one if present, else the most recent terminal). - * Exports are excluded: they're read-only, run concurrently with other jobs, and have their own - * client surface — surfacing one here would clobber the import/delete/backfill status the tray - * and SSE consumer derive from these fields. + * Gets a table by ID with full details. + * + * @param tableId - Table ID to fetch + * @returns Table definition or null if not found */ -async function latestJobForTable( - tableId: string, - executor: DbOrTx = db -): Promise { - const [row] = await executor - .select(JOB_PROJECTION) - .from(tableJobs) - .where(and(eq(tableJobs.tableId, tableId), ne(tableJobs.type, 'export'))) - .orderBy(desc(tableJobs.startedAt)) - .limit(1) - return mapJobRow(row) -} - -/** Latest non-export job per table for a batch of ids, via `DISTINCT ON (table_id)`. */ -async function latestJobsForTables(tableIds: string[]): Promise> { - const map = new Map() - if (tableIds.length === 0) return map - const rows = await db - .selectDistinctOn([tableJobs.tableId], { tableId: tableJobs.tableId, ...JOB_PROJECTION }) - .from(tableJobs) - .where(and(inArray(tableJobs.tableId, tableIds), ne(tableJobs.type, 'export'))) - .orderBy(tableJobs.tableId, desc(tableJobs.startedAt)) - for (const row of rows) map.set(row.tableId, mapJobRow(row)) - return map -} - export async function getTableById( tableId: string, options?: { includeArchived?: boolean; tx?: DbOrTx } @@ -400,19 +168,6 @@ export async function getTableById( * @param workspaceId - Workspace ID to list tables for * @returns Array of table definitions */ -async function countTables(workspaceId: string): Promise { - const [result] = await db - .select({ count: count() }) - .from(userTableDefinitions) - .where( - and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) - ) - return result.count -} - export async function listTables( workspaceId: string, options?: { scope?: TableScope } @@ -625,118 +380,6 @@ export async function createTable( } } -/** - * Adds a column to an existing table's schema. - * - * @param tableId - Table ID to update - * @param column - Column definition to add - * @param requestId - Request ID for logging - * @returns Updated table definition - * @throws Error if table not found or column name already exists - */ -export async function addTableColumn( - tableId: string, - column: { - id?: string - name: string - type: string - required?: boolean - unique?: boolean - position?: number - }, - requestId: string -): Promise { - return withLockedTable(tableId, async (table, trx) => { - if (!NAME_PATTERN.test(column.name)) { - throw new Error( - `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` - ) - } - - if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( - `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` - ) - } - - if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( - `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` - ) - } - - const schema = table.schema - if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { - throw new Error(`Column "${column.name}" already exists`) - } - - if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` - ) - } - - const newColumn: TableSchema['columns'][number] = { - // Honor a caller-provided id (undo of a delete reuses the original id); - // otherwise mint a fresh one. - id: column.id ?? generateColumnId(), - name: column.name, - type: column.type as TableSchema['columns'][number]['type'], - required: column.required ?? false, - unique: column.unique ?? false, - } - const newColumnId = getColumnId(newColumn) - - const columns = [...schema.columns] - if (column.position !== undefined && column.position >= 0 && column.position < columns.length) { - columns.splice(column.position, 0, newColumn) - } else { - columns.push(newColumn) - } - - const updatedSchema: TableSchema = { ...schema, columns } - - // Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the - // new column's id at the same index we used in `columns` keeps display - // ordering aligned with the user's intent for `position`-based inserts. - const existingOrder = table.metadata?.columnOrder - let updatedMetadata = table.metadata - if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) { - let insertIdx = existingOrder.length - if (column.position !== undefined && column.position >= 0) { - // Anchor on the column previously at `position` — that column shifted - // right by one in `columns`, so the new id slots in at its old spot. - const anchor = schema.columns[column.position] - if (anchor) { - const anchorIdx = existingOrder.indexOf(getColumnId(anchor)) - if (anchorIdx !== -1) insertIdx = anchorIdx - } - } - const nextOrder = [...existingOrder] - nextOrder.splice(insertIdx, 0, newColumnId) - updatedMetadata = { ...table.metadata, columnOrder: nextOrder } - } - - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - - const now = new Date() - - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, tableId)) - - logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) - - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) -} - /** * Adds multiple columns to an existing table inside a caller-provided * transaction. This is atomic with respect to the surrounding `trx`: either @@ -942,80 +585,6 @@ export async function deleteTable(tableId: string, requestId: string): Promise - requestId: string - tx?: DbOrTx -}): Promise { - const executor = tx ?? db - const tables = await executor - .select({ - id: userTableDefinitions.id, - schema: userTableDefinitions.schema, - }) - .from(userTableDefinitions) - .where( - and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) - ) - - for (const t of tables) { - const schema = t.schema as TableSchema - const groups = schema.workflowGroups ?? [] - if (groups.length === 0) continue - - let mutated = false - const nextGroups = groups.map((group) => { - if (group.workflowId !== workflowId) return group - const filtered = group.outputs.filter((o) => validBlockIds.has(o.blockId)) - if (filtered.length === group.outputs.length) return group - if (filtered.length === 0) { - logger.warn( - `[${requestId}] All outputs for workflow group "${group.name ?? group.id}" in table ${t.id} reference deleted blocks; leaving group intact for user reconfiguration.` - ) - return group - } - mutated = true - return { ...group, outputs: filtered } - }) - - if (!mutated) continue - - await executor - .update(userTableDefinitions) - .set({ - schema: { ...schema, workflowGroups: nextGroups }, - updatedAt: new Date(), - }) - .where(eq(userTableDefinitions.id, t.id)) - - logger.info(`[${requestId}] Pruned stale workflow=${workflowId} block refs from table ${t.id}`) - } -} - /** * Restores an archived table. */ @@ -1085,4240 +654,3 @@ 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 GREATER than the anchor key. Comparing keys - // (not the `(order_key, id)` row tuple) skips past any sibling that shares the - // anchor's key, so `keyBetween` always gets strictly-ordered bounds and can't - // throw on a stray duplicate. Identical to the row tuple when keys are distinct. - // A null anchorKey (flag off, un-backfilled) has no key to compare — leave the - // upper bound open, matching the prior best-effort behavior. - let nextKey: string | null = null - if (anchorKey !== null) { - const [next] = await trx - .select({ orderKey: userTableRows.orderKey }) - .from(userTableRows) - .where(and(eq(userTableRows.tableId, tableId), gt(userTableRows.orderKey, anchorKey))) - .orderBy(asc(userTableRows.orderKey)) - .limit(1) - nextKey = next?.orderKey ?? null - } - return { - orderKey: keyBetween(anchorKey, nextKey), - position: anchor.position + 1, - } - } - - // beforeRowId: lo = the largest key strictly LESS than the anchor key (distinct, - // same rationale as the afterRowId branch above). - let prevKey: string | null = null - if (anchorKey !== null) { - const [prev] = await trx - .select({ orderKey: userTableRows.orderKey }) - .from(userTableRows) - .where(and(eq(userTableRows.tableId, tableId), lt(userTableRows.orderKey, anchorKey))) - .orderBy(desc(userTableRows.orderKey)) - .limit(1) - prevKey = prev?.orderKey ?? null - } - return { - orderKey: keyBetween(prevKey, 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 - }) -} - -/** - * Selects one page of row ids to delete for the async delete-job worker: base scope plus a - * `created_at <= cutoff` floor (so rows inserted after the job started are never selected) and - * the caller's optional filter clause. Keyset paginated on `id` via `afterId` so excluded rows - * (which are skipped, not deleted) still advance the cursor — no OFFSET, no risk of looping on a - * fully-excluded page. - */ -export async function selectRowIdPage(params: { - tableId: string - workspaceId: string - cutoff: Date - filterClause?: SQL - afterId?: string - limit: number -}): Promise { - const { tableId, workspaceId, cutoff, filterClause, afterId, limit } = params - const selectPage = (executor: DbExecutor) => - executor - .select({ id: userTableRows.id }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId), - lte(userTableRows.createdAt, cutoff), - afterId ? gt(userTableRows.id, afterId) : undefined, - filterClause - ) - ) - .orderBy(asc(userTableRows.id)) - .limit(limit) - // A jsonb filter is unestimatable, so the planner would seq-scan the whole shared relation - // per page (12.6s measured) — keep it on the tenant's (table_id, id) index. - const rows = filterClause - ? await withSeqscanOff(async (trx) => selectPage(trx)) - : await selectPage(db) - return rows.map((r) => r.id) -} - -/** - * Deletes one page of rows for the async delete-job worker, committing each `DELETE_BATCH_SIZE` - * chunk in its own short transaction. One statement per transaction bounds how long the - * statement-level row_count trigger's lock on the definition row is held (a page-wide transaction - * held it for the entire page, starving concurrent inserts and overrunning `statement_timeout`), - * and a mid-page failure loses at most one uncommitted batch — the keyset walker (or a task - * retry) re-walks whatever remains. Skips legacy position compaction: under fractional ordering - * it's unnecessary, and in the legacy path `position` gaps are harmless — rows still order by - * position. Returns the count deleted. - */ -export async function deletePageByIds( - tableId: string, - workspaceId: string, - rowIds: string[] -): Promise { - let deleted = 0 - 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 db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - return trx - .delete(userTableRows) - .where( - and( - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId), - inArray(userTableRows.id, batch) - ) - ) - .returning({ id: userTableRows.id }) - }) - deleted += rows.length - } - return deleted -} - -/** - * Inserts a single row into a table. - * - * @param data - Row insertion data - * @param table - Table definition (to avoid re-fetching) - * @param requestId - Request ID for logging - * @returns Inserted row - * @throws Error if validation fails or capacity exceeded - */ -export async function insertRow( - data: InsertRowData, - table: TableDefinition, - requestId: string -): Promise { - // Validate row size - const sizeValidation = validateRowSize(data.data) - if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) - } - - // Validate against schema - const schemaValidation = coerceRowToSchema(data.data, table.schema) - if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) - } - - // Check unique constraints using optimized database query - const uniqueColumns = getUniqueColumns(table.schema) - if (uniqueColumns.length > 0) { - const uniqueValidation = await checkUniqueConstraintsDb(data.tableId, data.data, table.schema) - if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) - } - } - - const rowId = `row_${generateId().replace(/-/g, '')}` - const now = new Date() - - // Capacity enforcement lives in the `increment_user_table_row_count` trigger - // (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 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}`) - - const insertedRow: TableRow = { - id: row.id, - data: row.data as RowData, - executions: {}, - position: row.position, - orderKey: row.orderKey ?? undefined, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - } - - void fireTableTrigger( - data.tableId, - table.name, - 'insert', - [insertedRow], - null, - table.schema, - requestId - ) - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: [insertedRow.id], - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.userId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err)) - - return insertedRow -} - -/** - * Inserts multiple rows into a table. - * - * @param data - Batch insertion data - * @param table - Table definition - * @param requestId - Request ID for logging - * @returns Array of inserted rows - * @throws Error if validation fails or capacity exceeded - */ -export async function batchInsertRows( - data: BatchInsertData, - table: TableDefinition, - requestId: string -): Promise { - const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId)) - dispatchAfterBatchInsert(table, result, requestId, data.userId) - return result -} - -/** - * Transaction-bound variant of `batchInsertRows`. Validates rows and unique - * constraints, then performs INSERTs inside the provided transaction. Caller - * is responsible for opening the transaction. Use when row inserts must be - * atomic with other writes (e.g., schema mutations) on the same tx. - * - * Capacity enforcement lives in the `increment_user_table_row_count` trigger - * (migration 0198) — fires per row and raises `Maximum row limit (%) reached ...` - * if the cap is hit mid-batch. - */ -export async function batchInsertRowsWithTx( - trx: DbTransaction, - data: BatchInsertData, - table: TableDefinition, - requestId: string -): Promise { - for (let i = 0; i < data.rows.length; i++) { - const row = data.rows[i] - - const sizeValidation = validateRowSize(row) - if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) - } - - const schemaValidation = coerceRowToSchema(row, 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, - trx - ) - if (!uniqueResult.valid) { - const errorMessages = uniqueResult.errors - .map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`) - .join('; ') - throw new Error(errorMessages) - } - } - - const now = new Date() - - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - - 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 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, - actorUserId?: string | null -): 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, - triggeredByUserId: actorUserId, - }).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, - } -} - -/** 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)) -} - -/** - * 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)) -} - -/** 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)) -} - -/** - * Atomically claims a table's single background-job slot by inserting a `running` row into - * `table_jobs`. The partial-unique index on `table_id WHERE status = 'running'` is the - * concurrency gate: a second insert while a job runs hits `ON CONFLICT DO NOTHING` and returns no - * row, so import and delete (and two imports) are mutually exclusive for free. Returns whether it - * claimed the slot; the caller returns 409 when it didn't. - */ -export async function markTableJobRunning( - tableId: string, - jobId: string, - type: TableJobType, - /** Type-specific scope persisted to `table_jobs.payload` (e.g. {@link TableDeleteJobPayload}) - * so read paths can mask the job's effect while it runs. */ - payload?: unknown -): Promise { - // workspace_id is immutable; the atomic gate is the INSERT's conflict, not this read. - const [def] = await db - .select({ workspaceId: userTableDefinitions.workspaceId }) - .from(userTableDefinitions) - .where(eq(userTableDefinitions.id, tableId)) - .limit(1) - if (!def) return false - const inserted = await db - .insert(tableJobs) - .values({ - id: jobId, - tableId, - workspaceId: def.workspaceId, - type, - status: 'running', - payload: payload ?? null, - }) - .onConflictDoNothing() - .returning({ id: tableJobs.id }) - return inserted.length > 0 -} - -/** - * Releases a claim taken by {@link markTableJobRunning} for a synchronous job — deletes the - * transient claim row. Scoped to `jobId` + still-running so it only clears its own claim, never a - * newer run. A sync route claims, writes, then releases here in a `finally`. - */ -export async function releaseJobClaim(tableId: string, jobId: string): Promise { - await db - .delete(tableJobs) - .where( - and(eq(tableJobs.id, jobId), eq(tableJobs.tableId, tableId), eq(tableJobs.status, 'running')) - ) -} - -/** - * Records job progress (rows processed so far) and bumps `updated_at` so the stale-job janitor - * (`cleanup-stale-executions`) sees a live heartbeat. - * - * Scoped to `jobId` AND `status = 'running'`: a stale/superseded worker no longer matches (its - * write is a no-op), and once the job is terminal (e.g. canceled) the match fails too — so this - * returning `false` is the worker's signal to stop. Returns whether this worker still owns an - * in-flight job. - */ -export async function updateJobProgress( - tableId: string, - rowsProcessed: number, - jobId: string -): Promise { - const updated = await db - .update(tableJobs) - .set({ rowsProcessed, updatedAt: new Date() }) - .where(ownsActiveJob(tableId, jobId)) - .returning({ id: tableJobs.id }) - return updated.length > 0 -} - -/** - * Reads the persisted progress of an in-flight job this worker still owns (`null` when the job - * was canceled/superseded). A retried run seeds its counter from this so progress stays - * cumulative — earlier attempts' batches are already committed, and restarting from zero would - * clobber `rows_processed` (and every count derived from it) with the retry's smaller number. - */ -export async function getJobProgress(tableId: string, jobId: string): Promise { - const [job] = await db - .select({ rowsProcessed: tableJobs.rowsProcessed }) - .from(tableJobs) - .where(ownsActiveJob(tableId, jobId)) - .limit(1) - return job ? job.rowsProcessed : null -} - -/** - * One keyset page of rows for the export worker, ordered by `(position, id)`. Keyset (not - * OFFSET) keeps each page O(page) — offset paging re-scans every prior row per page, which is - * O(N²) across a large export. `(position, id)` is total (position exists on every row; id breaks - * ties) and served by the `(table_id, position)` index; under fractional ordering a manually - * reordered table may export in near-grid rather than exact grid order — the right trade for a - * bulk dump. The delete-job visibility mask applies, like every user-facing read. - */ -export async function selectExportRowPage( - table: TableDefinition, - after: { position: number; id: string } | null, - limit: number -): Promise> { - const deleteMask = await pendingDeleteMask(table) - const rows = await db - .select({ id: userTableRows.id, data: userTableRows.data, position: userTableRows.position }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId), - deleteMask, - after - ? sql`(${userTableRows.position}, ${userTableRows.id}) > (${after.position}, ${after.id})` - : undefined - ) - ) - .orderBy(asc(userTableRows.position), asc(userTableRows.id)) - .limit(limit) - return rows as Array<{ id: string; data: RowData; position: number }> -} - -/** How long a terminal export stays listable (and re-downloadable from the tray). */ -const EXPORT_JOB_VISIBILITY_MS = 10 * 60 * 1000 - -export interface WorkspaceExportJob { - jobId: string - tableId: string - tableName: string - status: string - rowsProcessed: number - format: 'csv' | 'json' - hasResult: boolean - error: string | null -} - -/** - * Export jobs the tray surfaces for a workspace: everything running, plus terminals from the last - * {@link EXPORT_JOB_VISIBILITY_MS} so a just-finished export stays re-downloadable. Exports live - * outside the table-level job derivation (which excludes them), so this is their read path. - */ -export async function listWorkspaceExportJobs(workspaceId: string): Promise { - const visibilityCutoff = new Date(Date.now() - EXPORT_JOB_VISIBILITY_MS) - const rows = await db - .select({ - jobId: tableJobs.id, - tableId: tableJobs.tableId, - tableName: userTableDefinitions.name, - status: tableJobs.status, - rowsProcessed: tableJobs.rowsProcessed, - payload: tableJobs.payload, - error: tableJobs.error, - }) - .from(tableJobs) - .innerJoin(userTableDefinitions, eq(userTableDefinitions.id, tableJobs.tableId)) - .where( - and( - eq(tableJobs.workspaceId, workspaceId), - eq(tableJobs.type, 'export'), - or(eq(tableJobs.status, 'running'), gt(tableJobs.updatedAt, visibilityCutoff)) - ) - ) - .orderBy(desc(tableJobs.startedAt)) - return rows.map((r) => { - const payload = r.payload as TableExportJobPayload | null - return { - jobId: r.jobId, - tableId: r.tableId, - tableName: r.tableName, - status: r.status, - rowsProcessed: r.rowsProcessed, - format: payload?.format ?? 'csv', - hasResult: Boolean(payload?.resultKey), - error: r.error, - } - }) -} - -/** Reads one job row (type/status/payload) scoped to its table. Null when absent. */ -export async function getTableJob( - tableId: string, - jobId: string -): Promise<{ id: string; type: string; status: string; payload: unknown } | null> { - const [job] = await db - .select({ - id: tableJobs.id, - type: tableJobs.type, - status: tableJobs.status, - payload: tableJobs.payload, - }) - .from(tableJobs) - .where(and(eq(tableJobs.id, jobId), eq(tableJobs.tableId, tableId))) - .limit(1) - return job ?? null -} - -/** - * Stamps an export job's generated-file storage key onto its payload (`{ resultKey }` merge). - * Scoped to the still-running job so a superseded attempt can't clobber a newer run's result. - * The download route reads it; the janitor deletes the file when the terminal job is pruned. - */ -export async function setJobResultKey( - tableId: string, - jobId: string, - resultKey: string -): Promise { - await db - .update(tableJobs) - .set({ - payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || jsonb_build_object('resultKey', ${resultKey}::text)`, - updatedAt: new Date(), - }) - .where(ownsActiveJob(tableId, jobId)) -} - -/** Shared WHERE for terminal transitions: this job run, and still in-flight (write-once). */ -function ownsActiveJob(tableId: string, jobId: string) { - return and( - eq(tableJobs.id, jobId), - eq(tableJobs.tableId, tableId), - eq(tableJobs.status, 'running') - ) -} - -/** - * Marks a job complete. 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 async function markJobReady(tableId: string, jobId: string): Promise { - const now = new Date() - const updated = await db - .update(tableJobs) - .set({ status: 'ready', error: null, completedAt: now, updatedAt: now }) - .where(ownsActiveJob(tableId, jobId)) - .returning({ id: tableJobs.id }) - return updated.length > 0 -} - -/** - * Marks a job failed, leaving any already-committed work in place. No-op unless it's still this - * in-flight run (so a stale worker can't clobber a newer job or a cancel). - */ -export async function markJobFailed(tableId: string, jobId: string, error: string): Promise { - const now = new Date() - await db - .update(tableJobs) - .set({ status: 'failed', error: error.slice(0, 2000), completedAt: now, updatedAt: now }) - .where(ownsActiveJob(tableId, jobId)) -} - -/** - * Marks an in-flight job canceled (user-initiated). No-op unless it's still running. The - * worker's next ownership check then returns `false` and it stops; committed work is left in - * place (no rollback). Returns whether a running job was actually canceled. - */ -export async function markJobCanceled(tableId: string, jobId: string): Promise { - const now = new Date() - const updated = await db - .update(tableJobs) - .set({ status: 'canceled', completedAt: now, updatedAt: now }) - .where(ownsActiveJob(tableId, jobId)) - .returning({ id: tableJobs.id }) - return updated.length > 0 -} - -/** - * Replaces all rows in a table with a new set of rows. Deletes existing rows - * and inserts the provided rows inside a single transaction so the table is - * never observed in an empty intermediate state by other readers. - * - * Validates each row against the schema, enforces unique constraints within the - * new rows (existing rows are deleted, so DB-side checks are unnecessary), and - * enforces `maxRows` before the replace executes. - * - * @param data - Replace data (rows to install) - * @param table - Table definition - * @param requestId - Request ID for logging - * @returns Count of rows deleted and inserted - * @throws Error if validation fails or capacity exceeded - */ -export async function replaceTableRows( - data: ReplaceRowsData, - table: TableDefinition, - requestId: string -): Promise { - return db.transaction((trx) => replaceTableRowsWithTx(trx, data, table, requestId)) -} - -/** - * Transaction-bound variant of `replaceTableRows`. Caller opens the transaction. - * Use when the replace must be atomic with other writes (e.g., schema mutations). - */ -export async function replaceTableRowsWithTx( - trx: DbTransaction, - data: ReplaceRowsData, - table: TableDefinition, - requestId: string -): Promise { - if (data.tableId !== table.id) { - throw new Error(`Table ID mismatch: ${data.tableId} vs ${table.id}`) - } - if (data.workspaceId !== table.workspaceId) { - throw new Error(`Workspace ID mismatch: ${data.workspaceId} does not own table ${data.tableId}`) - } - if (data.rows.length > table.maxRows) { - throw new Error( - `Cannot replace: ${data.rows.length} rows exceeds table row limit (${table.maxRows})` - ) - } - - for (let i = 0; i < data.rows.length; i++) { - const row = data.rows[i] - - const sizeValidation = validateRowSize(row) - if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) - } - - const schemaValidation = coerceRowToSchema(row, table.schema) - if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) - } - } - - const uniqueColumns = getUniqueColumns(table.schema) - if (uniqueColumns.length > 0 && data.rows.length > 0) { - const seen = new Map>() - for (const col of uniqueColumns) { - seen.set(col.name, new Map()) - } - for (let i = 0; i < data.rows.length; i++) { - const row = data.rows[i] - for (const col of uniqueColumns) { - const value = row[col.name] - if (value === null || value === undefined) continue - const normalized = typeof value === 'string' ? value.toLowerCase() : JSON.stringify(value) - const map = seen.get(col.name)! - if (map.has(normalized)) { - throw new Error( - `Row ${i + 1}: Column "${col.name}" must be unique. Value "${String(value)}" duplicates row ${map.get(normalized)! + 1} in batch` - ) - } - map.set(normalized, i) - } - } - } - - const now = new Date() - - const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length - const statementMs = scaledStatementTimeoutMs(totalRowWork, { - baseMs: 120_000, - perRowMs: 3, - }) - - await setTableTxTimeouts(trx, { statementMs }) - - // Serialize concurrent replaces (and concurrent auto-position inserts) on the - // same table. Without this, two concurrent replaces each see their own MVCC - // 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 acquireRowOrderLock(trx, data.tableId) - - const deletedRows = await trx - .delete(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - .returning({ id: userTableRows.id }) - - 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 } : {}), - })) - - const batchSize = TABLE_LIMITS.MAX_BATCH_INSERT_SIZE - for (let i = 0; i < rowsToInsert.length; i += batchSize) { - const chunk = rowsToInsert.slice(i, i + batchSize) - const inserted = await trx.insert(userTableRows).values(chunk).returning({ - id: userTableRows.id, - }) - insertedCount += inserted.length - } - } - - logger.info( - `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedRows.length}, inserted ${insertedCount}` - ) - - 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: { id?: string; 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) { - // Take the row-order lock before creating columns so this path uses the - // same rows_pos → user_table_definitions order as plain inserts. Creating - // columns first would lock the definition row before rows_pos, inverting - // the order and deadlocking concurrent inserts on this table. The lock is - // re-entrant, so the per-batch acquire below is a no-op. - await acquireRowOrderLock(trx, table.id) - 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: { id?: string; 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) { - await acquireRowOrderLock(trx, table.id) - 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. - * - * Uses a single unique column for matching (not OR across all unique columns) to avoid - * ambiguous matches when multiple unique columns exist. Capacity enforcement lives - * in the `increment_user_table_row_count` trigger (migration 0198). On the insert - * path we acquire the per-table advisory lock and re-check for an existing match - * before inserting, so a concurrent upsert racing on the same conflict target - * cannot produce a duplicate row. - * - * @param data - Upsert data including optional conflictTarget - * @param table - Table definition - * @param requestId - Request ID for logging - * @returns The upserted row and whether it was an insert or update - * @throws Error if no unique columns, ambiguous conflict target, or capacity exceeded - */ -export async function upsertRow( - data: UpsertRowData, - table: TableDefinition, - requestId: string -): Promise { - const schema = table.schema - const uniqueColumns = getUniqueColumns(schema) - - if (uniqueColumns.length === 0) { - throw new Error( - 'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.' - ) - } - - // Determine the single conflict target column, resolving to its stable - // storage id (the row-data key). `conflictTarget` may arrive as an id - // (first-party) or a name (legacy/internal) — match either. - let targetColumnKey: string - if (data.conflictTarget) { - const col = uniqueColumns.find( - (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget - ) - if (!col) { - throw new Error( - `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` - ) - } - targetColumnKey = getColumnId(col) - } else if (uniqueColumns.length === 1) { - targetColumnKey = getColumnId(uniqueColumns[0]) - } else { - throw new Error( - `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify conflictTarget to indicate which column to match on.` - ) - } - - // Validate row data - const sizeValidation = validateRowSize(data.data) - if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) - } - - const schemaValidation = coerceRowToSchema(data.data, schema) - if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) - } - - // Read the conflict-target value *after* coercion so `matchFilter` branches on - // the persisted type (e.g. a coerced `"123"` → `123` matches existing rows). - const targetValue = data.data[targetColumnKey] - if (targetValue === undefined || targetValue === null) { - // Surface the display name, not the internal id — v1 callers pass a name. - const targetColumnName = - uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey - throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) - } - - // `data->` and `data->>` accept the JSON key as a parameterized text value; - // no need for `sql.raw` interpolation. - const matchFilter = - typeof targetValue === 'string' - ? sql`${userTableRows.data}->>${targetColumnKey}::text = ${String(targetValue)}` - : sql`(${userTableRows.data}->${targetColumnKey}::text)::jsonb = ${JSON.stringify(targetValue)}::jsonb` - - // Capacity enforcement for the insert path lives in the `increment_user_table_row_count` - // trigger (migration 0198). The update path doesn't change row_count, so no check needed. - const result = await db.transaction(async (trx) => { - await setTableTxTimeouts(trx) - // The conflict lookups below match on `data->>key` — unestimatable, and an - // insert-path upsert (no existing match) can't exit early, so the planner - // would seq-scan the whole shared relation. See withSeqscanOff. - await trx.execute(sql`SET LOCAL enable_seqscan = off`) - - // Find existing row by single conflict target column - const [existingRow] = await trx - .select() - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId), - matchFilter - ) - ) - .limit(1) - - // Check uniqueness on ALL unique columns (not just the conflict target) - const uniqueValidation = await checkUniqueConstraintsDb( - data.tableId, - data.data, - schema, - existingRow?.id, // exclude the matched row on updates - trx - ) - if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) - } - - const now = new Date() - - // Resolve which row (if any) we should update. If the initial SELECT missed, - // acquire the lock and re-check — a concurrent upsert may have inserted the - // matching row between our SELECT and the INSERT path; without the re-check - // both transactions would insert and bypass the app-level unique check. - let matchedRowId = existingRow?.id - let previousData = existingRow?.data as RowData | undefined - if (!matchedRowId) { - await acquireRowOrderLock(trx, data.tableId) - const [racedRow] = await trx - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId), - matchFilter - ) - ) - .limit(1) - if (racedRow) { - matchedRowId = racedRow.id - previousData = racedRow.data as RowData - } - } - - if (matchedRowId) { - const [updatedRow] = await trx - .update(userTableRows) - .set({ data: data.data, updatedAt: now }) - .where(eq(userTableRows.id, matchedRowId)) - .returning() - - const executions = await loadExecutionsForRow(trx, updatedRow.id) - return { - row: { - id: updatedRow.id, - data: updatedRow.data as RowData, - executions, - position: updatedRow.position, - orderKey: updatedRow.orderKey ?? undefined, - createdAt: updatedRow.createdAt, - updatedAt: updatedRow.updatedAt, - }, - previousData, - operation: 'update' as const, - } - } - - const [insertedRow] = await trx - .insert(userTableRows) - .values({ - id: `row_${generateId().replace(/-/g, '')}`, - tableId: data.tableId, - workspaceId: data.workspaceId, - data: data.data, - position: await reserveInsertPosition(trx, data.tableId), - orderKey: await resolveInsertOrderKey(trx, data.tableId), - createdAt: now, - updatedAt: now, - ...(data.userId ? { createdBy: data.userId } : {}), - }) - .returning() - - return { - row: { - id: insertedRow.id, - data: insertedRow.data as RowData, - executions: {}, - position: insertedRow.position, - orderKey: insertedRow.orderKey ?? undefined, - createdAt: insertedRow.createdAt, - updatedAt: insertedRow.updatedAt, - }, - operation: 'insert' as const, - } - }) - - logger.info( - `[${requestId}] Upserted (${result.operation}) row ${result.row.id} in table ${data.tableId}` - ) - - if (result.operation === 'insert') { - void fireTableTrigger( - data.tableId, - table.name, - 'insert', - [result.row], - null, - table.schema, - requestId - ) - } else if (result.operation === 'update' && result.previousData) { - const oldRows = new Map([[result.row.id, result.previousData]]) - void fireTableTrigger( - data.tableId, - table.name, - 'update', - [result.row], - oldRows, - table.schema, - requestId - ) - } - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: [result.row.id], - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.userId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err)) - - return result -} - -/** - * Canonical ORDER BY for a table's rows, shared by `queryRows` (the paginated - * list) and `findRowMatches` so a match's ordinal lines up with its index in - * the list. Order: explicit data sort (if any) → fractional `order_key` or - * legacy `position` → `id`. The `id` tiebreak is always appended so equal - * positions order deterministically — without it two separate query executions - * (a find vs a list page) could shuffle ties and misalign ordinals. - */ -function buildRowOrderBySql( - sort: Sort | undefined, - tableName: string, - columns: ColumnDefinition[] -): SQL { - const primary = isTablesFractionalOrderingEnabled - ? `${tableName}.order_key` - : `${tableName}.position` - const id = `${tableName}.id` - if (sort && Object.keys(sort).length > 0) { - const sortClause = buildSortClause(sort, tableName, columns) - if (sortClause) { - return sql.join([sortClause, sql.raw(primary), sql.raw(id)], sql.raw(', ')) - } - } - return sql.raw(`${primary}, ${id}`) -} - -/** One matching cell from {@link findRowMatches}. */ -export interface FindRowMatch { - /** 0-based index of the row in the filtered+sorted view (aligns with the list query). */ - ordinal: number - rowId: string - /** Stable column id of the matching cell (the JSONB storage key), not the display name. */ - column: string -} - -/** Max matching cells returned by {@link findRowMatches}; one extra is fetched to detect truncation. */ -const FIND_MATCH_LIMIT = 1000 - -/** - * Case-insensitive substring search across every cell of a table's rows. Each - * matching cell becomes a {@link FindRowMatch} carrying its row id, column, and - * 0-based ordinal in the filtered+sorted view (so the client can page up to and - * reveal it). `filter`/`sort` mirror the active list view via - * {@link buildRowOrderBySql}, keeping ordinals aligned. - * - * Cost: one pass over the table's rows — `ILIKE` over `jsonb_each_text` cannot - * use the JSONB GIN index, and the ordinal's `row_number()` needs every row - * counted regardless. The planner can't estimate the lateral ILIKE (jsonb is - * opaque to it), so left alone it seq-scans the entire shared relation and - * disk-sorts the window input (measured 75s on a 1M-row table in a 12M-row - * relation). `SET LOCAL` planner flags keep it tenant-bounded; on the default - * order they additionally force the streaming `(table_id, order_key, id)` index - * walk where `row_number()` needs no sort at all (measured 2s). A `pg_trgm` GIN - * index on a text projection is the future accelerator if needed. - */ -export async function findRowMatches( - table: TableDefinition, - options: { q: string; filter?: Filter; sort?: Sort }, - requestId: string -): Promise<{ matches: FindRowMatch[]; truncated: boolean }> { - const tableName = USER_TABLE_ROWS_SQL_NAME - const columns = table.schema.columns - // Row data is keyed by stable column id, so scan/return JSONB keys as ids. - const columnIds = columns.map(getColumnId) - if (columnIds.length === 0) return { matches: [], truncated: false } - - // Same visibility rule as queryRows: don't surface rows a running delete job will remove. - const deleteMask = await pendingDeleteMask(table) - - const baseConditions = and( - eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId), - deleteMask - ) - let whereClause: SQL | undefined = baseConditions - if (options.filter && Object.keys(options.filter).length > 0) { - const filterClause = buildFilterClause(options.filter, tableName, columns) - if (filterClause) whereClause = and(baseConditions, filterClause) - } - - const orderBySql = buildRowOrderBySql(options.sort, tableName, columns) - const pattern = `%${escapeLikePattern(options.q)}%` - - const result = await db.transaction(async (trx) => { - // Planner flags, not correctness: `enable_* = off` only penalizes a plan shape, so a - // genuinely required sort still runs. Seqscan off keeps the scan inside the tenant's rows - // (the lateral ILIKE is unestimatable, so the planner otherwise walks the whole shared - // relation). On the default order, the remaining flags steer to the already-sorted - // `(table_id, order_key, id)` index walk so the window function streams without a 100MB+ - // disk sort; a custom sort has no index to stream from, so those flags would only distort - // that plan. - await trx.execute(sql`SET LOCAL enable_seqscan = off`) - if (!options.sort) { - await trx.execute(sql`SET LOCAL enable_bitmapscan = off`) - await trx.execute(sql`SET LOCAL enable_sort = off`) - await trx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`) - } - return trx.execute<{ - ordinal: string | number - id: string - column_name: string - }>(sql` - WITH ordered AS ( - SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal - FROM ${userTableRows} - WHERE ${whereClause} - ) - SELECT o.ordinal, o.id, kv.key AS column_name - FROM ordered o - CROSS JOIN LATERAL jsonb_each_text(o.data) kv - WHERE kv.value ILIKE ${pattern} - AND ${inArray(sql`kv.key`, columnIds)} - ORDER BY o.ordinal - LIMIT ${FIND_MATCH_LIMIT + 1} - `) - }) - - const all = Array.from(result) - const truncated = all.length > FIND_MATCH_LIMIT - const sliced = truncated ? all.slice(0, FIND_MATCH_LIMIT) : all - const matches: FindRowMatch[] = sliced.map((r) => ({ - ordinal: Number(r.ordinal), - rowId: r.id, - column: r.column_name, - })) - - logger.info( - `[${requestId}] Find "${options.q}" in table ${table.id}: ${matches.length} match(es)${truncated ? ' (truncated)' : ''}` - ) - - return { matches, truncated } -} - -/** - * Queries rows from a table with filtering, sorting, and pagination. - * - * Filter cost model: equality filters (`$eq`, `$in`) compile to JSONB - * containment (`@>`) and hit the GIN (jsonb_path_ops) index on - * `user_table_rows.data`. Range operators (`$gt`, `$gte`, `$lt`, `$lte`) and - * `$contains` compile to `data->>'field'` text extraction and bypass the GIN - * index — they fall back to a sequential scan of the rows for the table - * (bounded only by the btree on `table_id`). Prefer equality on hot paths; set - * `includeTotal: false` when the caller does not need the `COUNT(*)`. - * - * @param table - Table definition (provides id, workspaceId, and column schema for type-aware filter/sort casts) - * @param options - Query options (filter, sort, limit, offset) - * @param requestId - Request ID for logging - * @returns Query result with rows and pagination info - */ -/** - * Visibility mask for a running delete job: returns a clause keeping only rows the job will NOT - * delete, or `undefined` when no delete job is running. The job's persisted scope - * ({@link TableDeleteJobPayload}) defines the doomed set — `matches(filter) AND created_at <= - * cutoff AND id NOT IN excludeRowIds` — exactly what the worker's `selectRowIdPage` selects, so - * mid-job reads (refresh, other clients, exports) are consistent with the eventual result. The - * mask lifts automatically when the job leaves `running` (done, failed, or canceled). - * - * `(doomed) IS NOT TRUE` rather than `NOT (doomed)`: JSONB predicates evaluate to NULL on missing - * cells, and those rows are NOT selected for deletion (NULL ≠ TRUE) — they must stay visible. - */ -async function pendingDeleteMask(table: TableDefinition): Promise { - const [job] = await db - .select({ payload: tableJobs.payload }) - .from(tableJobs) - .where( - and( - eq(tableJobs.tableId, table.id), - eq(tableJobs.status, 'running'), - eq(tableJobs.type, 'delete') - ) - ) - .limit(1) - if (!job?.payload) return undefined - const scope = job.payload as TableDeleteJobPayload - - const doomedParts: SQL[] = [] - if (scope.filter && Object.keys(scope.filter).length > 0) { - try { - const clause = buildFilterClause(scope.filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) - if (clause) doomedParts.push(clause) - } catch (error) { - // Schema drifted mid-job (column renamed/deleted). Showing doomed rows briefly beats - // failing every read; the worker resolves the same way on its next page. - logger.warn(`Skipping delete-job mask for table ${table.id}: stale filter`, { - error: toError(error).message, - }) - return undefined - } - } - if (scope.cutoff) doomedParts.push(lte(userTableRows.createdAt, new Date(scope.cutoff))) - if (scope.excludeRowIds && scope.excludeRowIds.length > 0) { - doomedParts.push(notInArray(userTableRows.id, scope.excludeRowIds)) - } - if (doomedParts.length === 0) return undefined - return sql`(${and(...doomedParts)}) IS NOT TRUE` -} - -/** - * `COUNT(*)` for a filtered view, kept inside the tenant's rows: measured - * 12.7s → 1.0s counting a rare ILIKE filter on a 1M-row table inside a 12M-row - * relation (see {@link withSeqscanOff} for why the planner gets this wrong). - */ -async function countRowsTenantBounded(whereClause: SQL | undefined): Promise { - return withSeqscanOff(async (trx) => { - const [result] = await trx.select({ count: count() }).from(userTableRows).where(whereClause) - return Number(result.count) - }) -} - -export async function queryRows( - table: TableDefinition, - options: QueryOptions, - requestId: string -): Promise { - const { - filter, - sort, - limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, - offset = 0, - after, - includeTotal = true, - withExecutions = true, - } = options - - const tableName = USER_TABLE_ROWS_SQL_NAME - const columns = table.schema.columns - - // Hide rows a running delete job is about to remove — both the page and the count below share - // this clause, so totals stay consistent with the visible rows. - const deleteMask = await pendingDeleteMask(table) - - const baseConditions = and( - eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId), - deleteMask - ) - - let whereClause = baseConditions - if (filter && Object.keys(filter).length > 0) { - const filterClause = buildFilterClause(filter, tableName, columns) - if (filterClause) { - whereClause = and(baseConditions, filterClause) - } - } - - // Keyset page: seek past the cursor on the default `(order_key, id)` order instead of paying - // OFFSET's scan-and-discard of every prior row (O(N²) across a deep scroll / full drain). Only - // valid without a custom sort — the contract rejects `after` + `sort` together. The count below - // deliberately excludes the cursor: totals cover the whole view, not the remaining pages. - const pageWhere = - after && !sort - ? and( - whereClause, - sql`(${userTableRows.orderKey}, ${userTableRows.id}) > (${after.orderKey}, ${after.id})` - ) - : whereClause - - const buildPageQuery = (executor: DbExecutor) => { - const query = executor - .select() - .from(userTableRows) - .where(pageWhere ?? baseConditions) - .orderBy(buildRowOrderBySql(sort, tableName, columns)) - return after ? query.limit(limit) : 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. Filtered counts - // go through the tenant-bounded variant (see countRowsTenantBounded); the - // unfiltered count already plans an index-only scan on the table_id prefix. - // Custom column sorts order by `data->>'col'` — unestimatable, so left alone - // the planner seq-scans and sorts the whole shared relation on every page - // (9.7s measured on a 1M-row table; 0.76s tenant-bounded). Default-order - // pages already stream the `(table_id, order_key, id)` index. - const hasFilter = Boolean(filter && Object.keys(filter).length > 0) - const rowsPromise = sort ? withSeqscanOff(async (trx) => buildPageQuery(trx)) : buildPageQuery(db) - const countPromise = includeTotal - ? hasFilter - ? countRowsTenantBounded(whereClause) - : db - .select({ count: count() }) - .from(userTableRows) - .where(whereClause ?? baseConditions) - .then((r) => Number(r[0].count)) - : null - - const [rows, totalCount] = await Promise.all([rowsPromise, countPromise]) - - 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})` - ) - - return { - rows: rows.map((r) => ({ - id: r.id, - data: r.data as RowData, - executions: executionsByRow?.get(r.id) ?? {}, - position: r.position, - orderKey: r.orderKey ?? undefined, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })), - rowCount: rows.length, - totalCount, - limit, - offset, - } -} - -/** - * Gets a single row by ID. - * - * @param tableId - Table ID - * @param rowId - Row ID to fetch - * @param workspaceId - Workspace ID for access control - * @returns Row or null if not found - */ -export async function getRowById( - tableId: string, - rowId: string, - workspaceId: string -): Promise { - const results = await db - .select() - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .limit(1) - - if (results.length === 0) return null - - const row = results[0] - const executions = await loadExecutionsForRow(db, row.id) - return { - id: row.id, - data: row.data as RowData, - executions, - position: row.position, - orderKey: row.orderKey ?? undefined, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - } -} - -/** - * Derive automatic clears + cancellation candidates from a row's data patch. - * - * Walks `schema.workflowGroups` left-to-right with a propagating `dirtied` - * column set. For each group whose deps overlap the dirty set, decide to - * clear (terminal exec) or cancel+rerun (in-flight exec), then add the - * group's outputs to the dirty set so later groups in the chain see them - * as dirty too. This models transitive dep chains as a single forward pass — - * editing column A propagates through group 1 (deps on A) to group 2 (deps - * on group 1's output) without explicit DAG traversal. - * - * Returns: - * - `executionsPatch`: caller's patch + nulls for cleared groups (or - * undefined if nothing applied). - * - `inFlightDownstreamGroups`: groups whose dep was dirtied and that are - * currently in-flight. Cancel-and-restart is the caller's job. - * - * Assumption: `workflowGroups[]` is in topological order — a group's deps - * may only reference columns to its left (enforced by `workflow-sidebar`'s - * "Run after" picker + the reorder scrub via `stripGroupDeps`). Violating - * this would silently miss the propagation. - */ -function deriveExecClearsForDataPatch( - dataPatch: RowData, - schema: TableSchema, - existingExecutions: RowExecutions, - callerPatch: Record | undefined, - mergedData: RowData -): { - executionsPatch: Record | undefined - inFlightDownstreamGroups: string[] -} { - const dirtied = new Set(Object.keys(dataPatch)) - const groupsToClear = new Set() - const inFlightDownstreamGroups: string[] = [] - - // Own-output clears: when the user wipes a workflow output column, drop - // that group's exec entry so the auto-fire reactor re-arms the cell. - // Also flags the cleared output column as dirty so transitive downstream - // groups see it. - for (const [columnId, value] of Object.entries(dataPatch)) { - const cleared = value === null || value === undefined || value === '' - if (!cleared) continue - const col = schema.columns.find((c) => getColumnId(c) === columnId) - if (col?.workflowGroupId) groupsToClear.add(col.workflowGroupId) - } - - // Left-to-right walk, propagating dirty columns forward. - const groups = schema.workflowGroups ?? [] - const afterRow = { data: mergedData } as TableRow - for (const group of groups) { - const deps = group.dependencies?.columns ?? [] - const depMatched = deps.some((d) => dirtied.has(d)) - if (!depMatched) continue - - // A dep column changed, but if the group's deps are no longer satisfied - // after the patch — a checkbox was unchecked or a text dep cleared — there's - // nothing to recompute. Leave the prior result alone instead of re-arming or - // cancelling it; only checking a box / filling a dep drives downstream work. - if (!areGroupDepsSatisfied(group, afterRow)) continue - - const exec = existingExecutions[group.id] - if (exec) { - const status = exec.status - if (status === 'completed' || status === 'error' || status === 'cancelled') { - groupsToClear.add(group.id) - } else if (status === 'queued' || status === 'running' || status === 'pending') { - inFlightDownstreamGroups.push(group.id) - } - } else { - // No exec entry yet — `mode: 'new'` already covers this group. We - // still propagate the dirty signal forward so later groups in the - // chain see this group's outputs as dirty too. - groupsToClear.add(group.id) - } - - // Propagate: this group is about to be re-computed, so groups whose - // deps reference its output columns are also dirty. - for (const out of group.outputs) dirtied.add(out.columnName) - } - - if (groupsToClear.size === 0) { - return { executionsPatch: callerPatch, inFlightDownstreamGroups } - } - const merged: Record = { ...(callerPatch ?? {}) } - for (const gid of groupsToClear) { - if (!(gid in merged)) merged[gid] = null - } - return { executionsPatch: merged, inFlightDownstreamGroups } -} - -/** Internal: thrown inside `db.transaction` to roll back when the executions - * guard rejects a write. The outer `.catch` translates it into a `null` return. */ -class GuardRejected extends Error { - constructor() { - super('cell-write guard rejected') - } -} - -/** Merges an `executionsPatch` into the row's existing executions blob. */ -function applyExecutionsPatch( - existing: RowExecutions, - patch: Record | undefined -): RowExecutions { - if (!patch) return existing - const next: RowExecutions = { ...existing } - for (const [gid, value] of Object.entries(patch)) { - if (value === null) { - delete next[gid] - } else { - next[gid] = value - } - } - return next -} - -/** - * 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 - * `guard` is set, the upsert is gated to: - * - reject if a `cancelled` row for the same execution already exists, and - * - reject if the row exists but is owned by a different executionId - * (with carve-outs for missing rows and null executionIds — the dispatcher's - * pre-batch `pending` stamp leaves executionId unset so the first cell-task - * can claim). - * - * Returns `'guard-rejected'` when the guarded group's upsert affected 0 rows - * (callers signal failure to the cell-task path). Returns `'wrote'` otherwise. - */ -async function writeExecutionsPatch( - trx: DbOrTx, - tableId: string, - rowId: string, - patch: Record | undefined, - guard?: { groupId: string; executionId: string } -): Promise<'wrote' | 'guard-rejected'> { - if (!patch) return 'wrote' - const entries = Object.entries(patch) - if (entries.length === 0) return 'wrote' - - for (const [gid, value] of entries) { - if (value === null) { - await trx - .delete(tableRowExecutions) - .where(and(eq(tableRowExecutions.rowId, rowId), eq(tableRowExecutions.groupId, gid)) as SQL) - continue - } - const insertValues = { - tableId, - rowId, - groupId: gid, - status: value.status, - executionId: value.executionId, - jobId: value.jobId, - workflowId: value.workflowId, - error: value.error, - runningBlockIds: value.runningBlockIds ?? [], - blockErrors: value.blockErrors ?? {}, - cancelledAt: value.cancelledAt ? new Date(value.cancelledAt) : null, - updatedAt: new Date(), - } as const - - const isGuarded = guard && guard.groupId === gid - if (isGuarded) { - // Gate by guard semantics. The original JSONB guard had two AND'd - // clauses; we collapse them onto the upsert's WHERE so a non-matching - // existing row leaves the table untouched and we observe 0 affected. - const guardExecutionId = guard.executionId - const updated = await trx - .insert(tableRowExecutions) - .values(insertValues) - .onConflictDoUpdate({ - target: [tableRowExecutions.rowId, tableRowExecutions.groupId], - set: { - status: insertValues.status, - executionId: insertValues.executionId, - jobId: insertValues.jobId, - workflowId: insertValues.workflowId, - error: insertValues.error, - runningBlockIds: insertValues.runningBlockIds, - blockErrors: insertValues.blockErrors, - cancelledAt: insertValues.cancelledAt, - updatedAt: insertValues.updatedAt, - }, - where: and( - // Reject any guarded worker write when the cell is `cancelled` — a - // stop click wrote it authoritatively. SQL mirror of `isExecCancelled` - // (deps.ts). Status-only (not executionId-scoped): the cancel can - // only carry the pre-stamp's executionId (often null), so matching on - // id would let the worker's real-id claim resurrect a killed cell. - sql`${tableRowExecutions.status} <> 'cancelled'`, - // Stale-worker: the cell's active run has moved on. Carve-outs - // permit a fresh worker to take over when the row's executionId - // is unset (dispatcher's pre-batch `pending` stamp). - sql`(${tableRowExecutions.executionId} IS NULL OR ${tableRowExecutions.executionId} = ${guardExecutionId})` - ) as SQL, - }) - .returning({ rowId: tableRowExecutions.rowId }) - if (updated.length === 0) return 'guard-rejected' - continue - } - - await trx - .insert(tableRowExecutions) - .values(insertValues) - .onConflictDoUpdate({ - target: [tableRowExecutions.rowId, tableRowExecutions.groupId], - set: { - status: insertValues.status, - executionId: insertValues.executionId, - jobId: insertValues.jobId, - workflowId: insertValues.workflowId, - error: insertValues.error, - runningBlockIds: insertValues.runningBlockIds, - blockErrors: insertValues.blockErrors, - cancelledAt: insertValues.cancelledAt, - updatedAt: insertValues.updatedAt, - }, - }) - } - - return 'wrote' -} - -/** - * Strips the given workflow group ids from every row's executions on a table — - * used by the column / group delete paths so stale running/queued exec records - * don't linger and inflate counters after the group is gone. The caller wraps - * in their own transaction. - */ -async function stripGroupExecutions( - trx: DbOrTx, - tableId: string, - groupIds: Iterable -): Promise { - const ids = Array.from(new Set(groupIds)) - if (ids.length === 0) return - await trx - .delete(tableRowExecutions) - .where( - and(eq(tableRowExecutions.tableId, tableId), inArray(tableRowExecutions.groupId, ids)) as SQL - ) -} - -/** - * Updates a single row. - * - * @param data - Update data - * @param table - Table definition - * @param requestId - Request ID for logging - * @returns Updated row - * @throws Error if row not found or validation fails - */ -export async function updateRow( - data: UpdateRowData, - table: TableDefinition, - requestId: string -): Promise { - // Get existing row - const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) - if (!existingRow) { - throw new Error('Row not found') - } - - // Merge partial update with existing row data so callers can pass only changed fields - const mergedData = { - ...(existingRow.data as RowData), - ...data.data, - } - // Auto-clear exec records for workflow output columns the user just wiped - // AND for downstream groups whose deps just changed. Surfaces the in-flight - // downstream groups so the caller can cancel + re-run them. - const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = - deriveExecClearsForDataPatch( - data.data, - table.schema, - existingRow.executions, - data.executionsPatch, - mergedData - ) - const mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch) - - // Validate size - const sizeValidation = validateRowSize(mergedData) - if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) - } - - // Validate against schema - const schemaValidation = coerceRowToSchema(mergedData, table.schema) - if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) - } - - // Check unique constraints using optimized database query - const uniqueColumns = getUniqueColumns(table.schema) - if (uniqueColumns.length > 0) { - const uniqueValidation = await checkUniqueConstraintsDb( - data.tableId, - mergedData, - table.schema, - data.rowId // Exclude current row - ) - if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) - } - } - - const now = new Date() - - // Cell-task partial writes pass `cancellationGuard` so the upsert into - // `tableRowExecutions` is a no-op when (a) a stop click already wrote - // `cancelled` for this run, or (b) a newer run has taken over the cell - // with a different executionId. Authoritative cancel writes from - // `cancelWorkflowGroupRuns` skip the guard entirely. Data + executions - // commit in one transaction so a partial write can't leave the sidecar - // and the row out of sync. - const guard = data.cancellationGuard - const guardRejected = await db - .transaction(async (trx) => { - await trx - .update(userTableRows) - .set({ data: mergedData, updatedAt: now }) - .where(eq(userTableRows.id, data.rowId)) - - const result = await writeExecutionsPatch( - trx, - data.tableId, - data.rowId, - effectiveExecutionsPatch, - guard - ) - if (result === 'guard-rejected') { - // Roll back the data update too — the worker isn't authoritative. - throw new GuardRejected() - } - return false - }) - .catch((err) => { - if (err instanceof GuardRejected) return true - throw err - }) - - if (guardRejected) { - return null - } - - logger.info(`[${requestId}] Updated row ${data.rowId} in table ${data.tableId}`) - - const updatedRow: TableRow = { - id: data.rowId, - data: mergedData, - executions: mergedExecutions, - position: existingRow.position, - createdAt: existingRow.createdAt, - updatedAt: now, - } - - const oldRows = new Map([[data.rowId, existingRow.data as RowData]]) - void fireTableTrigger( - data.tableId, - table.name, - 'update', - [updatedRow], - oldRows, - table.schema, - requestId - ) - - // Auto-fire only on user-facing data edits. Internal callers that mutate - // executions (cell-task partial/terminal writes, cancel writes) always pass - // `executionsPatch` — re-dispatching from those would recursively spawn new - // dispatches for every running/terminal write, flooding the dispatcher with - // redundant pre-stamps that strand `pending` cells. - const isInternalExecWrite = data.executionsPatch && Object.keys(data.executionsPatch).length > 0 - if (isInternalExecWrite) { - return updatedRow - } - - // Two passes: - // 1. Cancel in-flight downstream groups whose dep just changed, then - // manually re-run them — the cancel writes `cancelled` per cell and - // `mode: 'incomplete' + isManualRun: true` wipes those entries and - // re-enqueues. - // 2. `mode: 'new'` for groups that just had their exec entries cleared - // (own-output wipe OR terminal downstream dep-changed) — the - // dispatcher's `jsonb_exists_all` SQL filter lets the row through - // because at least one targeted group's exec is now missing. - if (inFlightDownstreamGroups.length > 0) { - void (async () => { - try { - await cancelWorkflowGroupRuns(data.tableId, data.rowId, { - groupIds: inFlightDownstreamGroups, - }) - await runWorkflowColumn({ - tableId: data.tableId, - workspaceId: data.workspaceId, - mode: 'incomplete', - isManualRun: true, - rowIds: [data.rowId], - groupIds: inFlightDownstreamGroups, - requestId, - triggeredByUserId: data.actorUserId, - }) - } catch (err) { - logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err) - } - })() - } - void runWorkflowColumn({ - tableId: data.tableId, - workspaceId: data.workspaceId, - rowIds: [data.rowId], - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err)) - - return updatedRow -} - -/** - * Deletes a single row (hard delete). - * - * @param tableId - Table ID - * @param rowId - Row ID to delete - * @param workspaceId - Workspace ID for access control - * @param requestId - Request ID for logging - * @throws Error if row not found - */ -export async function deleteRow( - tableId: string, - rowId: string, - workspaceId: string, - requestId: string -): Promise { - const deleted = await deleteOrderedRow({ tableId, rowId, workspaceId }) - if (!deleted) throw new Error('Row not found') - - logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`) -} - -/** - * Updates multiple rows matching a filter. - * - * @param table - Table definition (provides column schema for type-aware filter casts) - * @param data - Bulk update data - * @param requestId - Request ID for logging - * @returns Bulk operation result - */ -export async function updateRowsByFilter( - table: TableDefinition, - data: BulkUpdateData, - requestId: string -): Promise { - const tableName = USER_TABLE_ROWS_SQL_NAME - - const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) - if (!filterClause) { - throw new Error('Filter is required for bulk update') - } - - const baseConditions = and( - eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId) - ) - - // Tenant-bounded: the jsonb filter is unestimatable and otherwise sends the planner to a - // whole-shared-relation seq scan (14.4s measured on a 1M-row table). - const matchingRows = await withSeqscanOff(async (trx) => { - let query = trx - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where(and(baseConditions, filterClause)) - if (data.limit) { - query = query.limit(data.limit) as typeof query - } - return query - }) - - if (matchingRows.length === 0) { - return { affectedCount: 0, affectedRowIds: [] } - } - - // Coerce the patch itself in place — the write below persists `data.data` - // (as `patchJson`), so coercing only the per-row merged copies would be - // discarded. The merged validation in the loop still enforces required - // fields against the full row. - coerceRowValues(data.data, table.schema) - - for (const row of matchingRows) { - const existingData = row.data as RowData - const mergedData = { ...existingData, ...data.data } - - const sizeValidation = validateRowSize(mergedData) - if (!sizeValidation.valid) { - throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) - } - - const schemaValidation = coerceRowToSchema(mergedData, table.schema) - if (!schemaValidation.valid) { - throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) - } - } - - const uniqueColumns = getUniqueColumns(table.schema) - const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) - if (uniqueColumnsInUpdate.length > 0) { - if (matchingRows.length > 1) { - throw new Error( - `Cannot set unique column values when updating multiple rows. ` + - `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + - `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` - ) - } - - // Only one row — only the touched unique columns need re-checking. - const row = matchingRows[0] - const mergedData = { ...(row.data as RowData), ...data.data } - const uniqueValidation = await checkUniqueConstraintsDb( - table.id, - mergedData, - table.schema, - row.id - ) - if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) - } - } - - const now = new Date() - const ids = matchingRows.map((r) => r.id) - const patchJson = JSON.stringify(data.data) - - await db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - for (let i = 0; i < ids.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { - const batchIds = ids.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) - await trx - .update(userTableRows) - .set({ - data: sql`${userTableRows.data} || ${patchJson}::jsonb`, - updatedAt: now, - }) - .where(inArray(userTableRows.id, batchIds)) - } - }) - - logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${table.id}`) - - const oldRows = new Map(matchingRows.map((r) => [r.id, r.data as RowData])) - const updatedRows: TableRow[] = matchingRows.map((r) => ({ - id: r.id, - data: { ...(r.data as RowData), ...data.data }, - executions: {}, - position: 0, - createdAt: now, - updatedAt: now, - })) - void fireTableTrigger( - table.id, - table.name, - 'update', - updatedRows, - oldRows, - table.schema, - requestId - ) - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: updatedRows.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err)) - - return { - affectedCount: matchingRows.length, - affectedRowIds: ids, - } -} - -/** - * Updates multiple rows with per-row data in a single transaction. - * Avoids the race condition of parallel update_row calls overwriting each other. - */ -export async function batchUpdateRows( - data: BatchUpdateByIdData, - table: TableDefinition, - requestId: string -): Promise { - if (data.updates.length === 0) { - return { affectedCount: 0, affectedRowIds: [] } - } - - const rowIds = data.updates.map((u) => u.rowId) - const existingRows = await db - .select({ - id: userTableRows.id, - data: userTableRows.data, - }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId), - inArray(userTableRows.id, rowIds) - ) - ) - - const executionsByRow = await loadExecutionsByRow( - db, - existingRows.map((r) => r.id) - ) - - type ExistingRow = { data: RowData; executions: RowExecutions } - const existingMap = new Map( - existingRows.map((r) => [ - r.id, - { data: r.data as RowData, executions: executionsByRow.get(r.id) ?? {} }, - ]) - ) - - const missing = rowIds.filter((id) => !existingMap.has(id)) - if (missing.length > 0) { - throw new Error(`Rows not found: ${missing.join(', ')}`) - } - - const mergedUpdates: Array<{ - rowId: string - mergedData: RowData - mergedExecutions: RowExecutions - executionsPatch?: Record - inFlightDownstreamGroups: string[] - }> = [] - for (const update of data.updates) { - const existing = existingMap.get(update.rowId)! - const merged = { ...existing.data, ...update.data } - // Auto-clear exec records for workflow output columns the user just - // wiped AND downstream dep-changed terminal groups — same rationale as - // `updateRow`. Per-row in-flight downstream groups are surfaced so we - // can run the cancel+rerun orchestration after the batch commits. - const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } = - deriveExecClearsForDataPatch( - update.data, - table.schema, - existing.executions, - update.executionsPatch, - merged - ) - const mergedExecutions = applyExecutionsPatch(existing.executions, effectiveExecutionsPatch) - - const sizeValidation = validateRowSize(merged) - if (!sizeValidation.valid) { - throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) - } - - const schemaValidation = coerceRowToSchema(merged, table.schema) - if (!schemaValidation.valid) { - throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) - } - - mergedUpdates.push({ - rowId: update.rowId, - mergedData: merged, - mergedExecutions, - executionsPatch: effectiveExecutionsPatch, - inFlightDownstreamGroups, - }) - } - - const uniqueColumns = getUniqueColumns(table.schema) - if (uniqueColumns.length > 0) { - for (const { rowId, mergedData } of mergedUpdates) { - const uniqueValidation = await checkUniqueConstraintsDb( - data.tableId, - mergedData, - table.schema, - rowId - ) - if (!uniqueValidation.valid) { - throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`) - } - } - } - - const now = new Date() - - await db.transaction(async (trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { - const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) - // Update row data in parallel; sidecar exec writes are sequential per - // row (each goes through writeExecutionsPatch's per-key upsert). - const dataPromises = batch.map(({ rowId, mergedData }) => - trx - .update(userTableRows) - .set({ data: mergedData, updatedAt: now }) - .where(eq(userTableRows.id, rowId)) - ) - await Promise.all(dataPromises) - for (const { rowId, executionsPatch } of batch) { - await writeExecutionsPatch(trx, data.tableId, rowId, executionsPatch) - } - } - }) - - logger.info(`[${requestId}] Batch updated ${mergedUpdates.length} rows in table ${data.tableId}`) - - const oldRowsForTrigger = new Map( - data.updates.map((u) => [u.rowId, existingMap.get(u.rowId)!.data]) - ) - const updatedRowsForTrigger: TableRow[] = mergedUpdates.map( - ({ rowId, mergedData, mergedExecutions }) => ({ - id: rowId, - data: mergedData, - executions: mergedExecutions, - position: 0, - createdAt: now, - updatedAt: now, - }) - ) - void fireTableTrigger( - data.tableId, - table.name, - 'update', - updatedRowsForTrigger, - oldRowsForTrigger, - table.schema, - requestId - ) - // Per-row cancel+rerun for in-flight downstream groups whose deps just - // changed — same orchestration as single-row `updateRow`. Without this, - // batch updates would leave running workflows reading stale dep values. - // Each row needs its own cancel + manual-incomplete dispatch because - // `cancelWorkflowGroupRuns`'s `groupIds` filter is per-row. - const rowsWithInFlightDownstream = mergedUpdates.filter( - (u) => u.inFlightDownstreamGroups.length > 0 - ) - if (rowsWithInFlightDownstream.length > 0) { - void (async () => { - try { - for (const { rowId, inFlightDownstreamGroups } of rowsWithInFlightDownstream) { - await cancelWorkflowGroupRuns(data.tableId, rowId, { - groupIds: inFlightDownstreamGroups, - }) - await runWorkflowColumn({ - tableId: data.tableId, - workspaceId: data.workspaceId, - mode: 'incomplete', - isManualRun: true, - rowIds: [rowId], - groupIds: inFlightDownstreamGroups, - requestId, - triggeredByUserId: data.actorUserId, - }) - } - } catch (err) { - logger.error( - `[${requestId}] cancel+rerun for in-flight downstream groups (batch) failed:`, - err - ) - } - })() - } - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: updatedRowsForTrigger.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) - - return { - affectedCount: mergedUpdates.length, - affectedRowIds: mergedUpdates.map((u) => u.rowId), - } -} - -/** - * Deletes multiple rows matching a filter. - * - * @param table - Table definition (provides column schema for type-aware filter casts) - * @param data - Bulk delete data - * @param requestId - Request ID for logging - * @returns Bulk operation result - */ -export async function deleteRowsByFilter( - table: TableDefinition, - data: BulkDeleteData, - requestId: string -): Promise { - const tableName = USER_TABLE_ROWS_SQL_NAME - - // Build filter clause - const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) - if (!filterClause) { - throw new Error('Filter is required for bulk delete') - } - - // Find matching rows - const baseConditions = and( - eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId) - ) - - // Tenant-bounded for the same reason as updateRowsByFilter — see withSeqscanOff. - const matchingRows = await withSeqscanOff(async (trx) => { - let query = trx - .select({ id: userTableRows.id, position: userTableRows.position }) - .from(userTableRows) - .where(and(baseConditions, filterClause)) - if (data.limit) { - query = query.limit(data.limit) as typeof query - } - return query - }) - - if (matchingRows.length === 0) { - return { affectedCount: 0, affectedRowIds: [] } - } - - const rowIds = matchingRows.map((r) => r.id) - - await deleteOrderedRowsByIds({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds, - }) - - logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) - - return { - affectedCount: matchingRows.length, - affectedRowIds: rowIds, - } -} - -/** - * Deletes rows by their IDs. - * - * @param data - Row IDs and table context - * @param requestId - Request ID for logging - * @returns Deletion result with deleted/missing row IDs - */ -export async function deleteRowsByIds( - data: BulkDeleteByIdsData, - requestId: string -): Promise { - const uniqueRequestedRowIds = Array.from(new Set(data.rowIds)) - - const deletedRows = await deleteOrderedRowsByIds({ - tableId: data.tableId, - workspaceId: data.workspaceId, - rowIds: uniqueRequestedRowIds, - }) - - const deletedIds = deletedRows.map((r) => r.id) - const deletedIdSet = new Set(deletedIds) - const missingRowIds = uniqueRequestedRowIds.filter((id) => !deletedIdSet.has(id)) - - logger.info(`[${requestId}] Deleted ${deletedIds.length} rows by ID from table ${data.tableId}`) - - return { - deletedCount: deletedIds.length, - deletedRowIds: deletedIds, - requestedCount: uniqueRequestedRowIds.length, - missingRowIds, - } -} - -/** - * Renames a column in a table's schema and updates all row data keys. - * - * @param data - Rename column data - * @param requestId - Request ID for logging - * @returns Updated table definition - * @throws Error if table not found, column not found, or new name conflicts - */ -export async function renameColumn( - data: RenameColumnData, - requestId: string -): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - if (!NAME_PATTERN.test(data.newName)) { - throw new Error( - `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` - ) - } - - if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( - `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` - ) - } - - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) - if (columnIndex === -1) { - throw new Error(`Column "${data.oldName}" not found`) - } - - if ( - schema.columns.some( - (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() - ) - ) { - throw new Error(`Column "${data.newName}" already exists`) - } - - const targetColumn = schema.columns[columnIndex] - const actualOldName = targetColumn.name - - // Rename is metadata-only: stored rows, metadata, and workflow-group refs all - // key on the column's stable id, which a rename never changes — so this is a - // pure schema write, no per-row JSONB rewrite or group/metadata cascade. - // Stamp the current storage key as the id (for any not-yet-backfilled column) - // so existing rows stay reachable as the display name changes. - const columnId = targetColumn.id ?? actualOldName - const updatedColumns = schema.columns.map((c, i) => - i === columnIndex ? { ...c, id: columnId, name: data.newName } : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) - - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - logger.info( - `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` - ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) -} - -/** Removes the given column-id keys from a metadata blob (widths/order/pinned). */ -function stripColumnIdsFromMetadata( - metadata: TableMetadata | null, - ids: ReadonlySet -): TableMetadata | null { - if (!metadata) return metadata - let next = metadata - if (metadata.columnWidths) { - const widths = { ...metadata.columnWidths } - let changed = false - for (const id of ids) - if (id in widths) { - delete widths[id] - changed = true - } - if (changed) next = { ...next, columnWidths: widths } - } - if (metadata.columnOrder?.some((id) => ids.has(id))) { - next = { ...next, columnOrder: metadata.columnOrder.filter((id) => !ids.has(id)) } - } - if (metadata.pinnedColumns?.some((id) => ids.has(id))) { - next = { ...next, pinnedColumns: metadata.pinnedColumns.filter((id) => !ids.has(id)) } - } - return next -} - -/** - * Fire-and-forget reclamation of a deleted column's row storage. The column is - * already gone from the schema, so reads never surface the orphaned id — - * dropping the JSONB key just frees space. Runs in its own transaction with a - * row-count-scaled timeout; failures are logged, not propagated. - */ -function stripColumnDataInBackground( - tableId: string, - columnIds: string[], - rowCount: number, - requestId: string -): void { - if (columnIds.length === 0) return - void (async () => { - try { - await db.transaction(async (trx) => { - const statementMs = scaledStatementTimeoutMs(rowCount, { - baseMs: 60_000, - perRowMs: 2 * columnIds.length, - }) - await setTableTxTimeouts(trx, { statementMs }) - for (const id of columnIds) { - await trx.execute( - sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${tableId} AND data ? ${id}::text` - ) - } - }) - logger.info( - `[${requestId}] Background-stripped deleted column data [${columnIds.join(', ')}] from table ${tableId}` - ) - } catch (err) { - logger.error( - `[${requestId}] Background column-data strip failed for table ${tableId} [${columnIds.join(', ')}]:`, - err - ) - } - })() -} - -/** - * Deletes a column from a table's schema. When id-keyed, returns once the schema - * is updated and reclaims the column's row-data storage in the background - * (fire-and-forget); the legacy path strips the row key synchronously. - * - * @param data - Delete column data - * @param requestId - Request ID for logging - * @returns Updated table definition - * @throws Error if table not found, column not found, or it's the last column - */ -export async function deleteColumn( - data: DeleteColumnData, - requestId: string -): Promise { - const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) - } - - if (schema.columns.length <= 1) { - throw new Error('Cannot delete the last column in a table') - } - - const targetColumn = schema.columns[columnIndex] - const actualName = targetColumn.name - const columnId = getColumnId(targetColumn) - const ownerGroupId = targetColumn.workflowGroupId - - // Drop this column's reference (by id) from every group's outputs and - // `columns` dependency. If the column is the last output of its parent - // group, the group itself is also removed (a group with zero outputs is - // invalid). - let groupRemovedId: string | null = null - const updatedGroups = (schema.workflowGroups ?? []) - .map((group) => { - let next = group - if (ownerGroupId && group.id === ownerGroupId) { - const remaining = group.outputs.filter((o) => o.columnName !== columnId) - if (remaining.length === 0) { - groupRemovedId = group.id - } - next = { ...next, outputs: remaining } - } - return stripGroupDeps(next, new Set([columnId])) - }) - .filter((g) => g.id !== groupRemovedId) - - const updatedSchema: TableSchema = { - ...schema, - columns: schema.columns.filter((_, i) => i !== columnIndex), - ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), - } - const updatedMetadata = stripColumnIdsFromMetadata( - table.metadata as TableMetadata | null, - new Set([columnId]) - ) - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - - const now = new Date() - - // Schema/metadata update commits now; the column's row-data storage is - // reclaimed in the background (fire-and-forget) — reads never surface the - // orphaned id since the column is already gone from the schema. - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId]) - - logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) - - return { - def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, - stripKey: columnId, - } - }) - - stripColumnDataInBackground(data.tableId, [stripKey], def.rowCount ?? 0, requestId) - return def -} - -/** - * Deletes multiple columns from a table in a single transaction. - * Avoids the race condition of calling deleteColumn multiple times in parallel. - */ -export async function deleteColumns( - data: { tableId: string; columnNames: string[] }, - requestId: string -): Promise { - const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const namesToDelete = new Set() - const idsToDelete = new Set() - const notFound: string[] = [] - - for (const name of data.columnNames) { - const col = schema.columns.find((c) => columnMatchesRef(c, name)) - if (!col) { - notFound.push(name) - } else { - namesToDelete.add(col.name) - idsToDelete.add(getColumnId(col)) - } - } - - if (notFound.length > 0) { - throw new Error(`Columns not found: ${notFound.join(', ')}`) - } - - const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) - if (remaining.length === 0) { - throw new Error('Cannot delete all columns from a table') - } - - // For each group, drop outputs whose column (by id) is being deleted. Groups - // that end up with zero outputs are removed entirely (they'd be invalid). - // Then any remaining group's dependencies referencing a removed column are - // cleaned up. - const removedGroupIds = new Set() - let updatedGroups = (schema.workflowGroups ?? []).map((group) => { - const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName)) - if (remainingOutputs.length === 0) { - removedGroupIds.add(group.id) - } - return remainingOutputs.length === group.outputs.length - ? group - : { ...group, outputs: remainingOutputs } - }) - updatedGroups = updatedGroups - .filter((g) => !removedGroupIds.has(g.id)) - .map((group) => stripGroupDeps(group, idsToDelete)) - const updatedSchema: TableSchema = { - ...schema, - columns: remaining, - ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), - } - const updatedMetadata = stripColumnIdsFromMetadata( - table.metadata as TableMetadata | null, - idsToDelete - ) - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - - const now = new Date() - - // Schema/metadata commit now; row storage for the deleted columns is - // reclaimed in the background (fire-and-forget). - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - await stripGroupExecutions(trx, data.tableId, removedGroupIds) - - logger.info( - `[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}` - ) - - return { - def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, - stripKeys: Array.from(idsToDelete), - } - }) - - if (stripKeys.length > 0) { - stripColumnDataInBackground(data.tableId, stripKeys, def.rowCount ?? 0, requestId) - } - return def -} - -/** - * Changes the type of a column. Validates that existing data is compatible. - * - * @param data - Update column type data - * @param requestId - Request ID for logging - * @returns Updated table definition - * @throws Error if table not found, column not found, or existing data is incompatible - */ -export async function updateColumnType( - data: UpdateColumnTypeData, - requestId: string -): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - // Scale both statement and idle timeouts to row count: the compatibility - // check below iterates every row in Node between the row SELECT and the - // schema UPDATE, leaving the transaction idle for that gap. The default 5s - // `idle_in_transaction_session_timeout` would abort a valid type change on - // a large table. - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - - if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { - throw new Error( - `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` - ) - } - - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) - } - - const column = schema.columns[columnIndex] - if (column.type === data.newType) { - return table - } - const columnKey = getColumnId(column) - - // Validate existing data is compatible with the new type - const rows = await trx - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - sql`${userTableRows.data} ? ${columnKey}`, - sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` - ) - ) - - let incompatibleCount = 0 - for (const row of rows) { - const rowData = row.data as RowData - const value = rowData[columnKey] - if (value === null || value === undefined) continue - - if (!isValueCompatibleWithType(value, data.newType)) { - incompatibleCount++ - } - } - - if (incompatibleCount > 0) { - throw new Error( - `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` - ) - } - - const updatedColumns = schema.columns.map((c, i) => - i === columnIndex ? { ...c, type: data.newType } : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() - - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - logger.info( - `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` - ) - - return { ...table, schema: updatedSchema, updatedAt: now } - }) -} - -/** - * Updates constraints (required, unique) on a column. - * - * @param data - Update column constraints data - * @param requestId - Request ID for logging - * @returns Updated table definition - * @throws Error if table not found, column not found, or existing data violates the constraint - */ -export async function updateColumnConstraints( - data: UpdateColumnConstraintsData, - requestId: string -): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - // Scale both statement and idle timeouts to row count: the required/unique - // validation runs between separate queries inside this transaction, leaving - // it briefly idle. Match `updateColumnType` so the default 5s - // `idle_in_transaction_session_timeout` can't abort a valid change on a - // large table. - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) - } - - const column = schema.columns[columnIndex] - const columnKey = getColumnId(column) - if (column.workflowGroupId) { - throw new Error( - `Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.` - ) - } - if (data.required === true && !column.required) { - const [result] = await trx - .select({ count: count() }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - sql`(NOT (${userTableRows.data} ? ${columnKey}) OR ${userTableRows.data}->>${columnKey}::text IS NULL)` - ) - ) - - if (result.count > 0) { - throw new Error( - `Cannot set column "${column.name}" as required: ${result.count} row(s) have null or missing values` - ) - } - } - - if (data.unique === true && !column.unique) { - const duplicates = (await trx.execute( - sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` - )) as { val: string; cnt: number }[] - - if (duplicates.length > 0) { - throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`) - } - } - - const updatedColumns = schema.columns.map((c, i) => - i === columnIndex - ? { - ...c, - ...(data.required !== undefined ? { required: data.required } : {}), - ...(data.unique !== undefined ? { unique: data.unique } : {}), - } - : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() - - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - logger.info( - `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` - ) - - return { ...table, schema: updatedSchema, updatedAt: now } - }) -} - -/** - * Atomically inserts a workflow group plus its output columns into a table's - * schema. Both arrays update in one DB write so the schema is never observed - * mid-mutation (e.g. columns referencing a group that doesn't yet exist). - */ -export async function addWorkflowGroup( - data: AddWorkflowGroupData, - requestId: string -): Promise { - const updatedTable = await withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const groups = schema.workflowGroups ?? [] - if (groups.some((g) => g.id === data.group.id)) { - throw new Error(`Workflow group "${data.group.id}" already exists`) - } - - const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) - for (const col of data.outputColumns) { - if (!NAME_PATTERN.test(col.name)) { - throw new Error( - `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` - ) - } - if (existingNames.has(col.name.toLowerCase())) { - throw new Error(`Column "${col.name}" already exists`) - } - } - - if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` - ) - } - - // Assign stable ids to the new output columns, then rewrite the group's - // column refs from name → id so outputs/deps/inputMappings key on ids — - // matching the row-data storage key and surviving future renames. - const outputColumns = data.outputColumns.map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const updatedColumns = [...schema.columns, ...outputColumns] - const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) - const group = remapGroupColumnRefs(data.group, idByName) - - const updatedSchema: TableSchema = { - ...schema, - columns: updatedColumns, - workflowGroups: [...groups, group], - } - - // Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`. - // New output columns get appended in the order the caller supplied. - const existingOrder = table.metadata?.columnOrder - let updatedMetadata = table.metadata - if (existingOrder && existingOrder.length > 0) { - const known = new Set(existingOrder) - const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) - if (append.length > 0) { - updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } - } - } - - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - logger.info( - `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` - ) - - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) - - // Auto-fire existing rows whose deps are already met for the new group. - // Fire-and-forget — the dispatcher bounds queue depth (window of 20) and - // walks the table in the background. HTTP returns instantly; cells fill - // in over the next minutes as the dispatcher walks. Mothership opts out - // by setting `autoRun: false`. - if (data.autoRun !== false) { - void runWorkflowColumn({ - tableId: updatedTable.id, - workspaceId: updatedTable.workspaceId, - mode: 'new', - isManualRun: false, - groupIds: [data.group.id], - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) - } - - return updatedTable -} - -/** - * Updates a workflow group: any combination of workflowId, name, dependencies, - * outputs[]. Computes added/removed outputs vs current state and inserts / - * removes columns transactionally. Removed outputs also clear their key from - * every row's `data`. - */ -export async function updateWorkflowGroup( - data: UpdateWorkflowGroupData, - requestId: string -): Promise { - const mappingUpdates = data.mappingUpdates ?? [] - - // Phase 1 (no lock): when there are mapping updates, load the workflow once to - // resolve each remap's new leaf type. Kept OFF the advisory-lock critical - // section so concurrent group edits on the same table don't time out waiting - // on this DB load. Best-effort — a resolution failure leaves column types - // unchanged (workflow deleted, block removed). The result is applied against - // the fresh schema under the lock in phase 2. - const remapLeafTypeByColumn = new Map() - // The workflow id the leaf types above were resolved against. Phase 2 only - // applies the resolved types if the group still points at this workflow under - // the lock — a concurrent `workflowId` change would make them stale. - let resolvedForWorkflowId: string | undefined - if (mappingUpdates.length > 0) { - try { - const preTable = await getTableById(data.tableId) - const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId) - const targetWorkflowId = data.workflowId ?? preGroup?.workflowId - if (targetWorkflowId) { - resolvedForWorkflowId = targetWorkflowId - const [ - { loadWorkflowFromNormalizedTables }, - { flattenWorkflowOutputs }, - { columnTypeForLeaf }, - ] = await Promise.all([ - import('@/lib/workflows/persistence/utils'), - import('@/lib/workflows/blocks/flatten-outputs'), - import('./column-naming'), - ]) - const normalized = await loadWorkflowFromNormalizedTables(targetWorkflowId) - if (normalized) { - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record | undefined, - })) - const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) - const flatByKey = new Map(flattened.map((f) => [`${f.blockId}::${f.path}`, f])) - for (const u of mappingUpdates) { - const match = flatByKey.get(`${u.blockId}::${u.path}`) - if (!match) continue - const newType = columnTypeForLeaf(match.leafType) - if (newType) remapLeafTypeByColumn.set(u.columnName, newType) - } - } - } - } catch (err) { - logger.warn( - `[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`, - err - ) - } - } - - const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } = - await withLockedTable(data.tableId, async (table, trx) => { - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - - // Normalize every caller-supplied column reference to its stable id, so - // the diff/splice/clear logic below operates uniformly in id-space (the - // row-data storage key). New output columns get ids first; then output - // `columnName`, deps, input mappings, and mapping-update targets are - // remapped name → id. Callers that already pass ids are unaffected. - const newColDefs = (data.newOutputColumns ?? []).map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const idByName = new Map( - [...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)]) - ) - const remapRef = (ref: string) => idByName.get(ref) ?? ref - const outputsInput = data.outputs?.map((o) => ({ ...o, columnName: remapRef(o.columnName) })) - const dependenciesInput = data.dependencies - ? { columns: data.dependencies.columns?.map(remapRef) } - : undefined - const inputMappingsInput = data.inputMappings?.map((m) => ({ - ...m, - columnName: remapRef(m.columnName), - })) - const mappingUpdatesNorm = mappingUpdates.map((u) => ({ - ...u, - columnName: remapRef(u.columnName), - })) - // Re-key the out-of-lock leaf-type resolution to ids to match. - const remapLeafTypeById = new Map() - for (const [name, type] of remapLeafTypeByColumn) remapLeafTypeById.set(remapRef(name), type) - - // Apply `mappingUpdates` first: each entry repoints an existing output's - // `(blockId, path)` while preserving the column. We patch the **old** view - // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the - // swap as a remove+add. The corresponding row data is cleared after the - // schema write so stale values from the old source don't linger. - const remappedColumnIds = new Set() - // Per-column type override (keyed by id) resolved (out-of-lock) from the - // new mapping's leaf type. Only populated when a remap actually changes - // the column's type against the fresh schema. - const remappedColumnTypes = new Map() - let oldOutputs = group.outputs - if (mappingUpdatesNorm.length > 0) { - const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u])) - for (const u of mappingUpdatesNorm) { - const exists = oldOutputs.some((o) => o.columnName === u.columnName) - if (!exists) { - throw new Error( - `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` - ) - } - } - oldOutputs = oldOutputs.map((o) => { - const u = updateById.get(o.columnName) - if (!u) return o - remappedColumnIds.add(o.columnName) - return { ...o, blockId: u.blockId, path: u.path } - }) - - // Only apply the out-of-lock leaf-type resolution if the group still - // points at the workflow we resolved against. If a concurrent writer - // changed `workflowId` between phase 1 and now, those types are stale — - // leave column types unchanged (best-effort, same as a resolution - // failure) rather than stamping types from the old workflow. - const finalWorkflowId = data.workflowId ?? group.workflowId - if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { - logger.warn( - `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` - ) - } else { - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) - for (const u of mappingUpdatesNorm) { - const newType = remapLeafTypeById.get(u.columnName) - if (!newType) continue - const oldType = colById.get(u.columnName)?.type - if (newType !== oldType) { - remappedColumnTypes.set(u.columnName, newType) - } - } - } - } - - // If the caller passed `outputs`, that's the new full set. If only - // `mappingUpdates` was sent, the new set is the remapped old set. - const newOutputs = outputsInput ?? oldOutputs - // Enrichment outputs all share empty `blockId`/`path`, so keying on those - // alone collapses every sibling to one entry (dropping columns on diff). Key - // on the registry `outputId` when present; fall back to `blockId::path` for - // workflow outputs. - const oldKey = (o: WorkflowGroupOutput) => - o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}` - const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) - const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) - - const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) - const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) - const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c])) - - for (const out of added) { - if (!newColById.has(out.columnName)) { - throw new Error( - `Missing column definition for new output "${out.columnName}" (group ${data.groupId}).` - ) - } - } - - const removedColumnIds = new Set(removed.map((o) => o.columnName)) - let nextColumns = schema.columns - .filter((c) => !removedColumnIds.has(getColumnId(c))) - .map((c) => { - const newType = remappedColumnTypes.get(getColumnId(c)) - return newType ? { ...c, type: newType } : c - }) - if (newColDefs.length > 0) { - // Splice the new column defs into the group's contiguous run rather than - // appending at the end. The desired in-group order is `newOutputs` (the - // sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first - // surviving sibling's index in `nextColumns`, and emit each output's - // column def in turn. - const groupColIds = new Set(newOutputs.map((o) => o.columnName)) - const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c))) - const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx - const orderedGroupCols: ColumnDefinition[] = [] - for (const out of newOutputs) { - const fresh = newColById.get(out.columnName) - if (fresh) { - orderedGroupCols.push(fresh) - } else { - const existing = nextColumns.find((c) => getColumnId(c) === out.columnName) - if (existing) orderedGroupCols.push(existing) - } - } - const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c))) - nextColumns = [ - ...remaining.slice(0, anchorIdx), - ...orderedGroupCols, - ...remaining.slice(anchorIdx), - ] - } - - const updatedGroup: WorkflowGroup = { - ...group, - workflowId: data.workflowId ?? group.workflowId, - name: data.name ?? group.name, - dependencies: dependenciesInput ?? group.dependencies, - outputs: newOutputs, - ...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}), - ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), - ...(data.type !== undefined ? { type: data.type } : {}), - ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), - } - // Removed outputs may be referenced as deps by sibling groups; strip those - // refs so we don't leave dangling-column deps that fail schema validation. - const nextGroups = groups - .map((g, i) => (i === groupIndex ? updatedGroup : g)) - .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds))) - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } - - // `columnOrder` (column ids) mirrors the schema layout. Drop removed - // columns, then splice the new ones in at the same anchor as `nextColumns` - // so the table renders them inside the group's contiguous run. - let updatedColumnOrder = table.metadata?.columnOrder?.filter( - (id) => !removedColumnIds.has(id) - ) - if (updatedColumnOrder && newColDefs.length > 0) { - const newColIds = new Set(newColDefs.map(getColumnId)) - const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id)) - const groupColIds = new Set(newOutputs.map((o) => o.columnName)) - const orderedGroupIds = newOutputs.map((o) => o.columnName) - const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id)) - const anchorOrderIdx = - firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx - const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id)) - updatedColumnOrder = [ - ...remainingOrder.slice(0, anchorOrderIdx), - ...orderedGroupIds, - ...remainingOrder.slice(anchorOrderIdx), - ] - } - assertValidSchema(updatedSchema, updatedColumnOrder) - - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null - - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - for (const id of removedColumnIds) { - await trx.execute( - sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text` - ) - } - // Remapped columns: clear stale values in-tx so rows the backfill can't - // repopulate (no log, no matching span output) end up empty rather than - // retaining the previous mapping's value. The backfill below then writes - // the new mapping's value into rows where it can find one. - for (const id of remappedColumnIds) { - if (removedColumnIds.has(id)) continue - await trx.execute( - sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text` - ) - } - - logger.info( - `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` - ) - - const updatedTable: TableDefinition = { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - return { - updatedTable, - added, - remappedColumnIds, - newOutputs, - previousAutoRun: group.autoRun, - } - }) - - // Backfill from saved execution logs so already-completed group runs surface - // the schema changes without re-running the workflow. Two passes: - // - added outputs (new columns): never overwrite hand-edited values. - // - remapped outputs (existing column re-pointed): overwrite, since the - // new mapping is the source of truth and the user expects the cell to - // refresh to the new output's value. - // Small tables backfill inline-awaited (response returns with consistent - // data); large ones run as a background job. A failed backfill is logged - // but doesn't fail the request — the schema change has already committed. - // Lazy import: backfill-runner closes a cycle back to this module. - const { maybeBackfillGroupOutputs } = await import('./backfill-runner') - if (added.length > 0) { - try { - await maybeBackfillGroupOutputs({ - table: updatedTable, - groupId: data.groupId, - outputs: added, - overwrite: false, - requestId, - actorUserId: data.actorUserId, - }) - } catch (err) { - logger.warn( - `[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, - err - ) - } - } - if (remappedColumnIds.size > 0) { - const remappedOutputs = newOutputs.filter((o) => remappedColumnIds.has(o.columnName)) - try { - await maybeBackfillGroupOutputs({ - table: updatedTable, - groupId: data.groupId, - outputs: remappedOutputs, - overwrite: true, - requestId, - actorUserId: data.actorUserId, - }) - } catch (err) { - logger.warn( - `[${requestId}] Remap backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, - err - ) - } - } - - // autoRun toggled false → true: fire deps-satisfied rows now via the - // dispatcher. Mirrors the post-add path so re-enabling auto-fire doesn't - // require manual run clicks for rows that are already eligible. - if (previousAutoRun === false && data.autoRun === true) { - void runWorkflowColumn({ - tableId: updatedTable.id, - workspaceId: updatedTable.workspaceId, - mode: 'new', - isManualRun: false, - groupIds: [data.groupId], - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => - logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) - ) - } - - return updatedTable -} - -/** - * Adds a single output to an existing workflow group. Mirrors `addTableColumn` - * for plain columns: one canonical op, one column created, type inferred from - * the workflow's flattened outputs (`leafType` for `(blockId, path)`). The - * column is spliced into the group's contiguous run so the table renders the - * new output next to its siblings. - */ -export async function addWorkflowGroupOutput( - data: { - tableId: string - groupId: string - blockId: string - path: string - /** Optional override; defaults to a slug derived from `path`. */ - columnName?: string - /** The member adding the output — billed/gated for any backfill-triggered re-run. */ - actorUserId?: string | null - }, - requestId: string -): Promise { - // Phase 1 (no lock): load the workflow and resolve the pickable output plus - // its execution-order index. This depends only on the workflow graph (which - // is stable), so it runs OFF the advisory-lock critical section — holding the - // lock during this DB load would make concurrent adders on the same table - // time out waiting (the Mothership fan-out this fix targets). Phase 2 - // re-validates that the group still maps to the same workflow under the lock. - const preTable = await getTableById(data.tableId) - if (!preTable) throw new Error('Table not found') - const preGroup = (preTable.schema.workflowGroups ?? []).find((g) => g.id === data.groupId) - if (!preGroup) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const workflowId = preGroup.workflowId - - const [ - { loadWorkflowFromNormalizedTables }, - { flattenWorkflowOutputs, getBlockExecutionOrder }, - { columnTypeForLeaf, deriveOutputColumnName }, - ] = await Promise.all([ - import('@/lib/workflows/persistence/utils'), - import('@/lib/workflows/blocks/flatten-outputs'), - import('./column-naming'), - ]) - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - throw new Error(`Workflow ${workflowId} not found`) - } - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record | undefined, - })) - const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) - const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path) - if (!match) { - throw new Error( - `Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}` - ) - } - const newColumnType = columnTypeForLeaf(match.leafType) - const distances = getBlockExecutionOrder(blocks, normalized.edges ?? []) - const flatIndex = new Map(flattened.map((f, i) => [`${f.blockId}::${f.path}`, i])) - - // Phase 2 (locked): re-read fresh, validate against the current schema, and - // write. The critical section holds no I/O — just the in-memory splice + the - // schema UPDATE — so concurrent adders queue behind it quickly. - const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - if (group.workflowId !== workflowId) { - throw new Error( - `Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.` - ) - } - - if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) { - throw new Error( - `Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}` - ) - } - - const taken = new Set(schema.columns.map((c) => c.name)) - const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken) - if (!NAME_PATTERN.test(columnName)) { - throw new Error(`Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.`) - } - if (taken.has(columnName)) { - throw new Error(`Column "${columnName}" already exists`) - } - if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` - ) - } - - const newColDef: ColumnDefinition = { - id: generateColumnId(), - name: columnName, - type: newColumnType, - required: false, - unique: false, - workflowGroupId: data.groupId, - } - const newColumnId = getColumnId(newColDef) - const newOutput: WorkflowGroupOutput = { - blockId: data.blockId, - path: data.path, - columnName: newColumnId, - } - - // Sort all of the group's outputs (existing + new) in workflow execution - // order: BFS distance from the start block ASC, with discovery order as - // tiebreak. This matches what the column-sidebar does at create time, so - // columns from the same workflow always read in the order their blocks run - // — regardless of whether they were added at create time or one-by-one. - const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) - const orderKey = (o: { blockId: string; path: string }) => { - const d = distances[o.blockId] - const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d - const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY - return [dist, idx] as const - } - const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { - const [da, ia] = orderKey(a) - const [db, ib] = orderKey(b) - return da !== db ? da - db : ia - ib - }) - const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName) - const updatedGroup: WorkflowGroup = { - ...group, - outputs: allGroupOutputs, - } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) - - // Splice the new column run into nextColumns: keep the columns outside the - // group where they were, replace the group's contiguous run with the - // BFS-ordered list. Anchor at the position of the first existing sibling - // (or append if the group was empty). - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) - const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => { - if (id === newColumnId) return newColDef - const existing = colById.get(id) - if (!existing) { - throw new Error(`Internal: column "${id}" missing while splicing group outputs`) - } - return existing - }) - const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c))) - const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c))) - const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx - const nextColumns = [ - ...remainingCols.slice(0, colAnchor), - ...orderedGroupCols, - ...remainingCols.slice(colAnchor), - ] - - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } - - const updatedColumnOrder = table.metadata?.columnOrder - ? (() => { - const orderWithoutGroup = table.metadata!.columnOrder!.filter( - (id) => !groupColIdsBefore.has(id) - ) - const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) => - groupColIdsBefore.has(id) - ) - const orderAnchor = - firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx - return [ - ...orderWithoutGroup.slice(0, orderAnchor), - ...orderedGroupColIds, - ...orderWithoutGroup.slice(orderAnchor), - ] - })() - : undefined - - assertValidSchema(updatedSchema, updatedColumnOrder) - - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null - - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - - logger.info( - `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` - ) - - const updatedTable: TableDefinition = { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - return { updatedTable, newOutput } - }) - - // Backfill from saved execution logs — same flow `updateWorkflowGroup` - // uses for added outputs. Reads each row's saved trace spans for the - // group's executionId and writes the new output's value back. Existing - // rows that have hand-edited values are left alone (overwrite: false). - // Cheap compared to re-running the workflow on every row, which is what - // an earlier version of this code did — that mistakenly fanned out N - // workflow-group-cell jobs and burned compute the user didn't ask for. - // Small tables backfill inline; large ones run as a background job. - // Lazy import: backfill-runner closes a cycle back to this module. - try { - const { maybeBackfillGroupOutputs } = await import('./backfill-runner') - await maybeBackfillGroupOutputs({ - table: updatedTable, - groupId: data.groupId, - outputs: [newOutput], - overwrite: false, - requestId, - actorUserId: data.actorUserId, - }) - } catch (err) { - logger.warn( - `[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId} after adding output "${newOutput.columnName}":`, - err - ) - } - - return updatedTable -} - -/** - * Removes a single output from a workflow group. Drops the bound column and - * strips the value from every row's `data` JSONB. If the output is the - * group's last, the empty group is left in place — drop it explicitly with - * `deleteWorkflowGroup` if needed. - */ -export async function deleteWorkflowGroupOutput( - data: { tableId: string; groupId: string; columnName: string }, - requestId: string -): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - // `data.columnName` may be a column id (first-party) or display name - // (mothership/legacy); resolve to the stable id used everywhere below. - const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName)) - const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName - if (!group.outputs.some((o) => o.columnName === columnId)) { - throw new Error( - `Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"` - ) - } - - const updatedGroup: WorkflowGroup = { - ...group, - outputs: group.outputs.filter((o) => o.columnName !== columnId), - } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) - const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId) - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } - - const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId) - assertValidSchema(updatedSchema, updatedColumnOrder) - - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null - - const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - await trx.execute( - sql`UPDATE user_table_rows SET data = data - ${columnId}::text WHERE table_id = ${data.tableId} AND data ? ${columnId}::text` - ) - - logger.info( - `[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}` - ) - - return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } - }) -} - -/** - * Removes a workflow group plus all its output columns. Also strips the - * group's `executions[groupId]` entry from every row. - */ -export async function deleteWorkflowGroup( - data: DeleteWorkflowGroupData, - requestId: string -): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const group = groups.find((g) => g.id === data.groupId) - if (!group) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - - const removedColumnIds = new Set(group.outputs.map((o) => o.columnName)) - // Removed group's output columns may be referenced as deps by sibling groups. - // Strip those refs so we don't leave dangling-column deps behind. - const nextGroups = groups - .filter((g) => g.id !== data.groupId) - .map((g) => stripGroupDeps(g, removedColumnIds)) - const updatedSchema: TableSchema = { - ...schema, - columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))), - workflowGroups: nextGroups, - } - const updatedColumnOrder = table.metadata?.columnOrder?.filter( - (id) => !removedColumnIds.has(id) - ) - assertValidSchema(updatedSchema, updatedColumnOrder) - - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null - - const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - for (const id of removedColumnIds) { - await trx.execute( - sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text` - ) - } - await stripGroupExecutions(trx, data.tableId, [data.groupId]) - - logger.info( - `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` - ) - - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) -} - -/** - * Checks if a value is compatible with a target column type. - */ -function isValueCompatibleWithType( - value: unknown, - targetType: (typeof COLUMN_TYPES)[number] -): boolean { - if (value === null || value === undefined) return true - - switch (targetType) { - case 'string': - return true - case 'number': { - if (typeof value === 'number') return Number.isFinite(value) - if (typeof value === 'string') { - const num = Number(value) - return Number.isFinite(num) && value.trim() !== '' - } - return false - } - case 'boolean': { - if (typeof value === 'boolean') return true - if (typeof value === 'string') - return ['true', 'false', '1', '0'].includes(value.toLowerCase()) - if (typeof value === 'number') return value === 0 || value === 1 - return false - } - case 'date': { - if (value instanceof Date) return !Number.isNaN(value.getTime()) - if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) - return false - } - case 'json': - return true - default: - return false - } -} diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 7e064468a26..1e4f4a35ea0 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -7,9 +7,15 @@ import type { SQL } from 'drizzle-orm' import { sql } from 'drizzle-orm' -import { getColumnId } from './column-keys' -import { NAME_PATTERN } from './constants' -import type { ColumnDefinition, ConditionOperators, Filter, JsonValue, Sort } from './types' +import { getColumnId } from '@/lib/table/column-keys' +import { NAME_PATTERN } from '@/lib/table/constants' +import type { + ColumnDefinition, + ConditionOperators, + Filter, + JsonValue, + Sort, +} from '@/lib/table/types' /** * Error thrown when caller-supplied filter or sort input is malformed. diff --git a/apps/sim/lib/table/tx.ts b/apps/sim/lib/table/tx.ts new file mode 100644 index 00000000000..4e0b8b2dcbe --- /dev/null +++ b/apps/sim/lib/table/tx.ts @@ -0,0 +1,50 @@ +/** + * Shared transaction / locking helpers for the table service layer. + * + * Internal module: not exposed via the `@/lib/table` barrel. Consumers import + * directly from `@/lib/table/tx`. + */ + +import { sql } from 'drizzle-orm' +import type { DbTransaction } from '@/lib/table/planner' + +const TIMEOUT_CAP_MS = 10 * 60_000 + +/** + * Sets per-transaction Postgres timeouts via `SET LOCAL`. + * + * `lock_timeout` is the critical one: without it, a waiter inherits the full + * `statement_timeout` clock, so one stuck writer can drain the pool. + * + * Safe under pgBouncer transaction pooling — `SET LOCAL` is transaction-scoped + * and cleared at COMMIT/ROLLBACK before the session returns to the pool. + */ +export async function setTableTxTimeouts( + trx: DbTransaction, + opts?: { statementMs?: number; lockMs?: number; idleMs?: number } +) { + const s = opts?.statementMs ?? 10_000 + const l = opts?.lockMs ?? 3_000 + const i = opts?.idleMs ?? 5_000 + await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${s}ms'`)) + await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${l}ms'`)) + await trx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${i}ms'`)) +} + +/** + * Scales `statement_timeout` to the expected row-count work. + * + * Bulk operations that rewrite JSONB or cascade row triggers (e.g. + * `replaceTableRows`, `deleteColumn`, `renameColumn`) scale roughly linearly + * with row count. A fixed cap would regress large-table users who never saw a + * timeout before `SET LOCAL` was introduced. This helper picks + * `max(baseMs, rowCount * perRowMs)`, capped at 10 minutes so a single + * runaway transaction cannot indefinitely pin a pool connection. + */ +export function scaledStatementTimeoutMs( + rowCount: number, + opts: { baseMs: number; perRowMs: number } +): number { + const safeRowCount = Math.max(0, rowCount) + return Math.min(TIMEOUT_CAP_MS, Math.max(opts.baseMs, safeRowCount * opts.perRowMs)) +} diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index aaa0760a3e3..cf57842617a 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -2,7 +2,7 @@ * Type definitions for user-defined tables. */ -import type { COLUMN_TYPES } from './constants' +import type { COLUMN_TYPES } from '@/lib/table/constants' export type ColumnValue = string | number | boolean | null | Date export type JsonValue = ColumnValue | JsonValue[] | { [key: string]: JsonValue } diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index fa3aacaf98b..b42e2a62b8d 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -6,10 +6,16 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { getColumnId } from './column-keys' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants' -import { withSeqscanOff } from './planner' -import type { ColumnDefinition, JsonValue, RowData, TableSchema, ValidationResult } from './types' +import { getColumnId } from '@/lib/table/column-keys' +import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { withSeqscanOff } from '@/lib/table/planner' +import type { + ColumnDefinition, + JsonValue, + RowData, + TableSchema, + ValidationResult, +} from '@/lib/table/types' export type { ColumnDefinition, TableSchema, ValidationResult } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 8adbe32a5eb..bfa7de1a36f 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -16,7 +16,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { buildCancelledExecution } from '@/lib/table/cell-write' import type { Filter, @@ -31,16 +31,16 @@ import type { const logger = createLogger('WorkflowGroupScheduler') -import { getColumnId } from './column-keys' -import { USER_TABLE_ROWS_SQL_NAME } from './constants' -import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from './deps' -import type { DispatchLimit, DispatchMode } from './dispatcher' -import { buildFilterClause } from './sql' +import { getColumnId } from '@/lib/table/column-keys' +import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' +import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from '@/lib/table/deps' +import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher' +import { buildFilterClause } from '@/lib/table/sql' export { getUnmetGroupDeps, optimisticallyScheduleNewlyEligibleGroups, -} from './deps' +} from '@/lib/table/deps' /** * Per-(row, group) eligibility for both the auto-fire reactor and manual @@ -367,9 +367,12 @@ export async function cancelWorkflowGroupRuns( rowId?: string, options?: { groupIds?: string[]; filter?: Filter; excludeRowIds?: string[] } ): Promise { - const { getTableById, updateRow } = await import('@/lib/table/service') + const { getTableById } = await import('@/lib/table/service') + const { updateRow } = await import('@/lib/table/rows/service') const { getJobQueue } = await import('@/lib/core/async-jobs/config') - const { listActiveDispatches, markActiveDispatchesCancelled } = await import('./dispatcher') + const { listActiveDispatches, markActiveDispatchesCancelled } = await import( + '@/lib/table/dispatcher' + ) const table = await getTableById(tableId) if (!table) { @@ -661,7 +664,7 @@ export async function runWorkflowColumn(opts: { if (rowIds && rowIds.length === 0) return { dispatchId: null } // Lazy imports: `./service` and `./dispatcher` both close cycles back to // this module; `@trigger.dev/sdk` is heavy and only needed on this op. - const { getTableById } = await import('./service') + const { getTableById } = await import('@/lib/table/service') const table = await getTableById(tableId) if (!table) throw new Error('Table not found') if (table.workspaceId !== workspaceId) throw new Error('Invalid workspace ID') diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts new file mode 100644 index 00000000000..8479118650d --- /dev/null +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -0,0 +1,964 @@ +/** + * Workflow-group operations on user tables. + * + * Extracted from the table service: add/update/delete workflow groups and their + * output columns, plus stale-output pruning after a workflow deploy. These ops + * mutate `schema.workflowGroups` (and the bound output columns + row data) under + * the per-table advisory lock from `withLockedTable`. + */ + +import { db } from '@sim/db' +import { userTableDefinitions } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { + columnMatchesRef, + generateColumnId, + getColumnId, + remapGroupColumnRefs, +} from '@/lib/table/column-keys' +import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { stripGroupExecutions } from '@/lib/table/rows/executions' +import { getTableById, withLockedTable } from '@/lib/table/service' +import { setTableTxTimeouts } from '@/lib/table/tx' +import type { + AddWorkflowGroupData, + ColumnDefinition, + DeleteWorkflowGroupData, + TableDefinition, + TableMetadata, + TableSchema, + UpdateWorkflowGroupData, + WorkflowGroup, + WorkflowGroupOutput, +} from '@/lib/table/types' +import { assertValidSchema, runWorkflowColumn, stripGroupDeps } from '@/lib/table/workflow-columns' + +const logger = createLogger('TableWorkflowGroupsService') +/** + * Drops references to deleted blocks from every workflow group on every table + * that targets the just-deployed workflow. Called from the workflow deploy + * orchestrator after the new deployment commits, so the table UI never holds + * stale `{blockId, path}` entries for blocks the user removed. + * + * - Filters `outputs[]` per group. If every output would be filtered out, the + * group is left untouched and a warning is logged — the user must + * reconfigure it manually. + * - Scoped to the workflow's workspace. + * - Idempotent: running twice with the same `validBlockIds` is a no-op on the + * second pass. Existing row data is left alone. + */ +export async function pruneStaleWorkflowGroupOutputs({ + workflowId, + workspaceId, + validBlockIds, + requestId, + tx, +}: { + workflowId: string + workspaceId: string + validBlockIds: Set + requestId: string + tx?: DbOrTx +}): Promise { + const executor = tx ?? db + const tables = await executor + .select({ + id: userTableDefinitions.id, + schema: userTableDefinitions.schema, + }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + + for (const t of tables) { + const schema = t.schema as TableSchema + const groups = schema.workflowGroups ?? [] + if (groups.length === 0) continue + + let mutated = false + const nextGroups = groups.map((group) => { + if (group.workflowId !== workflowId) return group + const filtered = group.outputs.filter((o) => validBlockIds.has(o.blockId)) + if (filtered.length === group.outputs.length) return group + if (filtered.length === 0) { + logger.warn( + `[${requestId}] All outputs for workflow group "${group.name ?? group.id}" in table ${t.id} reference deleted blocks; leaving group intact for user reconfiguration.` + ) + return group + } + mutated = true + return { ...group, outputs: filtered } + }) + + if (!mutated) continue + + await executor + .update(userTableDefinitions) + .set({ + schema: { ...schema, workflowGroups: nextGroups }, + updatedAt: new Date(), + }) + .where(eq(userTableDefinitions.id, t.id)) + + logger.info(`[${requestId}] Pruned stale workflow=${workflowId} block refs from table ${t.id}`) + } +} + +/** + * Atomically inserts a workflow group plus its output columns into a table's + * schema. Both arrays update in one DB write so the schema is never observed + * mid-mutation (e.g. columns referencing a group that doesn't yet exist). + */ +export async function addWorkflowGroup( + data: AddWorkflowGroupData, + requestId: string +): Promise { + const updatedTable = await withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const groups = schema.workflowGroups ?? [] + if (groups.some((g) => g.id === data.group.id)) { + throw new Error(`Workflow group "${data.group.id}" already exists`) + } + + const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) + for (const col of data.outputColumns) { + if (!NAME_PATTERN.test(col.name)) { + throw new Error( + `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` + ) + } + if (existingNames.has(col.name.toLowerCase())) { + throw new Error(`Column "${col.name}" already exists`) + } + } + + if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new Error( + `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + ) + } + + // Assign stable ids to the new output columns, then rewrite the group's + // column refs from name → id so outputs/deps/inputMappings key on ids — + // matching the row-data storage key and surviving future renames. + const outputColumns = data.outputColumns.map((col) => + col.id ? col : { ...col, id: generateColumnId() } + ) + const updatedColumns = [...schema.columns, ...outputColumns] + const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) + const group = remapGroupColumnRefs(data.group, idByName) + + const updatedSchema: TableSchema = { + ...schema, + columns: updatedColumns, + workflowGroups: [...groups, group], + } + + // Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`. + // New output columns get appended in the order the caller supplied. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0) { + const known = new Set(existingOrder) + const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) + if (append.length > 0) { + updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } + } + } + + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` + ) + + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }) + + // Auto-fire existing rows whose deps are already met for the new group. + // Fire-and-forget — the dispatcher bounds queue depth (window of 20) and + // walks the table in the background. HTTP returns instantly; cells fill + // in over the next minutes as the dispatcher walks. Mothership opts out + // by setting `autoRun: false`. + if (data.autoRun !== false) { + void runWorkflowColumn({ + tableId: updatedTable.id, + workspaceId: updatedTable.workspaceId, + mode: 'new', + isManualRun: false, + groupIds: [data.group.id], + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) + } + + return updatedTable +} + +/** + * Updates a workflow group: any combination of workflowId, name, dependencies, + * outputs[]. Computes added/removed outputs vs current state and inserts / + * removes columns transactionally. Removed outputs also clear their key from + * every row's `data`. + */ +export async function updateWorkflowGroup( + data: UpdateWorkflowGroupData, + requestId: string +): Promise { + const mappingUpdates = data.mappingUpdates ?? [] + + // Phase 1 (no lock): when there are mapping updates, load the workflow once to + // resolve each remap's new leaf type. Kept OFF the advisory-lock critical + // section so concurrent group edits on the same table don't time out waiting + // on this DB load. Best-effort — a resolution failure leaves column types + // unchanged (workflow deleted, block removed). The result is applied against + // the fresh schema under the lock in phase 2. + const remapLeafTypeByColumn = new Map() + // The workflow id the leaf types above were resolved against. Phase 2 only + // applies the resolved types if the group still points at this workflow under + // the lock — a concurrent `workflowId` change would make them stale. + let resolvedForWorkflowId: string | undefined + if (mappingUpdates.length > 0) { + try { + const preTable = await getTableById(data.tableId) + const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId) + const targetWorkflowId = data.workflowId ?? preGroup?.workflowId + if (targetWorkflowId) { + resolvedForWorkflowId = targetWorkflowId + const [ + { loadWorkflowFromNormalizedTables }, + { flattenWorkflowOutputs }, + { columnTypeForLeaf }, + ] = await Promise.all([ + import('@/lib/workflows/persistence/utils'), + import('@/lib/workflows/blocks/flatten-outputs'), + import('@/lib/table/column-naming'), + ]) + const normalized = await loadWorkflowFromNormalizedTables(targetWorkflowId) + if (normalized) { + const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ + id: b.id, + type: b.type, + name: b.name, + triggerMode: (b as { triggerMode?: boolean }).triggerMode, + subBlocks: b.subBlocks as Record | undefined, + })) + const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) + const flatByKey = new Map(flattened.map((f) => [`${f.blockId}::${f.path}`, f])) + for (const u of mappingUpdates) { + const match = flatByKey.get(`${u.blockId}::${u.path}`) + if (!match) continue + const newType = columnTypeForLeaf(match.leafType) + if (newType) remapLeafTypeByColumn.set(u.columnName, newType) + } + } + } + } catch (err) { + logger.warn( + `[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`, + err + ) + } + } + + const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } = + await withLockedTable(data.tableId, async (table, trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new Error(`Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + + // Normalize every caller-supplied column reference to its stable id, so + // the diff/splice/clear logic below operates uniformly in id-space (the + // row-data storage key). New output columns get ids first; then output + // `columnName`, deps, input mappings, and mapping-update targets are + // remapped name → id. Callers that already pass ids are unaffected. + const newColDefs = (data.newOutputColumns ?? []).map((col) => + col.id ? col : { ...col, id: generateColumnId() } + ) + const idByName = new Map( + [...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)]) + ) + const remapRef = (ref: string) => idByName.get(ref) ?? ref + const outputsInput = data.outputs?.map((o) => ({ ...o, columnName: remapRef(o.columnName) })) + const dependenciesInput = data.dependencies + ? { columns: data.dependencies.columns?.map(remapRef) } + : undefined + const inputMappingsInput = data.inputMappings?.map((m) => ({ + ...m, + columnName: remapRef(m.columnName), + })) + const mappingUpdatesNorm = mappingUpdates.map((u) => ({ + ...u, + columnName: remapRef(u.columnName), + })) + // Re-key the out-of-lock leaf-type resolution to ids to match. + const remapLeafTypeById = new Map() + for (const [name, type] of remapLeafTypeByColumn) remapLeafTypeById.set(remapRef(name), type) + + // Apply `mappingUpdates` first: each entry repoints an existing output's + // `(blockId, path)` while preserving the column. We patch the **old** view + // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the + // swap as a remove+add. The corresponding row data is cleared after the + // schema write so stale values from the old source don't linger. + const remappedColumnIds = new Set() + // Per-column type override (keyed by id) resolved (out-of-lock) from the + // new mapping's leaf type. Only populated when a remap actually changes + // the column's type against the fresh schema. + const remappedColumnTypes = new Map() + let oldOutputs = group.outputs + if (mappingUpdatesNorm.length > 0) { + const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u])) + for (const u of mappingUpdatesNorm) { + const exists = oldOutputs.some((o) => o.columnName === u.columnName) + if (!exists) { + throw new Error( + `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` + ) + } + } + oldOutputs = oldOutputs.map((o) => { + const u = updateById.get(o.columnName) + if (!u) return o + remappedColumnIds.add(o.columnName) + return { ...o, blockId: u.blockId, path: u.path } + }) + + // Only apply the out-of-lock leaf-type resolution if the group still + // points at the workflow we resolved against. If a concurrent writer + // changed `workflowId` between phase 1 and now, those types are stale — + // leave column types unchanged (best-effort, same as a resolution + // failure) rather than stamping types from the old workflow. + const finalWorkflowId = data.workflowId ?? group.workflowId + if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { + logger.warn( + `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` + ) + } else { + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + for (const u of mappingUpdatesNorm) { + const newType = remapLeafTypeById.get(u.columnName) + if (!newType) continue + const oldType = colById.get(u.columnName)?.type + if (newType !== oldType) { + remappedColumnTypes.set(u.columnName, newType) + } + } + } + } + + // If the caller passed `outputs`, that's the new full set. If only + // `mappingUpdates` was sent, the new set is the remapped old set. + const newOutputs = outputsInput ?? oldOutputs + // Enrichment outputs all share empty `blockId`/`path`, so keying on those + // alone collapses every sibling to one entry (dropping columns on diff). Key + // on the registry `outputId` when present; fall back to `blockId::path` for + // workflow outputs. + const oldKey = (o: WorkflowGroupOutput) => + o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}` + const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) + const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) + + const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) + const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) + const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c])) + + for (const out of added) { + if (!newColById.has(out.columnName)) { + throw new Error( + `Missing column definition for new output "${out.columnName}" (group ${data.groupId}).` + ) + } + } + + const removedColumnIds = new Set(removed.map((o) => o.columnName)) + let nextColumns = schema.columns + .filter((c) => !removedColumnIds.has(getColumnId(c))) + .map((c) => { + const newType = remappedColumnTypes.get(getColumnId(c)) + return newType ? { ...c, type: newType } : c + }) + if (newColDefs.length > 0) { + // Splice the new column defs into the group's contiguous run rather than + // appending at the end. The desired in-group order is `newOutputs` (the + // sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first + // surviving sibling's index in `nextColumns`, and emit each output's + // column def in turn. + const groupColIds = new Set(newOutputs.map((o) => o.columnName)) + const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c))) + const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx + const orderedGroupCols: ColumnDefinition[] = [] + for (const out of newOutputs) { + const fresh = newColById.get(out.columnName) + if (fresh) { + orderedGroupCols.push(fresh) + } else { + const existing = nextColumns.find((c) => getColumnId(c) === out.columnName) + if (existing) orderedGroupCols.push(existing) + } + } + const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c))) + nextColumns = [ + ...remaining.slice(0, anchorIdx), + ...orderedGroupCols, + ...remaining.slice(anchorIdx), + ] + } + + const updatedGroup: WorkflowGroup = { + ...group, + workflowId: data.workflowId ?? group.workflowId, + name: data.name ?? group.name, + dependencies: dependenciesInput ?? group.dependencies, + outputs: newOutputs, + ...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}), + ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), + ...(data.type !== undefined ? { type: data.type } : {}), + ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), + } + // Removed outputs may be referenced as deps by sibling groups; strip those + // refs so we don't leave dangling-column deps that fail schema validation. + const nextGroups = groups + .map((g, i) => (i === groupIndex ? updatedGroup : g)) + .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds))) + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } + + // `columnOrder` (column ids) mirrors the schema layout. Drop removed + // columns, then splice the new ones in at the same anchor as `nextColumns` + // so the table renders them inside the group's contiguous run. + let updatedColumnOrder = table.metadata?.columnOrder?.filter( + (id) => !removedColumnIds.has(id) + ) + if (updatedColumnOrder && newColDefs.length > 0) { + const newColIds = new Set(newColDefs.map(getColumnId)) + const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id)) + const groupColIds = new Set(newOutputs.map((o) => o.columnName)) + const orderedGroupIds = newOutputs.map((o) => o.columnName) + const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id)) + const anchorOrderIdx = + firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx + const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id)) + updatedColumnOrder = [ + ...remainingOrder.slice(0, anchorOrderIdx), + ...orderedGroupIds, + ...remainingOrder.slice(anchorOrderIdx), + ] + } + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + for (const id of removedColumnIds) { + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text` + ) + } + // Remapped columns: clear stale values in-tx so rows the backfill can't + // repopulate (no log, no matching span output) end up empty rather than + // retaining the previous mapping's value. The backfill below then writes + // the new mapping's value into rows where it can find one. + for (const id of remappedColumnIds) { + if (removedColumnIds.has(id)) continue + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text` + ) + } + + logger.info( + `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` + ) + + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + return { + updatedTable, + added, + remappedColumnIds, + newOutputs, + previousAutoRun: group.autoRun, + } + }) + + // Backfill from saved execution logs so already-completed group runs surface + // the schema changes without re-running the workflow. Two passes: + // - added outputs (new columns): never overwrite hand-edited values. + // - remapped outputs (existing column re-pointed): overwrite, since the + // new mapping is the source of truth and the user expects the cell to + // refresh to the new output's value. + // Small tables backfill inline-awaited (response returns with consistent + // data); large ones run as a background job. A failed backfill is logged + // but doesn't fail the request — the schema change has already committed. + // Lazy import: backfill-runner closes a cycle back to this module. + const { maybeBackfillGroupOutputs } = await import('@/lib/table/backfill-runner') + if (added.length > 0) { + try { + await maybeBackfillGroupOutputs({ + table: updatedTable, + groupId: data.groupId, + outputs: added, + overwrite: false, + requestId, + actorUserId: data.actorUserId, + }) + } catch (err) { + logger.warn( + `[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, + err + ) + } + } + if (remappedColumnIds.size > 0) { + const remappedOutputs = newOutputs.filter((o) => remappedColumnIds.has(o.columnName)) + try { + await maybeBackfillGroupOutputs({ + table: updatedTable, + groupId: data.groupId, + outputs: remappedOutputs, + overwrite: true, + requestId, + actorUserId: data.actorUserId, + }) + } catch (err) { + logger.warn( + `[${requestId}] Remap backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, + err + ) + } + } + + // autoRun toggled false → true: fire deps-satisfied rows now via the + // dispatcher. Mirrors the post-add path so re-enabling auto-fire doesn't + // require manual run clicks for rows that are already eligible. + if (previousAutoRun === false && data.autoRun === true) { + void runWorkflowColumn({ + tableId: updatedTable.id, + workspaceId: updatedTable.workspaceId, + mode: 'new', + isManualRun: false, + groupIds: [data.groupId], + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => + logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) + ) + } + + return updatedTable +} + +/** + * Adds a single output to an existing workflow group. Mirrors `addTableColumn` + * for plain columns: one canonical op, one column created, type inferred from + * the workflow's flattened outputs (`leafType` for `(blockId, path)`). The + * column is spliced into the group's contiguous run so the table renders the + * new output next to its siblings. + */ +export async function addWorkflowGroupOutput( + data: { + tableId: string + groupId: string + blockId: string + path: string + /** Optional override; defaults to a slug derived from `path`. */ + columnName?: string + /** The member adding the output — billed/gated for any backfill-triggered re-run. */ + actorUserId?: string | null + }, + requestId: string +): Promise { + // Phase 1 (no lock): load the workflow and resolve the pickable output plus + // its execution-order index. This depends only on the workflow graph (which + // is stable), so it runs OFF the advisory-lock critical section — holding the + // lock during this DB load would make concurrent adders on the same table + // time out waiting (the Mothership fan-out this fix targets). Phase 2 + // re-validates that the group still maps to the same workflow under the lock. + const preTable = await getTableById(data.tableId) + if (!preTable) throw new Error('Table not found') + const preGroup = (preTable.schema.workflowGroups ?? []).find((g) => g.id === data.groupId) + if (!preGroup) { + throw new Error(`Workflow group "${data.groupId}" not found`) + } + const workflowId = preGroup.workflowId + + const [ + { loadWorkflowFromNormalizedTables }, + { flattenWorkflowOutputs, getBlockExecutionOrder }, + { columnTypeForLeaf, deriveOutputColumnName }, + ] = await Promise.all([ + import('@/lib/workflows/persistence/utils'), + import('@/lib/workflows/blocks/flatten-outputs'), + import('@/lib/table/column-naming'), + ]) + const normalized = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalized) { + throw new Error(`Workflow ${workflowId} not found`) + } + const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ + id: b.id, + type: b.type, + name: b.name, + triggerMode: (b as { triggerMode?: boolean }).triggerMode, + subBlocks: b.subBlocks as Record | undefined, + })) + const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) + const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path) + if (!match) { + throw new Error( + `Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}` + ) + } + const newColumnType = columnTypeForLeaf(match.leafType) + const distances = getBlockExecutionOrder(blocks, normalized.edges ?? []) + const flatIndex = new Map(flattened.map((f, i) => [`${f.blockId}::${f.path}`, i])) + + // Phase 2 (locked): re-read fresh, validate against the current schema, and + // write. The critical section holds no I/O — just the in-memory splice + the + // schema UPDATE — so concurrent adders queue behind it quickly. + const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new Error(`Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + if (group.workflowId !== workflowId) { + throw new Error( + `Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.` + ) + } + + if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) { + throw new Error( + `Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}` + ) + } + + const taken = new Set(schema.columns.map((c) => c.name)) + const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken) + if (!NAME_PATTERN.test(columnName)) { + throw new Error(`Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.`) + } + if (taken.has(columnName)) { + throw new Error(`Column "${columnName}" already exists`) + } + if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new Error( + `Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + ) + } + + const newColDef: ColumnDefinition = { + id: generateColumnId(), + name: columnName, + type: newColumnType, + required: false, + unique: false, + workflowGroupId: data.groupId, + } + const newColumnId = getColumnId(newColDef) + const newOutput: WorkflowGroupOutput = { + blockId: data.blockId, + path: data.path, + columnName: newColumnId, + } + + // Sort all of the group's outputs (existing + new) in workflow execution + // order: BFS distance from the start block ASC, with discovery order as + // tiebreak. This matches what the column-sidebar does at create time, so + // columns from the same workflow always read in the order their blocks run + // — regardless of whether they were added at create time or one-by-one. + const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) + const orderKey = (o: { blockId: string; path: string }) => { + const d = distances[o.blockId] + const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d + const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY + return [dist, idx] as const + } + const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { + const [da, ia] = orderKey(a) + const [db, ib] = orderKey(b) + return da !== db ? da - db : ia - ib + }) + const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName) + const updatedGroup: WorkflowGroup = { + ...group, + outputs: allGroupOutputs, + } + const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + + // Splice the new column run into nextColumns: keep the columns outside the + // group where they were, replace the group's contiguous run with the + // BFS-ordered list. Anchor at the position of the first existing sibling + // (or append if the group was empty). + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => { + if (id === newColumnId) return newColDef + const existing = colById.get(id) + if (!existing) { + throw new Error(`Internal: column "${id}" missing while splicing group outputs`) + } + return existing + }) + const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c))) + const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c))) + const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx + const nextColumns = [ + ...remainingCols.slice(0, colAnchor), + ...orderedGroupCols, + ...remainingCols.slice(colAnchor), + ] + + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } + + const updatedColumnOrder = table.metadata?.columnOrder + ? (() => { + const orderWithoutGroup = table.metadata!.columnOrder!.filter( + (id) => !groupColIdsBefore.has(id) + ) + const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) => + groupColIdsBefore.has(id) + ) + const orderAnchor = + firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx + return [ + ...orderWithoutGroup.slice(0, orderAnchor), + ...orderedGroupColIds, + ...orderWithoutGroup.slice(orderAnchor), + ] + })() + : undefined + + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` + ) + + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + return { updatedTable, newOutput } + }) + + // Backfill from saved execution logs — same flow `updateWorkflowGroup` + // uses for added outputs. Reads each row's saved trace spans for the + // group's executionId and writes the new output's value back. Existing + // rows that have hand-edited values are left alone (overwrite: false). + // Cheap compared to re-running the workflow on every row, which is what + // an earlier version of this code did — that mistakenly fanned out N + // workflow-group-cell jobs and burned compute the user didn't ask for. + // Small tables backfill inline; large ones run as a background job. + // Lazy import: backfill-runner closes a cycle back to this module. + try { + const { maybeBackfillGroupOutputs } = await import('@/lib/table/backfill-runner') + await maybeBackfillGroupOutputs({ + table: updatedTable, + groupId: data.groupId, + outputs: [newOutput], + overwrite: false, + requestId, + actorUserId: data.actorUserId, + }) + } catch (err) { + logger.warn( + `[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId} after adding output "${newOutput.columnName}":`, + err + ) + } + + return updatedTable +} + +/** + * Removes a single output from a workflow group. Drops the bound column and + * strips the value from every row's `data` JSONB. If the output is the + * group's last, the empty group is left in place — drop it explicitly with + * `deleteWorkflowGroup` if needed. + */ +export async function deleteWorkflowGroupOutput( + data: { tableId: string; groupId: string; columnName: string }, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new Error(`Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + // `data.columnName` may be a column id (first-party) or display name + // (mothership/legacy); resolve to the stable id used everywhere below. + const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName)) + const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName + if (!group.outputs.some((o) => o.columnName === columnId)) { + throw new Error( + `Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"` + ) + } + + const updatedGroup: WorkflowGroup = { + ...group, + outputs: group.outputs.filter((o) => o.columnName !== columnId), + } + const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId) + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } + + const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId) + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${columnId}::text WHERE table_id = ${data.tableId} AND data ? ${columnId}::text` + ) + + logger.info( + `[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } + }) +} + +/** + * Removes a workflow group plus all its output columns. Also strips the + * group's `executions[groupId]` entry from every row. + */ +export async function deleteWorkflowGroup( + data: DeleteWorkflowGroupData, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const group = groups.find((g) => g.id === data.groupId) + if (!group) { + throw new Error(`Workflow group "${data.groupId}" not found`) + } + + const removedColumnIds = new Set(group.outputs.map((o) => o.columnName)) + // Removed group's output columns may be referenced as deps by sibling groups. + // Strip those refs so we don't leave dangling-column deps behind. + const nextGroups = groups + .filter((g) => g.id !== data.groupId) + .map((g) => stripGroupDeps(g, removedColumnIds)) + const updatedSchema: TableSchema = { + ...schema, + columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))), + workflowGroups: nextGroups, + } + const updatedColumnOrder = table.metadata?.columnOrder?.filter( + (id) => !removedColumnIds.has(id) + ) + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + for (const id of removedColumnIds) { + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text` + ) + } + await stripGroupExecutions(trx, data.tableId, [data.groupId]) + + logger.info( + `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` + ) + + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }) +} diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index f0fb7a606a6..b5460c8440a 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -1,6 +1,6 @@ 'use server' -import type { Logger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { @@ -25,6 +25,8 @@ import { import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' +const logger = createLogger('FileUtilsServer') + /** * Result type for file input resolution */ @@ -138,19 +140,62 @@ export async function resolveFileInputToUrl( } /** - * Download a file from a URL (internal or external) - * For internal URLs, uses direct storage access (server-side only) - * For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning + * Options for {@link downloadFileFromUrl}. + */ +export interface DownloadFileFromUrlOptions { + /** Download timeout for external URLs. Defaults to the max execution timeout. */ + timeoutMs?: number + /** Hard cap on the number of bytes read from the source. */ + maxBytes?: number + /** + * Principal the download is performed on behalf of. Required to authorize + * internal (`/api/files/serve/...`) URLs: the resolved storage key is checked + * with {@link verifyFileAccess} before any bytes are read. Without it, internal + * URLs are rejected (fail closed) so a `/api/files/serve/` substring can never + * be treated as implicitly trusted. + */ + userId?: string +} + +/** + * Download a file from a URL (internal or external). + * + * For internal URLs, uses direct storage access (server-side only) after + * authorizing the resolved storage key against `userId`. Context is derived + * from the key via {@link inferContextFromKey}, never from a caller-controlled + * `?context=` query param — trusting the param would let a private key be + * labeled with a world-readable context (e.g. profile-pictures) so + * {@link verifyFileAccess} short-circuits to granted while the private object is + * still read. This mirrors how `/api/files/serve` resolves context. + * + * For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning. */ export async function downloadFileFromUrl( fileUrl: string, - timeoutMs = getMaxExecutionTimeout(), - maxBytes?: number + options: DownloadFileFromUrlOptions = {} ): Promise { - const { parseInternalFileUrl } = await import('./file-utils') + const { timeoutMs = getMaxExecutionTimeout(), maxBytes, userId } = options if (isInternalFileUrl(fileUrl)) { - const { key, context } = parseInternalFileUrl(fileUrl) + if (!userId) { + logger.warn('Internal file download denied: no userId provided', { fileUrl }) + throw new Error('Access denied: internal file URL requires an authenticated user') + } + + const key = extractStorageKey(fileUrl) + if (!key) { + logger.warn('Internal file download denied: could not resolve storage key', { fileUrl }) + throw new Error('Access denied: could not resolve internal file key') + } + + const context = inferContextFromKey(key) + + const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) + if (!hasAccess) { + logger.warn('Internal file download denied: access check failed', { key, context, userId }) + throw new Error('Access denied: file not found or insufficient permissions') + } + const { downloadFile } = await import('@/lib/uploads/core/storage-service') return downloadFile({ key, context, maxBytes }) } diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index f5e3c45ebde..63793e0e144 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -2,7 +2,42 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils' +import { isAbortError, isInternalFileUrl, isNetworkError } from '@/lib/uploads/utils/file-utils' + +describe('isInternalFileUrl', () => { + it('classifies relative serve paths as internal', () => { + expect(isInternalFileUrl('/api/files/serve/kb/123-file.pdf')).toBe(true) + expect(isInternalFileUrl('/api/files/serve/workspace/ws-1/file.txt?context=workspace')).toBe( + true + ) + }) + + it('classifies absolute serve URLs as internal regardless of host', () => { + expect(isInternalFileUrl('https://www.sim.ai/api/files/serve/kb/x.pdf')).toBe(true) + expect(isInternalFileUrl('http://localhost:3000/api/files/serve/blob/kb/x')).toBe(true) + // Host is not used to gate (self-hosted/multi-domain); the storage sink authorizes. + expect(isInternalFileUrl('https://other-host/api/files/serve/workspace/v/x')).toBe(true) + }) + + it('does not match the marker outside the path (query/fragment)', () => { + expect(isInternalFileUrl('https://evil.com/x?next=/api/files/serve/secret')).toBe(false) + expect(isInternalFileUrl('https://evil.com/page#/api/files/serve/secret')).toBe(false) + expect(isInternalFileUrl('https://evil.com/redirect?u=/api/files/serve/kb/x')).toBe(false) + }) + + it('preserves traversal sequences so they survive downstream rejection', () => { + // Must stay internal (not normalized away) so the parse route applies its `..` check. + expect(isInternalFileUrl('https://attacker.com/api/files/serve/../../../etc/passwd')).toBe(true) + expect(isInternalFileUrl('/api/files/serve/../../app.js')).toBe(true) + }) + + it('returns false for non-internal and non-string inputs', () => { + expect(isInternalFileUrl('https://example.com/file.pdf')).toBe(false) + expect(isInternalFileUrl('data:text/plain;base64,abc')).toBe(false) + // @ts-expect-error verifying runtime guard + expect(isInternalFileUrl(undefined)).toBe(false) + }) +}) describe('isAbortError', () => { it('returns true for AbortError-named errors', () => { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index d11fdf1b70f..e99d83c0564 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -534,10 +534,33 @@ export function extractStorageKey(filePath: string): string { } /** - * Check if a URL is an internal file serve URL + * Whether a URL targets the internal file-serve endpoint (`/api/files/serve/`). + * + * The marker is matched only in the URL's path component, so it cannot be + * smuggled through a query string or fragment (e.g. + * `https://evil.com/x?next=/api/files/serve/...`) to skip DNS/SSRF validation. + * + * The raw path is inspected without URL normalization on purpose: callers such + * as the files parse route rely on traversal sequences (`..`) surviving this + * check so they are rejected downstream rather than collapsed away. A path-only + * marker still classifies any host as internal (e.g. + * `https://other-host/api/files/serve/`); cross-tenant reads are prevented + * at the storage sink by {@link verifyFileAccess}, not by host matching, which + * would break self-hosted and multi-domain deployments. */ export function isInternalFileUrl(fileUrl: string): boolean { - return fileUrl.includes('/api/files/serve/') + if (typeof fileUrl !== 'string') { + return false + } + + let path = fileUrl + const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(path) + if (scheme) { + path = path.slice(scheme[0].length) + } + path = path.split(/[?#]/, 1)[0] + + return path.startsWith('/api/files/serve/') } /** diff --git a/apps/sim/lib/webhooks/constants.ts b/apps/sim/lib/webhooks/constants.ts new file mode 100644 index 00000000000..bdd4556c590 --- /dev/null +++ b/apps/sim/lib/webhooks/constants.ts @@ -0,0 +1,12 @@ +import { env } from '@/lib/core/config/env' + +/** + * Maximum size of a webhook request body read into memory. The webhook receivers + * are public and unauthenticated, so the body must be bounded before it is + * buffered to prevent a memory-exhaustion DoS. Provider payloads rarely exceed a + * few MB; defaults to 10 MB and is overridable via `WEBHOOK_MAX_REQUEST_BYTES`. + * + * Shared by every public webhook receiver so the cap is a single source of truth. + */ +export const WEBHOOK_MAX_BODY_BYTES = + Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 381cc6108a0..60f26d86c33 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -4,9 +4,9 @@ import { createMockRequest, + envFlagsMock, executionPreprocessingMock, executionPreprocessingMockFns, - featureFlagsMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -67,7 +67,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ shouldExecuteInline: mockShouldExecuteInline, })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@sim/security/compare', () => ({ safeCompare: vi.fn().mockReturnValue(true), diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 3352048b94f..e261a9a77d4 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -9,9 +9,15 @@ import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing/core/subscri import { tryAdmit } from '@/lib/core/admission/gate' import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' -import { isProd } from '@/lib/core/config/feature-flags' +import { isProd } from '@/lib/core/config/env-flags' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { preprocessExecution } from '@/lib/execution/preprocessing' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' import { getPendingWebhookVerification, matchesPendingWebhookVerificationProbe, @@ -71,19 +77,33 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ return { valid: true } } +const WEBHOOK_BODY_LABEL = 'Webhook request body' + export async function parseWebhookBody( request: NextRequest, requestId: string ): Promise<{ body: unknown; rawBody: string } | NextResponse> { let rawBody: string | null = null try { - const requestClone = request.clone() - rawBody = await requestClone.text() + assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL) + + const buffer = await readStreamToBufferWithLimit(request.clone().body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: WEBHOOK_BODY_LABEL, + }) + rawBody = new TextDecoder().decode(buffer) if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } } } catch (bodyError) { + if (isPayloadSizeLimitError(bodyError)) { + logger.warn(`[${requestId}] Rejected oversized webhook body`, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + observedBytes: bodyError.observedBytes, + }) + return new NextResponse('Request body too large', { status: 413 }) + } logger.error(`[${requestId}] Failed to read request body`, { error: toError(bodyError).message, }) diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index 468e621f106..6dcb0b9fe4a 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -615,7 +615,7 @@ async function pruneWorkflowGroupOutputsIfStillActive(params: { if (!versionRow) return - const { pruneStaleWorkflowGroupOutputs } = await import('@/lib/table/service') + const { pruneStaleWorkflowGroupOutputs } = await import('@/lib/table/workflow-groups/service') await pruneStaleWorkflowGroupOutputs({ workflowId: params.workflowId, workspaceId: params.workspaceId, diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index fb8de2121a8..b49d2730df5 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -1,5 +1,5 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import type { SubBlockConfig } from '@/blocks/types' export type CanonicalMode = 'basic' | 'advanced' diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 9c75eda7218..0828cff4288 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -58,7 +58,7 @@ vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 26cd530ea9e..641dd15e9af 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -8,7 +8,7 @@ import { getUserOrganization } from '@/lib/billing/organizations/membership' import type { PlanCategory } from '@/lib/billing/plan-helpers' import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' const logger = createLogger('WorkspacePolicy') diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 204ada466ee..eb565fe020c 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,6 +1,6 @@ import type { NextConfig } from 'next' import { env, isTruthy } from './lib/core/config/env' -import { isDev } from './lib/core/config/feature-flags' +import { isDev } from './lib/core/config/env-flags' import { getChatEmbedCSPPolicy, getMainCSPPolicy, @@ -93,17 +93,6 @@ const nextConfig: NextConfig = { './lib/execution/sandbox/bundles/*.cjs', ], }, - turbopack: { - resolveAlias: { - // `dns/promises` has no browser shim. Server-only connector fetch logic - // (which imports `input-validation.server`) is statically reachable from - // the client bundle via the connector registry, but never runs there. - // Stub it for the browser only; the server keeps the real module so SSRF - // validation is unaffected. - 'dns/promises': { browser: './lib/core/security/empty-node-fallback.browser.ts' }, - dns: { browser: './lib/core/security/empty-node-fallback.browser.ts' }, - }, - }, experimental: { optimizeCss: true, preloadEntriesOnStart: false, diff --git a/apps/sim/package.json b/apps/sim/package.json index 6c41645d9d9..9d8cd3f42d4 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -148,7 +148,7 @@ "isolated-vm": "6.0.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "json5": "2.2.3", "jszip": "3.10.1", "jwt-decode": "^4.0.0", @@ -165,7 +165,7 @@ "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", - "nodemailer": "8.0.7", + "nodemailer": "8.0.9", "officeparser": "^5.2.0", "openai": "^4.91.1", "papaparse": "5.5.3", diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts new file mode 100644 index 00000000000..b5254f9eaf8 --- /dev/null +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { + mockAnthropic, + anthropicArgs, + mockValidate, + mockCreatePinnedFetch, + mockExecuteAnthropic, + sentinelFetch, + envState, +} = vi.hoisted(() => { + const anthropicArgs: Array> = [] + const sentinelFetch = vi.fn() + class MockAnthropic { + constructor(opts: Record) { + anthropicArgs.push(opts) + } + } + return { + mockAnthropic: MockAnthropic, + anthropicArgs, + mockValidate: vi.fn(), + mockCreatePinnedFetch: vi.fn(() => sentinelFetch), + mockExecuteAnthropic: vi.fn(), + sentinelFetch, + envState: { + AZURE_ANTHROPIC_ENDPOINT: undefined as string | undefined, + AZURE_ANTHROPIC_API_VERSION: undefined as string | undefined, + }, + } +}) + +vi.mock('@anthropic-ai/sdk', () => ({ default: mockAnthropic })) +vi.mock('@/lib/core/config/env', () => ({ env: envState })) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateUrlWithDNS: mockValidate, + createPinnedFetch: mockCreatePinnedFetch, +})) +vi.mock('@/providers/anthropic/core', () => ({ + executeAnthropicProviderRequest: mockExecuteAnthropic, +})) +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => []), + getProviderDefaultModel: vi.fn(() => 'azure-anthropic/claude'), +})) + +import { azureAnthropicProvider } from '@/providers/azure-anthropic/index' + +function request(overrides: Partial): ProviderRequest { + return { model: 'azure-anthropic/claude-3-5-sonnet', apiKey: 'k', messages: [], ...overrides } +} + +/** Invokes the createClient factory handed to the Anthropic core and returns the SDK options it built. */ +function buildClientOptions(): Record { + const config = mockExecuteAnthropic.mock.calls[0][1] + config.createClient('k', false) + return anthropicArgs[0] +} + +describe('azureAnthropicProvider — SSRF pinning', () => { + beforeEach(() => { + vi.clearAllMocks() + anthropicArgs.length = 0 + envState.AZURE_ANTHROPIC_ENDPOINT = undefined + envState.AZURE_ANTHROPIC_API_VERSION = undefined + mockExecuteAnthropic.mockResolvedValue({ content: 'ok' }) + }) + + it('validates and pins the connection to the resolved IP for a user-supplied endpoint', async () => { + mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + + await azureAnthropicProvider.executeRequest( + request({ azureEndpoint: 'https://rebind.attacker.tld' }) + ) + + expect(mockValidate).toHaveBeenCalledWith('https://rebind.attacker.tld', 'azureEndpoint') + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(buildClientOptions()).toMatchObject({ fetch: sentinelFetch }) + }) + + it('does not pin when the endpoint comes from trusted server env', async () => { + envState.AZURE_ANTHROPIC_ENDPOINT = 'https://trusted.services.ai.azure.com' + + await azureAnthropicProvider.executeRequest(request({ azureEndpoint: undefined })) + + expect(mockValidate).not.toHaveBeenCalled() + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(buildClientOptions()).not.toHaveProperty('fetch') + }) + + it('throws and never builds a client when validation blocks the endpoint', async () => { + mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) + + await expect( + azureAnthropicProvider.executeRequest( + request({ azureEndpoint: 'https://rebind.attacker.tld' }) + ) + ).rejects.toThrow('Invalid Azure Anthropic endpoint') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(mockExecuteAnthropic).not.toHaveBeenCalled() + }) + + it('fails closed when validation passes but yields no resolvable IP to pin', async () => { + mockValidate.mockResolvedValue({ isValid: true }) + + await expect( + azureAnthropicProvider.executeRequest( + request({ azureEndpoint: 'https://rebind.attacker.tld' }) + ) + ).rejects.toThrow('could not resolve a pinnable IP address') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(mockExecuteAnthropic).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index 999dc0938f8..39980d77c2e 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -1,7 +1,7 @@ import Anthropic from '@anthropic-ai/sdk' import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' -import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import type { StreamingExecution } from '@/executor/types' import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' @@ -28,6 +28,7 @@ export const azureAnthropicProvider: ProviderConfig = { ) } + let pinnedFetch: typeof fetch | undefined if (userProvidedEndpoint) { const validation = await validateUrlWithDNS(userProvidedEndpoint, 'azureEndpoint') if (!validation.isValid) { @@ -37,6 +38,10 @@ export const azureAnthropicProvider: ProviderConfig = { }) throw new Error(`Invalid Azure Anthropic endpoint: ${validation.error}`) } + if (!validation.resolvedIP) { + throw new Error('Invalid Azure Anthropic endpoint: could not resolve a pinnable IP address') + } + pinnedFetch = createPinnedFetch(validation.resolvedIP) } const apiKey = request.apiKey @@ -67,6 +72,7 @@ export const azureAnthropicProvider: ProviderConfig = { new Anthropic({ baseURL, apiKey, + ...(pinnedFetch ? { fetch: pinnedFetch } : {}), defaultHeaders: { 'api-key': apiKey, 'anthropic-version': anthropicVersion, diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts new file mode 100644 index 00000000000..7e18ea809df --- /dev/null +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { + mockAzureOpenAI, + azureOpenAIArgs, + mockChatCreate, + mockValidate, + mockCreatePinnedFetch, + mockExecuteResponses, + sentinelFetch, + mockIsChatCompletionsEndpoint, + mockIsResponsesEndpoint, + envState, +} = vi.hoisted(() => { + const azureOpenAIArgs: Array> = [] + const sentinelFetch = vi.fn() + const mockChatCreate = vi.fn() + class MockAzureOpenAI { + chat = { completions: { create: mockChatCreate } } + constructor(opts: Record) { + azureOpenAIArgs.push(opts) + } + } + return { + mockAzureOpenAI: MockAzureOpenAI, + azureOpenAIArgs, + mockChatCreate, + mockValidate: vi.fn(), + mockCreatePinnedFetch: vi.fn(() => sentinelFetch), + mockExecuteResponses: vi.fn(), + sentinelFetch, + mockIsChatCompletionsEndpoint: vi.fn(() => false), + mockIsResponsesEndpoint: vi.fn(() => false), + envState: { + AZURE_OPENAI_ENDPOINT: undefined as string | undefined, + AZURE_OPENAI_API_VERSION: undefined as string | undefined, + }, + } +}) + +vi.mock('openai', () => ({ AzureOpenAI: mockAzureOpenAI })) +vi.mock('@/lib/core/config/env', () => ({ env: envState })) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateUrlWithDNS: mockValidate, + createPinnedFetch: mockCreatePinnedFetch, +})) +vi.mock('@/providers/openai/core', () => ({ + executeResponsesProviderRequest: mockExecuteResponses, +})) +vi.mock('@/providers/azure-openai/utils', () => ({ + isChatCompletionsEndpoint: mockIsChatCompletionsEndpoint, + isResponsesEndpoint: mockIsResponsesEndpoint, + extractBaseUrl: vi.fn((url: string) => url), + extractDeploymentFromUrl: vi.fn(() => null), + extractApiVersionFromUrl: vi.fn(() => null), + createReadableStreamFromAzureOpenAIStream: vi.fn(), + checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => []), + getProviderDefaultModel: vi.fn(() => 'azure/gpt-4o'), +})) +vi.mock('@/providers/attachments', () => ({ + prepareProviderAttachments: vi.fn(() => []), +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + })), + sumToolCosts: vi.fn(() => 0), +})) +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +import { azureOpenAIProvider } from '@/providers/azure-openai/index' + +function request(overrides: Partial): ProviderRequest { + return { model: 'azure/gpt-4o', apiKey: 'k', messages: [], ...overrides } +} + +/** Config object passed to the Responses core on the Nth call. */ +const responsesConfig = (call = 0) => mockExecuteResponses.mock.calls[call][1] + +describe('azureOpenAIProvider — SSRF pinning', () => { + beforeEach(() => { + vi.clearAllMocks() + azureOpenAIArgs.length = 0 + envState.AZURE_OPENAI_ENDPOINT = undefined + envState.AZURE_OPENAI_API_VERSION = undefined + mockIsChatCompletionsEndpoint.mockReturnValue(false) + mockIsResponsesEndpoint.mockReturnValue(false) + mockExecuteResponses.mockResolvedValue({ content: 'ok' }) + }) + + describe('Responses API path', () => { + it('validates and threads the pinned fetch into the Responses core for a user endpoint', async () => { + mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + + await azureOpenAIProvider.executeRequest( + request({ azureEndpoint: 'https://rebind.attacker.tld' }) + ) + + expect(mockValidate).toHaveBeenCalledWith('https://rebind.attacker.tld', 'azureEndpoint') + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(responsesConfig().fetch).toBe(sentinelFetch) + }) + + it('passes no custom fetch when the endpoint comes from trusted server env', async () => { + envState.AZURE_OPENAI_ENDPOINT = 'https://trusted.openai.azure.com' + + await azureOpenAIProvider.executeRequest(request({ azureEndpoint: undefined })) + + expect(mockValidate).not.toHaveBeenCalled() + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(responsesConfig().fetch).toBeUndefined() + }) + + it('throws and never reaches the Responses core when validation blocks the endpoint', async () => { + mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) + + await expect( + azureOpenAIProvider.executeRequest( + request({ azureEndpoint: 'https://rebind.attacker.tld' }) + ) + ).rejects.toThrow('Invalid Azure OpenAI endpoint') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(mockExecuteResponses).not.toHaveBeenCalled() + }) + + it('fails closed when validation passes but yields no resolvable IP to pin', async () => { + mockValidate.mockResolvedValue({ isValid: true }) + + await expect( + azureOpenAIProvider.executeRequest( + request({ azureEndpoint: 'https://rebind.attacker.tld' }) + ) + ).rejects.toThrow('could not resolve a pinnable IP address') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(mockExecuteResponses).not.toHaveBeenCalled() + }) + }) + + describe('Chat Completions path', () => { + it('constructs the AzureOpenAI client with the pinned fetch for a user endpoint', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + mockChatCreate.mockResolvedValue({ + choices: [{ message: { content: 'hi', tool_calls: undefined } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + + await azureOpenAIProvider.executeRequest( + request({ + azureEndpoint: 'https://rebind.attacker.tld/openai/deployments/gpt-4o/chat/completions', + }) + ) + + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(azureOpenAIArgs[0]).toMatchObject({ fetch: sentinelFetch }) + }) + + it('constructs the AzureOpenAI client without a custom fetch for a trusted env endpoint', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + envState.AZURE_OPENAI_ENDPOINT = + 'https://trusted.openai.azure.com/openai/deployments/gpt-4o/chat/completions' + mockChatCreate.mockResolvedValue({ + choices: [{ message: { content: 'hi', tool_calls: undefined } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + + await azureOpenAIProvider.executeRequest(request({ azureEndpoint: undefined })) + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch') + }) + }) +}) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index ddae32b7fb4..24d07184282 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -12,7 +12,7 @@ import type { } from 'openai/resources/chat/completions' import type { ReasoningEffort } from 'openai/resources/shared' import { env } from '@/lib/core/config/env' -import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { prepareProviderAttachments } from '@/providers/attachments' @@ -56,7 +56,8 @@ async function executeChatCompletionsRequest( request: ProviderRequest, azureEndpoint: string, azureApiVersion: string, - deploymentName: string + deploymentName: string, + pinnedFetch?: typeof fetch ): Promise { logger.info('Using Azure OpenAI Chat Completions API', { model: request.model, @@ -75,6 +76,7 @@ async function executeChatCompletionsRequest( apiKey: request.apiKey!, apiVersion: azureApiVersion, endpoint: azureEndpoint, + ...(pinnedFetch ? { fetch: pinnedFetch } : {}), }) const allMessages: ChatCompletionMessageParam[] = [] @@ -606,6 +608,7 @@ export const azureOpenAIProvider: ProviderConfig = { ) } + let pinnedFetch: typeof fetch | undefined if (userProvidedEndpoint) { const validation = await validateUrlWithDNS(userProvidedEndpoint, 'azureEndpoint') if (!validation.isValid) { @@ -615,6 +618,10 @@ export const azureOpenAIProvider: ProviderConfig = { }) throw new Error(`Invalid Azure OpenAI endpoint: ${validation.error}`) } + if (!validation.resolvedIP) { + throw new Error('Invalid Azure OpenAI endpoint: could not resolve a pinnable IP address') + } + pinnedFetch = createPinnedFetch(validation.resolvedIP) } const apiKey = request.apiKey @@ -652,7 +659,8 @@ export const azureOpenAIProvider: ProviderConfig = { { ...request, apiKey }, baseUrl, azureApiVersion, - deploymentName + deploymentName, + pinnedFetch ) } @@ -676,6 +684,7 @@ export const azureOpenAIProvider: ProviderConfig = { 'api-key': apiKey, }, logger, + fetch: pinnedFetch, } ) } @@ -700,6 +709,7 @@ export const azureOpenAIProvider: ProviderConfig = { 'api-key': apiKey, }, logger, + fetch: pinnedFetch, } ) }, diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 19d0a41ac84..73b62a79abc 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -18,7 +18,7 @@ vi.mock('@/providers/registry', () => ({ }), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: vi.fn(() => 1), })) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 19dcb5fda6b..26433940e33 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/feature-flags' +import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { StreamingExecution } from '@/executor/types' import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index c0fa50def86..913700ef5d7 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -41,6 +41,12 @@ export interface ResponsesProviderConfig { endpoint: string headers: Record logger: Logger + /** + * Optional fetch implementation. Used to pin the connection to a pre-validated + * IP (DNS-rebinding/SSRF protection) when the endpoint is user-supplied. + * Defaults to the global fetch. + */ + fetch?: typeof fetch } /** @@ -51,6 +57,7 @@ export async function executeResponsesProviderRequest( config: ResponsesProviderConfig ): Promise { const { logger } = config + const fetchImpl = config.fetch ?? fetch logger.info(`Preparing ${config.providerLabel} request`, { model: request.model, @@ -207,7 +214,7 @@ export async function executeResponsesProviderRequest( const postResponses = async ( body: Record ): Promise => { - const response = await fetch(config.endpoint, { + const response = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(body), @@ -229,7 +236,7 @@ export async function executeResponsesProviderRequest( if (request.stream && (!tools || tools.length === 0)) { logger.info(`Using streaming response for ${config.providerLabel} request`) - const streamResponse = await fetch(config.endpoint, { + const streamResponse = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(createRequestBody(initialInput, { stream: true })), @@ -643,7 +650,7 @@ export async function executeResponsesProviderRequest( } } - const streamResponse = await fetch(config.endpoint, { + const streamResponse = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(createRequestBody(currentInput, streamOverrides)), diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 46d414cbb66..9ed017b581b 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as environmentModule from '@/lib/core/config/feature-flags' +import * as environmentModule from '@/lib/core/config/env-flags' import { calculateCost, extractAndParseJSON, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index c584261b4ec..2c22c865e4a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -5,7 +5,7 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' -import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/feature-flags' +import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/env-flags' import { normalizeRecord, normalizeStringRecord, diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 829c48168f1..c95f5297f1e 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -5,31 +5,50 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreate, + openAIArgs, + mockOpenAI, mockExecuteTool, mockPrepareTools, mockCheckForced, mockCreateStream, + mockValidateUrlWithDNS, + mockCreatePinnedFetch, + pinnedFetchFn, envState, -} = vi.hoisted(() => ({ - mockCreate: vi.fn(), - mockExecuteTool: vi.fn(), - mockPrepareTools: vi.fn(), - mockCheckForced: vi.fn(), - mockCreateStream: vi.fn(), - envState: { - VLLM_BASE_URL: 'http://localhost:8000', - VLLM_API_KEY: undefined as string | undefined, - }, -})) - -vi.mock('openai', () => ({ - default: vi.fn().mockImplementation( - class { - chat = { completions: { create: mockCreate } } +} = vi.hoisted(() => { + const openAIArgs: Array> = [] + const mockCreate = vi.fn() + const pinnedFetchFn = vi.fn() + class MockOpenAI { + chat = { completions: { create: mockCreate } } + constructor(opts: Record) { + openAIArgs.push(opts) } - ), -})) + } + return { + mockCreate, + openAIArgs, + mockOpenAI: MockOpenAI, + mockExecuteTool: vi.fn(), + mockPrepareTools: vi.fn(), + mockCheckForced: vi.fn(), + mockCreateStream: vi.fn(), + mockValidateUrlWithDNS: vi.fn(), + mockCreatePinnedFetch: vi.fn(() => pinnedFetchFn), + pinnedFetchFn, + envState: { + VLLM_BASE_URL: 'http://localhost:8000', + VLLM_API_KEY: undefined as string | undefined, + }, + } +}) + +vi.mock('openai', () => ({ default: mockOpenAI })) vi.mock('@/lib/core/config/env', () => ({ env: envState })) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateUrlWithDNS: mockValidateUrlWithDNS, + createPinnedFetch: mockCreatePinnedFetch, +})) vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) vi.mock('@/providers/models', () => ({ getProviderModels: vi.fn(() => []), @@ -94,6 +113,7 @@ const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0] describe('vllmProvider', () => { beforeEach(() => { vi.clearAllMocks() + openAIArgs.length = 0 envState.VLLM_BASE_URL = 'http://localhost:8000' envState.VLLM_API_KEY = undefined mockPrepareTools.mockReturnValue({ @@ -105,6 +125,78 @@ describe('vllmProvider', () => { mockCheckForced.mockReturnValue({ hasUsedForcedTool: false, usedForcedTools: [] }) mockCreateStream.mockReturnValue(new ReadableStream({ start: (c) => c.close() })) mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + mockCreatePinnedFetch.mockReturnValue(pinnedFetchFn) + }) + + describe('endpoint SSRF protection', () => { + it('does not validate or pin when no endpoint is supplied (uses env base URL)', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('hi')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + }) + + expect(mockValidateUrlWithDNS).not.toHaveBeenCalled() + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(openAIArgs[0].baseURL).toBe('http://localhost:8000/v1') + expect(openAIArgs[0].fetch).toBeUndefined() + }) + + it('validates a user-supplied endpoint and pins the connection to the resolved IP', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('hi')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + azureEndpoint: 'https://my-vllm.example.com', + }) + + expect(mockValidateUrlWithDNS).toHaveBeenCalledWith( + 'https://my-vllm.example.com', + 'vLLM endpoint', + { allowHttp: true } + ) + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(openAIArgs[0].baseURL).toBe('https://my-vllm.example.com/v1') + expect(openAIArgs[0].fetch).toBe(pinnedFetchFn) + }) + + it('rejects a user-supplied endpoint that fails SSRF validation without issuing a request', async () => { + mockValidateUrlWithDNS.mockResolvedValueOnce({ + isValid: false, + error: 'vLLM endpoint resolves to a blocked IP address', + }) + + await expect( + vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + azureEndpoint: 'http://169.254.169.254', + }) + ).rejects.toThrow('Invalid vLLM endpoint') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(openAIArgs).toHaveLength(0) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('rejects a validated endpoint that did not resolve to a pinnable IP', async () => { + mockValidateUrlWithDNS.mockResolvedValueOnce({ isValid: true }) + + await expect( + vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + azureEndpoint: 'https://my-vllm.example.com', + }) + ).rejects.toThrow('could not resolve a pinnable IP address') + + expect(mockCreatePinnedFetch).not.toHaveBeenCalled() + expect(openAIArgs).toHaveLength(0) + expect(mockCreate).not.toHaveBeenCalled() + }) }) it('builds a chat payload with the vllm/ prefix stripped and messages assembled in order', async () => { diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 7251ec1c31a..572b5df51ca 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -3,6 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' +import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -95,15 +96,46 @@ export const vllmProvider: ProviderConfig = { stream: !!request.stream, }) - const baseUrl = (request.azureEndpoint || env.VLLM_BASE_URL || '').replace(/\/$/, '') + const userProvidedEndpoint = request.azureEndpoint + + const baseUrl = (userProvidedEndpoint || env.VLLM_BASE_URL || '').replace(/\/$/, '') if (!baseUrl) { throw new Error('VLLM_BASE_URL is required for vLLM provider') } + /** + * A user-supplied endpoint is attacker-controlled: validate it against the + * central SSRF guard and pin the connection to the resolved IP to defeat DNS + * rebinding. The operator-configured `VLLM_BASE_URL` is trusted and left + * unvalidated, mirroring the Azure providers. + * + * `allowHttp` is enabled because self-hosted vLLM is frequently served over + * plain HTTP; this only relaxes the protocol requirement — the private/reserved + * IP blocklist and blocked-port checks still apply, so SSRF protection is intact. + */ + let pinnedFetch: typeof fetch | undefined + if (userProvidedEndpoint) { + const validation = await validateUrlWithDNS(userProvidedEndpoint, 'vLLM endpoint', { + allowHttp: true, + }) + if (!validation.isValid) { + logger.warn('Blocked SSRF attempt via vLLM endpoint', { + endpoint: userProvidedEndpoint, + error: validation.error, + }) + throw new Error(`Invalid vLLM endpoint: ${validation.error}`) + } + if (!validation.resolvedIP) { + throw new Error('Invalid vLLM endpoint: could not resolve a pinnable IP address') + } + pinnedFetch = createPinnedFetch(validation.resolvedIP) + } + const apiKey = request.apiKey || env.VLLM_API_KEY || 'empty' const vllm = new OpenAI({ apiKey, baseURL: `${baseUrl}/v1`, + ...(pinnedFetch ? { fetch: pinnedFetch } : {}), }) const allMessages: Message[] = [] diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 0bf9114699d..1d5e0581589 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -3,7 +3,7 @@ import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' import { sendToProfound } from './lib/analytics/profound' import { getEnv } from './lib/core/config/env' -import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags' +import { isAuthDisabled, isHosted } from './lib/core/config/env-flags' import { generateRuntimeCSP } from './lib/core/security/csp' import { getClientIp } from './lib/core/utils/request' diff --git a/apps/sim/scripts/process-docs.ts b/apps/sim/scripts/process-docs.ts index bef7938ccbb..1d8c5cf3554 100644 --- a/apps/sim/scripts/process-docs.ts +++ b/apps/sim/scripts/process-docs.ts @@ -6,7 +6,7 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sql } from 'drizzle-orm' import { type DocChunk, DocsChunker } from '@/lib/chunkers' -import { isDev } from '@/lib/core/config/feature-flags' +import { isDev } from '@/lib/core/config/env-flags' const logger = createLogger('ProcessDocs') diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 05012aee9f8..43192146ccd 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -31,6 +31,7 @@ export type ChatContext = | { kind: 'file'; fileId: string; label: string } | { kind: 'folder'; folderId: string; label: string } | { kind: 'filefolder'; fileFolderId: string; label: string } + | { kind: 'scheduledtask'; scheduleId: string; label: string } | { kind: 'docs'; label: string } | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } diff --git a/apps/sim/tools/agiloft/attachment_info.ts b/apps/sim/tools/agiloft/attachment_info.ts index 549bbd665a8..4a577ca90db 100644 --- a/apps/sim/tools/agiloft/attachment_info.ts +++ b/apps/sim/tools/agiloft/attachment_info.ts @@ -2,8 +2,6 @@ import type { AgiloftAttachmentInfoParams, AgiloftAttachmentInfoResponse, } from '@/tools/agiloft/types' -import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftAttachmentInfoTool: ToolConfig< @@ -61,57 +59,27 @@ export const agiloftAttachmentInfoTool: ToolConfig< }, request: { - url: 'https://placeholder.agiloft.com', - method: 'GET', - headers: () => ({}), + url: () => '/api/tools/agiloft/attachment_info', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + fieldName: params.fieldName, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildAttachmentInfoUrl(base, params), - method: 'GET', - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { attachments: [], totalCount: 0 }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - - const attachments: Array<{ position: number; name: string; size: number }> = [] - - if (Array.isArray(result)) { - for (let i = 0; i < result.length; i++) { - const item = result[i] as Record - attachments.push({ - position: (item.filePosition as number) ?? (item.position as number) ?? i, - name: - (item.fileName as string) ?? - (item.name as string) ?? - (item.filename as string) ?? - '', - size: (item.size as number) ?? (item.fileSize as number) ?? 0, - }) - } - } - - return { - success: data.success !== false, - output: { - attachments, - totalCount: attachments.length, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/create_record.ts b/apps/sim/tools/agiloft/create_record.ts index c9b852659b9..216008e354a 100644 --- a/apps/sim/tools/agiloft/create_record.ts +++ b/apps/sim/tools/agiloft/create_record.ts @@ -1,6 +1,4 @@ import type { AgiloftCreateRecordParams, AgiloftRecordResponse } from '@/tools/agiloft/types' -import { buildCreateRecordUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftCreateRecordTool: ToolConfig = @@ -51,54 +49,26 @@ export const agiloftCreateRecordTool: ToolConfig '/api/tools/agiloft/create_record', method: 'POST', - headers: () => ({}), + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + data: params.data, + }), }, - directExecution: async (params) => { - let body: string - try { - body = JSON.stringify(JSON.parse(params.data)) - } catch { - return { - success: false, - output: { id: null, fields: {} }, - error: 'Invalid JSON in data parameter', - } + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), } - - return executeAgiloftRequest( - params, - (base) => ({ - url: buildCreateRecordUrl(base, params), - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body, - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { id: null, fields: {} }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null - - return { - success: data.success !== false, - output: { - id: id != null ? String(id) : null, - fields: result ?? {}, - }, - } - } - ) }, outputs: { diff --git a/apps/sim/tools/agiloft/delete_record.ts b/apps/sim/tools/agiloft/delete_record.ts index 3a6744b4f1e..f0599da85ef 100644 --- a/apps/sim/tools/agiloft/delete_record.ts +++ b/apps/sim/tools/agiloft/delete_record.ts @@ -1,6 +1,4 @@ import type { AgiloftDeleteRecordParams, AgiloftDeleteResponse } from '@/tools/agiloft/types' -import { buildDeleteRecordUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftDeleteRecordTool: ToolConfig = @@ -50,38 +48,26 @@ export const agiloftDeleteRecordTool: ToolConfig ({}), + url: () => '/api/tools/agiloft/delete_record', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildDeleteRecordUrl(base, params), - method: 'DELETE', - headers: { Accept: 'application/json' }, - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { id: params.recordId?.trim() ?? '', deleted: false }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - return { - success: true, - output: { - id: params.recordId?.trim() ?? '', - deleted: true, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/get_choice_line_id.ts b/apps/sim/tools/agiloft/get_choice_line_id.ts index 596cd8898a9..d2568933123 100644 --- a/apps/sim/tools/agiloft/get_choice_line_id.ts +++ b/apps/sim/tools/agiloft/get_choice_line_id.ts @@ -2,8 +2,6 @@ import type { AgiloftGetChoiceLineIdParams, AgiloftGetChoiceLineIdResponse, } from '@/tools/agiloft/types' -import { buildGetChoiceLineIdUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftGetChoiceLineIdTool: ToolConfig< @@ -62,63 +60,27 @@ export const agiloftGetChoiceLineIdTool: ToolConfig< }, request: { - url: 'https://placeholder.agiloft.com', - method: 'GET', - headers: () => ({}), + url: () => '/api/tools/agiloft/get_choice_line_id', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + fieldName: params.fieldName, + value: params.value, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildGetChoiceLineIdUrl(base, params), - method: 'GET', - headers: { Accept: 'application/json' }, - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { choiceLineId: null }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = data.result ?? data - let choiceLineId: number | null = null - - if (typeof result === 'number') { - choiceLineId = result - } else if (typeof result === 'string') { - const parsed = Number(result) - choiceLineId = Number.isFinite(parsed) ? parsed : null - } else if (typeof result === 'object' && result !== null) { - const obj = result as Record - const idVal = obj.id ?? obj.choiceLineId ?? obj.lineId - if (typeof idVal === 'number') { - choiceLineId = idVal - } else if (typeof idVal === 'string') { - const parsed = Number(idVal) - choiceLineId = Number.isFinite(parsed) ? parsed : null - } - } - - if (choiceLineId === null) { - return { - success: false, - output: { choiceLineId: null }, - error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}"`, - } - } - - return { - success: data.success !== false, - output: { choiceLineId }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/lock_record.ts b/apps/sim/tools/agiloft/lock_record.ts index 4c46d9cb758..4497d88da69 100644 --- a/apps/sim/tools/agiloft/lock_record.ts +++ b/apps/sim/tools/agiloft/lock_record.ts @@ -1,6 +1,4 @@ import type { AgiloftLockRecordParams, AgiloftLockResponse } from '@/tools/agiloft/types' -import { buildLockRecordUrl, getLockHttpMethod } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftLockRecordTool: ToolConfig = { @@ -55,52 +53,27 @@ export const agiloftLockRecordTool: ToolConfig ({}), + url: () => '/api/tools/agiloft/lock_record', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + lockAction: params.lockAction, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildLockRecordUrl(base, params), - method: getLockHttpMethod(params.lockAction), - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { - id: params.recordId?.trim() ?? '', - lockStatus: 'UNKNOWN', - lockedBy: null, - lockExpiresInMinutes: null, - }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - - return { - success: data.success !== false, - output: { - id: String(result.id ?? params.recordId?.trim() ?? ''), - lockStatus: - (result.lock_status as string) ?? (result.lockStatus as string) ?? 'UNKNOWN', - lockedBy: - (result.locked_by as string | null) ?? (result.lockedBy as string | null) ?? null, - lockExpiresInMinutes: - (result.lock_expires_in_minutes as number | null) ?? - (result.lockExpiresInMinutes as number | null) ?? - null, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/read_record.ts b/apps/sim/tools/agiloft/read_record.ts index ce59238e1f4..dcb495c61dc 100644 --- a/apps/sim/tools/agiloft/read_record.ts +++ b/apps/sim/tools/agiloft/read_record.ts @@ -1,6 +1,4 @@ import type { AgiloftReadRecordParams, AgiloftRecordResponse } from '@/tools/agiloft/types' -import { buildReadRecordUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftReadRecordTool: ToolConfig = { @@ -55,42 +53,27 @@ export const agiloftReadRecordTool: ToolConfig ({}), + url: () => '/api/tools/agiloft/read_record', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + fields: params.fields, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildReadRecordUrl(base, params), - method: 'GET', - headers: { Accept: 'application/json' }, - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { id: null, fields: {} }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null - - return { - success: data.success !== false, - output: { - id: id != null ? String(id) : null, - fields: result ?? {}, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/remove_attachment.ts b/apps/sim/tools/agiloft/remove_attachment.ts index 7f90e8d6c87..8eabc5e476b 100644 --- a/apps/sim/tools/agiloft/remove_attachment.ts +++ b/apps/sim/tools/agiloft/remove_attachment.ts @@ -2,8 +2,6 @@ import type { AgiloftRemoveAttachmentParams, AgiloftRemoveAttachmentResponse, } from '@/tools/agiloft/types' -import { buildRemoveAttachmentUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftRemoveAttachmentTool: ToolConfig< @@ -67,53 +65,28 @@ export const agiloftRemoveAttachmentTool: ToolConfig< }, request: { - url: 'https://placeholder.agiloft.com', - method: 'DELETE', - headers: () => ({}), + url: () => '/api/tools/agiloft/remove_attachment', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + fieldName: params.fieldName, + position: params.position, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildRemoveAttachmentUrl(base, params), - method: 'DELETE', - }), - async (response) => { - const text = await response.text() - - if (!response.ok) { - return { - success: false, - output: { - recordId: params.recordId?.trim() ?? '', - fieldName: params.fieldName?.trim() ?? '', - remainingAttachments: 0, - }, - error: `Agiloft error: ${response.status} - ${text}`, - } - } - - let remainingAttachments = 0 - try { - const data = JSON.parse(text) - const result = data.result ?? data - remainingAttachments = - typeof result === 'number' ? result : (result.count ?? result.remaining ?? 0) - } catch { - remainingAttachments = Number(text) || 0 - } - - return { - success: true, - output: { - recordId: params.recordId?.trim() ?? '', - fieldName: params.fieldName?.trim() ?? '', - remainingAttachments, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/saved_search.ts b/apps/sim/tools/agiloft/saved_search.ts index 86232d88220..6199c18ef69 100644 --- a/apps/sim/tools/agiloft/saved_search.ts +++ b/apps/sim/tools/agiloft/saved_search.ts @@ -1,6 +1,4 @@ import type { AgiloftSavedSearchParams, AgiloftSavedSearchResponse } from '@/tools/agiloft/types' -import { buildSavedSearchUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftSavedSearchTool: ToolConfig< @@ -46,57 +44,25 @@ export const agiloftSavedSearchTool: ToolConfig< }, request: { - url: 'https://placeholder.agiloft.com', - method: 'GET', - headers: () => ({}), + url: () => '/api/tools/agiloft/saved_search', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildSavedSearchUrl(base, params), - method: 'GET', - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { searches: [] }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - - const searches: Array<{ - name: string - label: string - id: string | number - description: string | null - }> = [] - - if (Array.isArray(result)) { - for (const item of result as Record[]) { - searches.push({ - name: (item.name as string) ?? '', - label: (item.label as string) ?? (item.name as string) ?? '', - id: (item.id as string | number) ?? (item.ID as string | number) ?? '', - description: (item.description as string | null) ?? null, - }) - } - } - - return { - success: data.success !== false, - output: { - searches, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/search_records.ts b/apps/sim/tools/agiloft/search_records.ts index 352124787f9..8cbc759f3a4 100644 --- a/apps/sim/tools/agiloft/search_records.ts +++ b/apps/sim/tools/agiloft/search_records.ts @@ -1,6 +1,4 @@ import type { AgiloftSearchRecordsParams, AgiloftSearchResponse } from '@/tools/agiloft/types' -import { buildSearchRecordsUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftSearchRecordsTool: ToolConfig< @@ -71,82 +69,29 @@ export const agiloftSearchRecordsTool: ToolConfig< }, request: { - url: 'https://placeholder.agiloft.com', - method: 'GET', - headers: () => ({}), + url: () => '/api/tools/agiloft/search_records', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + query: params.query, + fields: params.fields, + page: params.page, + limit: params.limit, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildSearchRecordsUrl(base, params), - method: 'GET', - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { records: [], totalCount: 0, page: 0, limit: 25 }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const records: Record[] = [] - const result = (data.result ?? data) as Record - - if (Array.isArray(result)) { - for (const item of result as Record[]) { - records.push(item) - } - } else { - const lengthRaw = result.EWREST_length ?? data.EWREST_length - const count = typeof lengthRaw === 'string' ? Number(lengthRaw) : (lengthRaw as number) - if (typeof count === 'number' && Number.isFinite(count)) { - const source = (result.EWREST_length != null ? result : data) as Record - for (let i = 0; i < count; i++) { - const record: Record = {} - for (const key of Object.keys(source)) { - const match = key.match(/^EWREST_(.+)_(\d+)$/) - if (match && Number(match[2]) === i) { - record[match[1]] = source[key] - } - } - if (Object.keys(record).length > 0) { - records.push(record) - } - } - } - } - - const totalCountRaw = - result.totalCount ?? - result.total ?? - result.count ?? - result.EWREST_length ?? - data.totalCount ?? - data.total ?? - data.count ?? - data.EWREST_length ?? - records.length - const totalCount = - typeof totalCountRaw === 'string' ? Number(totalCountRaw) : (totalCountRaw as number) - const page = params.page ? Number(params.page) : 0 - const limit = params.limit ? Number(params.limit) : 25 - - return { - success: data.success !== false, - output: { - records, - totalCount, - page, - limit, - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/select_records.ts b/apps/sim/tools/agiloft/select_records.ts index 5878551d44b..72af1539720 100644 --- a/apps/sim/tools/agiloft/select_records.ts +++ b/apps/sim/tools/agiloft/select_records.ts @@ -1,6 +1,4 @@ import type { AgiloftSelectRecordsParams, AgiloftSelectResponse } from '@/tools/agiloft/types' -import { buildSelectRecordsUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftSelectRecordsTool: ToolConfig< @@ -53,69 +51,26 @@ export const agiloftSelectRecordsTool: ToolConfig< }, request: { - url: 'https://placeholder.agiloft.com', - method: 'GET', - headers: () => ({}), + url: () => '/api/tools/agiloft/select_records', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + where: params.where, + }), }, - directExecution: async (params) => { - return executeAgiloftRequest( - params, - (base) => ({ - url: buildSelectRecordsUrl(base, params), - method: 'GET', - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { recordIds: [], totalCount: 0 }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - const recordIds: string[] = [] - - if (Array.isArray(result)) { - for (const item of result as Record[]) { - const id = item.id ?? item.ID ?? item - recordIds.push(String(id)) - } - } else if (typeof result === 'object' && result !== null) { - let i = 0 - while (result[`id_${i}`] !== undefined || result[`EWREST_id_${i}`] !== undefined) { - const id = result[`id_${i}`] ?? result[`EWREST_id_${i}`] - recordIds.push(String(id)) - i++ - } - if (recordIds.length === 0 && result.id !== undefined) { - recordIds.push(String(result.id)) - } - } - - const totalCountRaw = - result.EWREST_id_length ?? - result.totalCount ?? - result.total ?? - result.count ?? - data.EWREST_id_length ?? - data.totalCount ?? - data.total ?? - data.count ?? - recordIds.length - - return { - success: data.success !== false, - output: { - recordIds, - totalCount: Number(totalCountRaw), - }, - } - } - ) + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), + } }, outputs: { diff --git a/apps/sim/tools/agiloft/update_record.ts b/apps/sim/tools/agiloft/update_record.ts index a264b272906..4b887e50fa3 100644 --- a/apps/sim/tools/agiloft/update_record.ts +++ b/apps/sim/tools/agiloft/update_record.ts @@ -1,6 +1,4 @@ import type { AgiloftRecordResponse, AgiloftUpdateRecordParams } from '@/tools/agiloft/types' -import { buildUpdateRecordUrl } from '@/tools/agiloft/utils' -import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftUpdateRecordTool: ToolConfig = @@ -57,54 +55,27 @@ export const agiloftUpdateRecordTool: ToolConfig ({}), + url: () => '/api/tools/agiloft/update_record', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + data: params.data, + }), }, - directExecution: async (params) => { - let body: string - try { - body = JSON.stringify(JSON.parse(params.data)) - } catch { - return { - success: false, - output: { id: null, fields: {} }, - error: 'Invalid JSON in data parameter', - } + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output, + ...(data.error ? { error: data.error } : {}), } - - return executeAgiloftRequest( - params, - (base) => ({ - url: buildUpdateRecordUrl(base, params), - method: 'PUT', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body, - }), - async (response) => { - if (!response.ok) { - const errorText = await response.text() - return { - success: false, - output: { id: null, fields: {} }, - error: `Agiloft error: ${response.status} - ${errorText}`, - } - } - - const data = (await response.json()) as Record - const result = (data.result ?? data) as Record - const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null - - return { - success: data.success !== false, - output: { - id: id != null ? String(id) : null, - fields: result ?? {}, - }, - } - } - ) }, outputs: { diff --git a/apps/sim/tools/google_calendar/create.ts b/apps/sim/tools/google_calendar/create.ts index a8d55604eea..97514072be6 100644 --- a/apps/sim/tools/google_calendar/create.ts +++ b/apps/sim/tools/google_calendar/create.ts @@ -1,10 +1,18 @@ import { CALENDAR_API_BASE, + type CalendarAttendee, type GoogleCalendarApiEventResponse, type GoogleCalendarCreateParams, type GoogleCalendarCreateResponse, type GoogleCalendarEventRequestBody, } from '@/tools/google_calendar/types' +import { + assertRecurringTimeZone, + buildEventDateTime, + buildGoogleMeetConferenceData, + normalizeAttendees, + normalizeRecurrence, +} from '@/tools/google_calendar/utils' import type { ToolConfig } from '@/tools/types' export const createTool: ToolConfig = { @@ -54,22 +62,21 @@ export const createTool: ToolConfig ({ @@ -105,21 +126,17 @@ export const createTool: ToolConfig { - // Default timezone if not provided and datetime doesn't include offset - const timeZone = params.timeZone || 'America/Los_Angeles' - const needsTimezone = - !params.startDateTime.includes('+') && !params.startDateTime.includes('-', 10) + const recurrence = normalizeRecurrence(params.recurrence) + const isRecurring = recurrence.length > 0 + + if (isRecurring) { + assertRecurringTimeZone([params.startDateTime, params.endDateTime], params.timeZone) + } const eventData: GoogleCalendarEventRequestBody = { summary: params.summary, - start: { - dateTime: params.startDateTime, - ...(needsTimezone ? { timeZone } : {}), - }, - end: { - dateTime: params.endDateTime, - ...(needsTimezone ? { timeZone } : {}), - }, + start: buildEventDateTime(params.startDateTime, params.timeZone), + end: buildEventDateTime(params.endDateTime, params.timeZone), } if (params.description) { @@ -130,29 +147,17 @@ export const createTool: ToolConfig 0) { + eventData.attendees = attendees } - // Handle both string and array cases for attendees - let attendeeList: string[] = [] - if (params.attendees) { - const attendees = params.attendees as string | string[] - if (Array.isArray(attendees)) { - attendeeList = attendees.filter((email: string) => email && email.trim().length > 0) - } else if (typeof attendees === 'string' && attendees.trim().length > 0) { - // Convert comma-separated string to array - attendeeList = attendees - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } + if (isRecurring) { + eventData.recurrence = recurrence } - if (attendeeList.length > 0) { - eventData.attendees = attendeeList.map((email: string) => ({ email })) + if (params.addGoogleMeet) { + eventData.conferenceData = buildGoogleMeetConferenceData() } return eventData @@ -169,10 +174,12 @@ export const createTool: ToolConfig = { + id: 'google_calendar_create_calendar', + name: 'Google Calendar Create Calendar', + description: 'Create a new secondary calendar', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + summary: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Title of the new calendar', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the new calendar', + }, + location: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Geographic location of the calendar as free-form text', + }, + timeZone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Time zone of the calendar as an IANA name (e.g., America/Los_Angeles)', + }, + }, + + request: { + url: () => `${CALENDAR_API_BASE}/calendars`, + method: 'POST', + headers: (params: GoogleCalendarCreateCalendarParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params: GoogleCalendarCreateCalendarParams) => { + const body: Record = { summary: params.summary } + if (params.description) body.description = params.description + if (params.location) body.location = params.location + if (params.timeZone) body.timeZone = params.timeZone + return body + }, + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiCalendarResponse = await response.json() + + return { + success: true, + output: { + content: `Calendar "${data.summary}" created successfully`, + metadata: { + id: data.id, + summary: data.summary, + description: data.description, + location: data.location, + timeZone: data.timeZone, + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Calendar creation confirmation message' }, + metadata: { + type: 'json', + description: 'Created calendar metadata (id, summary, description, location, timeZone)', + }, + }, +} + +interface GoogleCalendarCreateCalendarV2Response { + success: boolean + output: { + id: string + summary: string + description: string | null + location: string | null + timeZone: string | null + } +} + +export const createCalendarV2Tool: ToolConfig< + GoogleCalendarCreateCalendarParams, + GoogleCalendarCreateCalendarV2Response +> = { + id: 'google_calendar_create_calendar_v2', + name: 'Google Calendar Create Calendar', + description: 'Create a new secondary calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: createCalendarTool.oauth, + params: createCalendarTool.params, + request: createCalendarTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiCalendarResponse = await response.json() + + return { + success: true, + output: { + id: data.id, + summary: data.summary, + description: data.description ?? null, + location: data.location ?? null, + timeZone: data.timeZone ?? null, + }, + } + }, + outputs: { + id: { type: 'string', description: 'Calendar ID' }, + summary: { type: 'string', description: 'Calendar title' }, + description: { type: 'string', description: 'Calendar description', optional: true }, + location: { type: 'string', description: 'Calendar location', optional: true }, + timeZone: { type: 'string', description: 'Calendar time zone', optional: true }, + }, +} diff --git a/apps/sim/tools/google_calendar/delete.ts b/apps/sim/tools/google_calendar/delete.ts index 9125d93827b..7eb9c86729b 100644 --- a/apps/sim/tools/google_calendar/delete.ts +++ b/apps/sim/tools/google_calendar/delete.ts @@ -70,7 +70,6 @@ export const deleteTool: ToolConfig { - // DELETE returns 204 No Content on success if (response.status === 204 || response.ok) { return { success: true, diff --git a/apps/sim/tools/google_calendar/index.ts b/apps/sim/tools/google_calendar/index.ts index 4d55e57ad90..22d3ca97a9f 100644 --- a/apps/sim/tools/google_calendar/index.ts +++ b/apps/sim/tools/google_calendar/index.ts @@ -1,35 +1,50 @@ import { createTool, createV2Tool } from '@/tools/google_calendar/create' +import { createCalendarTool, createCalendarV2Tool } from '@/tools/google_calendar/create_calendar' import { deleteTool, deleteV2Tool } from '@/tools/google_calendar/delete' import { freebusyTool, freebusyV2Tool } from '@/tools/google_calendar/freebusy' import { getTool, getV2Tool } from '@/tools/google_calendar/get' import { instancesTool, instancesV2Tool } from '@/tools/google_calendar/instances' import { inviteTool, inviteV2Tool } from '@/tools/google_calendar/invite' import { listTool, listV2Tool } from '@/tools/google_calendar/list' +import { listAclTool, listAclV2Tool } from '@/tools/google_calendar/list_acl' import { listCalendarsTool, listCalendarsV2Tool } from '@/tools/google_calendar/list_calendars' import { moveTool, moveV2Tool } from '@/tools/google_calendar/move' import { quickAddTool, quickAddV2Tool } from '@/tools/google_calendar/quick_add' +import { shareCalendarTool, shareCalendarV2Tool } from '@/tools/google_calendar/share_calendar' +import { + unshareCalendarTool, + unshareCalendarV2Tool, +} from '@/tools/google_calendar/unshare_calendar' import { updateTool, updateV2Tool } from '@/tools/google_calendar/update' export const googleCalendarCreateTool = createTool +export const googleCalendarCreateCalendarTool = createCalendarTool export const googleCalendarDeleteTool = deleteTool export const googleCalendarFreeBusyTool = freebusyTool export const googleCalendarGetTool = getTool export const googleCalendarInstancesTool = instancesTool export const googleCalendarInviteTool = inviteTool export const googleCalendarListTool = listTool +export const googleCalendarListAclTool = listAclTool export const googleCalendarListCalendarsTool = listCalendarsTool export const googleCalendarMoveTool = moveTool export const googleCalendarQuickAddTool = quickAddTool +export const googleCalendarShareCalendarTool = shareCalendarTool +export const googleCalendarUnshareCalendarTool = unshareCalendarTool export const googleCalendarUpdateTool = updateTool export const googleCalendarCreateV2Tool = createV2Tool +export const googleCalendarCreateCalendarV2Tool = createCalendarV2Tool export const googleCalendarDeleteV2Tool = deleteV2Tool export const googleCalendarFreeBusyV2Tool = freebusyV2Tool export const googleCalendarGetV2Tool = getV2Tool export const googleCalendarInstancesV2Tool = instancesV2Tool export const googleCalendarInviteV2Tool = inviteV2Tool export const googleCalendarListV2Tool = listV2Tool +export const googleCalendarListAclV2Tool = listAclV2Tool export const googleCalendarListCalendarsV2Tool = listCalendarsV2Tool export const googleCalendarMoveV2Tool = moveV2Tool export const googleCalendarQuickAddV2Tool = quickAddV2Tool +export const googleCalendarShareCalendarV2Tool = shareCalendarV2Tool +export const googleCalendarUnshareCalendarV2Tool = unshareCalendarV2Tool export const googleCalendarUpdateV2Tool = updateV2Tool diff --git a/apps/sim/tools/google_calendar/invite.ts b/apps/sim/tools/google_calendar/invite.ts index 18d9a1d15f0..f785870fbf2 100644 --- a/apps/sim/tools/google_calendar/invite.ts +++ b/apps/sim/tools/google_calendar/invite.ts @@ -1,10 +1,102 @@ import { CALENDAR_API_BASE, + type CalendarAttendee, + type GoogleCalendarApiEventResponse, type GoogleCalendarInviteParams, type GoogleCalendarInviteResponse, } from '@/tools/google_calendar/types' +import { normalizeAttendees } from '@/tools/google_calendar/utils' import type { ToolConfig } from '@/tools/types' +interface InviteResult { + data: GoogleCalendarApiEventResponse + totalAttendees: number + newAttendeesAdded: number + shouldReplace: boolean +} + +/** + * The Google Calendar update method replaces the entire event resource, so to invite + * attendees we read the existing event, merge the attendee list, then PUT it back. + */ +async function inviteAttendees( + response: Response, + params: GoogleCalendarInviteParams | undefined +): Promise { + const existingEvent: GoogleCalendarApiEventResponse = await response.json() + + if (!existingEvent.start || !existingEvent.end || !existingEvent.summary) { + throw new Error('Existing event is missing required fields (start, end, or summary)') + } + + const newAttendeeList = normalizeAttendees(params?.attendees).map((attendee) => attendee.email) + const existingAttendees: CalendarAttendee[] = existingEvent.attendees ?? [] + const shouldReplace = + params?.replaceExisting === true || String(params?.replaceExisting) === 'true' + + const existingEmails = new Set( + existingAttendees.map((attendee) => attendee.email?.toLowerCase() ?? '') + ) + const newAttendeesAdded = shouldReplace + ? newAttendeeList.length + : newAttendeeList.filter((email) => !existingEmails.has(email.toLowerCase())).length + + let finalAttendees: CalendarAttendee[] + if (shouldReplace) { + finalAttendees = newAttendeeList.map((email) => ({ email, responseStatus: 'needsAction' })) + } else { + finalAttendees = [...existingAttendees] + for (const email of newAttendeeList) { + if (!existingEmails.has(email.toLowerCase())) { + finalAttendees.push({ email, responseStatus: 'needsAction' }) + } + } + } + + const updatedEvent: Record = { ...existingEvent, attendees: finalAttendees } + const readOnlyFields = [ + 'id', + 'etag', + 'kind', + 'created', + 'updated', + 'htmlLink', + 'iCalUID', + 'creator', + 'organizer', + ] + for (const field of readOnlyFields) { + delete updatedEvent[field] + } + + const calendarId = params?.calendarId?.trim() || 'primary' + const queryParams = new URLSearchParams() + queryParams.append('sendUpdates', params?.sendUpdates ?? 'all') + const putUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params?.eventId?.trim() ?? '')}?${queryParams.toString()}` + + const putResponse = await fetch(putUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${params?.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedEvent), + }) + + if (!putResponse.ok) { + const errorData = await putResponse.json().catch(() => null) + throw new Error(errorData?.error?.message || 'Failed to invite attendees to calendar event') + } + + const data: GoogleCalendarApiEventResponse = await putResponse.json() + return { + data, + totalAttendees: data.attendees?.length ?? 0, + newAttendeesAdded, + shouldReplace, + } +} + export const inviteTool: ToolConfig = { id: 'google_calendar_invite', name: 'Google Calendar Invite Attendees', @@ -45,7 +137,7 @@ export const inviteTool: ToolConfig { - const calendarId = params.calendarId || 'primary' - return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId)}` + const calendarId = params.calendarId?.trim() || 'primary' + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId.trim())}` }, method: 'GET', headers: (params: GoogleCalendarInviteParams) => ({ @@ -68,163 +160,29 @@ export const inviteTool: ToolConfig { - const existingEvent = await response.json() + const { data, totalAttendees, newAttendeesAdded, shouldReplace } = await inviteAttendees( + response, + params + ) - // Validate required fields exist - if (!existingEvent.start || !existingEvent.end || !existingEvent.summary) { - throw new Error('Existing event is missing required fields (start, end, or summary)') - } - - // Process new attendees - handle both string and array formats - let newAttendeeList: string[] = [] - - if (params?.attendees) { - if (Array.isArray(params.attendees)) { - // Already an array from block processing - newAttendeeList = params.attendees.filter( - (email: string) => email && email.trim().length > 0 - ) - } else if ( - typeof (params.attendees as any) === 'string' && - (params.attendees as any).trim().length > 0 - ) { - // Fallback: process comma-separated string if block didn't convert it - newAttendeeList = (params.attendees as any) - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } - } - - // Calculate final attendees list - const existingAttendees = existingEvent.attendees || [] - let finalAttendees: Array = [] - - // Handle replaceExisting properly - check for both boolean true and string "true" - const shouldReplace = - params?.replaceExisting === true || (params?.replaceExisting as any) === 'true' - - if (shouldReplace) { - // Replace all attendees with just the new ones - finalAttendees = newAttendeeList.map((email: string) => ({ - email, - responseStatus: 'needsAction', - })) - } else { - // Add to existing attendees (preserve all existing ones) - - // Start with ALL existing attendees - preserve them completely - finalAttendees = [...existingAttendees] - - // Get set of existing emails for duplicate checking (case-insensitive) - const existingEmails = new Set( - existingAttendees.map((attendee: any) => attendee.email?.toLowerCase() || '') - ) - - // Add only new attendees that don't already exist - for (const newEmail of newAttendeeList) { - const emailLower = newEmail.toLowerCase() - if (!existingEmails.has(emailLower)) { - finalAttendees.push({ - email: newEmail, - responseStatus: 'needsAction', - }) - } - } - } - - // Use the complete existing event object and only modify the attendees field - // This is crucial because the Google Calendar API update method "does not support patch semantics - // and always updates the entire event resource" according to the documentation - const updatedEvent = { - ...existingEvent, // Start with the complete existing event to preserve all fields - attendees: finalAttendees, // Only modify the attendees field - } - - // Remove read-only fields that shouldn't be included in updates - const readOnlyFields = [ - 'id', - 'etag', - 'kind', - 'created', - 'updated', - 'htmlLink', - 'iCalUID', - 'sequence', - 'creator', - 'organizer', - ] - readOnlyFields.forEach((field) => { - delete updatedEvent[field] - }) - - // Construct PUT URL with query parameters - const calendarId = params?.calendarId || 'primary' - const queryParams = new URLSearchParams() - if (params?.sendUpdates !== undefined) { - queryParams.append('sendUpdates', params.sendUpdates) - } - - const queryString = queryParams.toString() - const putUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params?.eventId || '')}${queryString ? `?${queryString}` : ''}` - - // Send PUT request to update the event - const putResponse = await fetch(putUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${params?.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedEvent), - }) - - // Handle the PUT response - if (!putResponse.ok) { - const errorData = await putResponse.json() - throw new Error(errorData.error?.message || 'Failed to invite attendees to calendar event') - } - - const data = await putResponse.json() - const totalAttendees = data.attendees?.length || 0 - - // Calculate how many new attendees were actually added - let newAttendeesAdded = 0 - - if (shouldReplace) { - newAttendeesAdded = newAttendeeList.length - } else { - // Count how many of the new emails weren't already in the existing list - const existingEmails = new Set( - existingAttendees.map((attendee: any) => attendee.email?.toLowerCase() || '') - ) - newAttendeesAdded = newAttendeeList.filter( - (email) => !existingEmails.has(email.toLowerCase()) - ).length - } - - // Improved messaging about email delivery let baseMessage: string if (shouldReplace) { baseMessage = `Successfully updated event "${data.summary}" with ${totalAttendees} attendee${totalAttendees !== 1 ? 's' : ''}` + } else if (newAttendeesAdded > 0) { + baseMessage = `Successfully added ${newAttendeesAdded} new attendee${newAttendeesAdded !== 1 ? 's' : ''} to event "${data.summary}" (total: ${totalAttendees})` } else { - if (newAttendeesAdded > 0) { - baseMessage = `Successfully added ${newAttendeesAdded} new attendee${newAttendeesAdded !== 1 ? 's' : ''} to event "${data.summary}" (total: ${totalAttendees})` - } else { - baseMessage = `No new attendees added to event "${data.summary}" - all specified attendees were already invited (total: ${totalAttendees})` - } + baseMessage = `No new attendees added to event "${data.summary}" - all specified attendees were already invited (total: ${totalAttendees})` } const emailNote = params?.sendUpdates !== 'none' - ? ` Email invitations are being sent asynchronously - delivery may take a few minutes and depends on recipients' Google Calendar settings.` - : ` No email notifications will be sent as requested.` - - const content = baseMessage + emailNote + ? ' Email invitations are being sent asynchronously - delivery may take a few minutes and depends on recipients’ Google Calendar settings.' + : ' No email notifications will be sent as requested.' return { success: true, output: { - content, + content: baseMessage + emailNote, metadata: { id: data.id, htmlLink: data.htmlLink, @@ -258,16 +216,16 @@ interface GoogleCalendarInviteV2Response { success: boolean output: { id: string - htmlLink?: string - status?: string - summary?: string - description?: string - location?: string - start?: any - end?: any - attendees?: any - creator?: any - organizer?: any + htmlLink: string + status: string + summary: string | null + description: string | null + location: string | null + start: GoogleCalendarApiEventResponse['start'] + end: GoogleCalendarApiEventResponse['end'] + attendees: CalendarAttendee[] | null + creator: GoogleCalendarApiEventResponse['creator'] | null + organizer: GoogleCalendarApiEventResponse['organizer'] | null } } @@ -282,104 +240,7 @@ export const inviteV2Tool: ToolConfig { - const existingEvent = await response.json() - - if (!existingEvent.start || !existingEvent.end || !existingEvent.summary) { - throw new Error('Existing event is missing required fields (start, end, or summary)') - } - - let newAttendeeList: string[] = [] - - if (params?.attendees) { - if (Array.isArray(params.attendees)) { - newAttendeeList = params.attendees.filter( - (email: string) => email && email.trim().length > 0 - ) - } else if ( - typeof (params.attendees as any) === 'string' && - (params.attendees as any).trim().length > 0 - ) { - newAttendeeList = (params.attendees as any) - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } - } - - const existingAttendees = existingEvent.attendees || [] - let finalAttendees: Array = [] - - const shouldReplace = - params?.replaceExisting === true || (params?.replaceExisting as any) === 'true' - - if (shouldReplace) { - finalAttendees = newAttendeeList.map((email: string) => ({ - email, - responseStatus: 'needsAction', - })) - } else { - finalAttendees = [...existingAttendees] - - const existingEmails = new Set( - existingAttendees.map((attendee: any) => attendee.email?.toLowerCase() || '') - ) - - for (const newEmail of newAttendeeList) { - const emailLower = newEmail.toLowerCase() - if (!existingEmails.has(emailLower)) { - finalAttendees.push({ - email: newEmail, - responseStatus: 'needsAction', - }) - } - } - } - - const updatedEvent = { - ...existingEvent, - attendees: finalAttendees, - } - - const readOnlyFields = [ - 'id', - 'etag', - 'kind', - 'created', - 'updated', - 'htmlLink', - 'iCalUID', - 'sequence', - 'creator', - 'organizer', - ] - readOnlyFields.forEach((field) => { - delete updatedEvent[field] - }) - - const calendarId = params?.calendarId || 'primary' - const queryParams = new URLSearchParams() - if (params?.sendUpdates !== undefined) { - queryParams.append('sendUpdates', params.sendUpdates) - } - - const queryString = queryParams.toString() - const putUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params?.eventId || '')}${queryString ? `?${queryString}` : ''}` - - const putResponse = await fetch(putUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${params?.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedEvent), - }) - - if (!putResponse.ok) { - const errorData = await putResponse.json() - throw new Error(errorData.error?.message || 'Failed to invite attendees to calendar event') - } - - const data = await putResponse.json() + const { data } = await inviteAttendees(response, params) return { success: true, @@ -393,8 +254,8 @@ export const inviteV2Tool: ToolConfig> + events: GoogleCalendarListV2Event[] } } diff --git a/apps/sim/tools/google_calendar/list_acl.ts b/apps/sim/tools/google_calendar/list_acl.ts new file mode 100644 index 00000000000..28404c04145 --- /dev/null +++ b/apps/sim/tools/google_calendar/list_acl.ts @@ -0,0 +1,152 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarApiAclListResponse, + type GoogleCalendarListAclParams, + type GoogleCalendarListAclResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +export const listAclTool: ToolConfig = { + id: 'google_calendar_list_acl', + name: 'Google Calendar List Sharing', + description: 'List the access control rules (sharing) for a calendar', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Calendar ID to inspect (e.g., primary or calendar@group.calendar.google.com)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of ACL rules to return', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token for retrieving subsequent pages of results', + }, + showDeleted: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include deleted ACL rules (with role "none")', + }, + }, + + request: { + url: (params: GoogleCalendarListAclParams) => { + const calendarId = params.calendarId?.trim() || 'primary' + const queryParams = new URLSearchParams() + if (params.maxResults) queryParams.append('maxResults', params.maxResults.toString()) + if (params.pageToken) queryParams.append('pageToken', params.pageToken) + if (params.showDeleted !== undefined) + queryParams.append('showDeleted', params.showDeleted.toString()) + const queryString = queryParams.toString() + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/acl${queryString ? `?${queryString}` : ''}` + }, + method: 'GET', + headers: (params: GoogleCalendarListAclParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclListResponse = await response.json() + const rules = data.items || [] + const rulesCount = rules.length + + return { + success: true, + output: { + content: `Found ${rulesCount} sharing rule${rulesCount !== 1 ? 's' : ''}`, + metadata: { + nextPageToken: data.nextPageToken, + rules: rules.map((rule) => ({ + id: rule.id, + role: rule.role, + scope: rule.scope, + })), + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Summary of found sharing rules count' }, + metadata: { + type: 'json', + description: 'List of ACL rules with pagination token', + }, + }, +} + +interface GoogleCalendarListAclV2Response { + success: boolean + output: { + nextPageToken: string | null + rules: Array<{ id: string; role: string; scope: { type: string; value?: string } }> + } +} + +export const listAclV2Tool: ToolConfig< + GoogleCalendarListAclParams, + GoogleCalendarListAclV2Response +> = { + id: 'google_calendar_list_acl_v2', + name: 'Google Calendar List Sharing', + description: + 'List the access control rules (sharing) for a calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: listAclTool.oauth, + params: listAclTool.params, + request: listAclTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclListResponse = await response.json() + const rules = data.items || [] + + return { + success: true, + output: { + nextPageToken: data.nextPageToken ?? null, + rules: rules.map((rule) => ({ + id: rule.id, + role: rule.role, + scope: rule.scope, + })), + }, + } + }, + outputs: { + nextPageToken: { type: 'string', description: 'Next page token', optional: true }, + rules: { + type: 'array', + description: 'List of ACL rules', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'ACL rule ID' }, + role: { type: 'string', description: 'Access role' }, + scope: { type: 'json', description: 'Grantee scope (type and value)' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_calendar/move.ts b/apps/sim/tools/google_calendar/move.ts index e3bdbf28f9f..7332db60de4 100644 --- a/apps/sim/tools/google_calendar/move.ts +++ b/apps/sim/tools/google_calendar/move.ts @@ -121,7 +121,7 @@ export const moveTool: ToolConfig { const data = await response.json() - // Handle attendees if provided let finalEventData = data if (params?.attendees) { let attendeeList: string[] = [] @@ -88,7 +87,6 @@ export const quickAddTool: ToolConfig< if (Array.isArray(attendees)) { attendeeList = attendees.filter((email: string) => email && email.trim().length > 0) } else if (typeof attendees === 'string' && attendees.trim().length > 0) { - // Convert comma-separated string to array attendeeList = attendees .split(',') .map((email: string) => email.trim()) @@ -97,16 +95,13 @@ export const quickAddTool: ToolConfig< if (attendeeList.length > 0) { try { - // Update the event with attendees const calendarId = params.calendarId || 'primary' const eventId = data.id - // Prepare update data const updateData = { attendees: attendeeList.map((email: string) => ({ email })), } - // Build update URL with sendUpdates if specified const updateQueryParams = new URLSearchParams() if (params.sendUpdates !== undefined) { updateQueryParams.append('sendUpdates', params.sendUpdates) @@ -114,7 +109,6 @@ export const quickAddTool: ToolConfig< const updateUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${eventId}${updateQueryParams.toString() ? `?${updateQueryParams.toString()}` : ''}` - // Make the update request const updateResponse = await fetch(updateUrl, { method: 'PATCH', headers: { @@ -127,13 +121,11 @@ export const quickAddTool: ToolConfig< if (updateResponse.ok) { finalEventData = await updateResponse.json() } else { - // If update fails, we still return the original event but log the error logger.warn('Failed to add attendees to quick-added event', { error: await updateResponse.text(), }) } } catch (error) { - // If attendee update fails, we still return the original event logger.warn('Error adding attendees to quick-added event', { error }) } } diff --git a/apps/sim/tools/google_calendar/share_calendar.ts b/apps/sim/tools/google_calendar/share_calendar.ts new file mode 100644 index 00000000000..faf6b3f3dcc --- /dev/null +++ b/apps/sim/tools/google_calendar/share_calendar.ts @@ -0,0 +1,160 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarApiAclRule, + type GoogleCalendarShareCalendarParams, + type GoogleCalendarShareCalendarResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +const buildAclBody = (params: GoogleCalendarShareCalendarParams) => { + const scope: { type: string; value?: string } = { type: params.scopeType } + if (params.scopeType !== 'default') { + const value = params.scopeValue?.trim() + if (!value) { + throw new Error( + `A grantee is required when scope type is "${params.scopeType}". Provide an email (user/group) or domain name in scopeValue.` + ) + } + scope.value = value + } + return { role: params.role, scope } +} + +const buildAclUrl = (params: GoogleCalendarShareCalendarParams) => { + const calendarId = params.calendarId?.trim() || 'primary' + const queryParams = new URLSearchParams() + if (params.sendNotifications !== undefined) { + queryParams.append('sendNotifications', String(params.sendNotifications)) + } + const queryString = queryParams.toString() + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/acl${queryString ? `?${queryString}` : ''}` +} + +export const shareCalendarTool: ToolConfig< + GoogleCalendarShareCalendarParams, + GoogleCalendarShareCalendarResponse +> = { + id: 'google_calendar_share_calendar', + name: 'Google Calendar Share Calendar', + description: 'Grant a user, group, or domain access to a calendar by creating an ACL rule', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Calendar ID to share (e.g., primary or calendar@group.calendar.google.com)', + }, + role: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Access role to grant: freeBusyReader, reader, writer, or owner', + }, + scopeType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of grantee: user, group, domain, or default (public)', + }, + scopeValue: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Email (user/group), domain name (domain), or empty for default. Required unless scope type is default.', + }, + sendNotifications: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Whether to send a notification email about the change. Defaults to true.', + }, + }, + + request: { + url: buildAclUrl, + method: 'POST', + headers: (params: GoogleCalendarShareCalendarParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: buildAclBody, + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclRule = await response.json() + + return { + success: true, + output: { + content: `Granted ${data.role} access to ${data.scope?.value || data.scope?.type}`, + metadata: { + id: data.id, + role: data.role, + scope: data.scope, + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Sharing confirmation message' }, + metadata: { + type: 'json', + description: 'Created ACL rule (id, role, scope)', + }, + }, +} + +interface GoogleCalendarShareCalendarV2Response { + success: boolean + output: { + id: string + role: string + scope: { type: string; value?: string } + } +} + +export const shareCalendarV2Tool: ToolConfig< + GoogleCalendarShareCalendarParams, + GoogleCalendarShareCalendarV2Response +> = { + id: 'google_calendar_share_calendar_v2', + name: 'Google Calendar Share Calendar', + description: + 'Grant a user, group, or domain access to a calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: shareCalendarTool.oauth, + params: shareCalendarTool.params, + request: shareCalendarTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclRule = await response.json() + + return { + success: true, + output: { + id: data.id, + role: data.role, + scope: data.scope, + }, + } + }, + outputs: { + id: { type: 'string', description: 'ACL rule ID' }, + role: { type: 'string', description: 'Granted access role' }, + scope: { type: 'json', description: 'Grantee scope (type and value)' }, + }, +} diff --git a/apps/sim/tools/google_calendar/types.ts b/apps/sim/tools/google_calendar/types.ts index 31003dd0a74..b48d884fb30 100644 --- a/apps/sim/tools/google_calendar/types.ts +++ b/apps/sim/tools/google_calendar/types.ts @@ -2,8 +2,7 @@ import type { ToolResponse } from '@/tools/types' export const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3' -// Shared attendee interface that matches Google Calendar API specification -interface CalendarAttendee { +export interface CalendarAttendee { id?: string email: string displayName?: string @@ -18,7 +17,7 @@ interface CalendarAttendee { interface BaseGoogleCalendarParams { accessToken: string - calendarId?: string // defaults to 'primary' if not provided + calendarId?: string } export interface GoogleCalendarCreateParams extends BaseGoogleCalendarParams { @@ -28,14 +27,18 @@ export interface GoogleCalendarCreateParams extends BaseGoogleCalendarParams { startDateTime: string endDateTime: string timeZone?: string - attendees?: string[] // Array of email addresses + attendees?: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' + recurrence?: string | string[] + addGoogleMeet?: boolean } export interface GoogleCalendarListParams extends BaseGoogleCalendarParams { - timeMin?: string // RFC3339 timestamp - timeMax?: string // RFC3339 timestamp + timeMin?: string + timeMax?: string + q?: string maxResults?: number + pageToken?: string singleEvents?: boolean orderBy?: 'startTime' | 'updated' showDeleted?: boolean @@ -55,6 +58,8 @@ export interface GoogleCalendarUpdateParams extends BaseGoogleCalendarParams { timeZone?: string attendees?: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' + recurrence?: string | string[] + addGoogleMeet?: boolean } export interface GoogleCalendarDeleteParams extends BaseGoogleCalendarParams { @@ -63,16 +68,16 @@ export interface GoogleCalendarDeleteParams extends BaseGoogleCalendarParams { } export interface GoogleCalendarQuickAddParams extends BaseGoogleCalendarParams { - text: string // Natural language text like "Meeting with John tomorrow at 3pm" - attendees?: string[] // Array of email addresses (comma-separated string also accepted) + text: string + attendees?: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' } export interface GoogleCalendarInviteParams extends BaseGoogleCalendarParams { eventId: string - attendees: string[] // Array of email addresses to invite + attendees: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' - replaceExisting?: boolean // Whether to replace existing attendees or add to them + replaceExisting?: boolean } interface GoogleCalendarMoveParams extends BaseGoogleCalendarParams { @@ -92,10 +97,10 @@ interface GoogleCalendarInstancesParams extends BaseGoogleCalendarParams { export interface GoogleCalendarFreeBusyParams { accessToken: string - calendarIds: string // Comma-separated calendar IDs (e.g., "primary,other@example.com") - timeMin: string // RFC3339 timestamp (e.g., 2025-06-03T00:00:00Z) - timeMax: string // RFC3339 timestamp (e.g., 2025-06-04T00:00:00Z) - timeZone?: string // IANA time zone (e.g., "UTC", "America/New_York") + calendarIds: string + timeMin: string + timeMax: string + timeZone?: string } interface GoogleCalendarListCalendarsParams { @@ -107,6 +112,40 @@ interface GoogleCalendarListCalendarsParams { showHidden?: boolean } +export interface GoogleCalendarCreateCalendarParams { + accessToken: string + summary: string + description?: string + location?: string + timeZone?: string +} + +type GoogleCalendarAclRole = 'freeBusyReader' | 'reader' | 'writer' | 'owner' +type GoogleCalendarAclScopeType = 'user' | 'group' | 'domain' | 'default' + +export interface GoogleCalendarShareCalendarParams { + accessToken: string + calendarId?: string + role: GoogleCalendarAclRole + scopeType: GoogleCalendarAclScopeType + scopeValue?: string + sendNotifications?: boolean +} + +export interface GoogleCalendarListAclParams { + accessToken: string + calendarId?: string + maxResults?: number + pageToken?: string + showDeleted?: boolean +} + +export interface GoogleCalendarUnshareCalendarParams { + accessToken: string + calendarId?: string + ruleId: string +} + export type GoogleCalendarToolParams = | GoogleCalendarCreateParams | GoogleCalendarListParams @@ -119,14 +158,20 @@ export type GoogleCalendarToolParams = | GoogleCalendarInstancesParams | GoogleCalendarFreeBusyParams | GoogleCalendarListCalendarsParams + | GoogleCalendarCreateCalendarParams + | GoogleCalendarShareCalendarParams + | GoogleCalendarListAclParams + | GoogleCalendarUnshareCalendarParams interface EventMetadata { id: string htmlLink: string + hangoutLink?: string status: string summary: string description?: string location?: string + recurrence?: string[] start: { dateTime?: string date?: string @@ -162,7 +207,6 @@ interface GoogleCalendarToolResponse extends ToolResponse { } } -// Specific response types for each operation export interface GoogleCalendarCreateResponse extends ToolResponse { output: { content: string @@ -242,32 +286,44 @@ interface GoogleCalendarEvent { } } +interface GoogleCalendarEventDateTime { + dateTime?: string + date?: string + timeZone?: string +} + +interface GoogleCalendarConferenceCreateRequest { + createRequest: { + requestId: string + conferenceSolutionKey: { type: string } + } +} + export interface GoogleCalendarEventRequestBody { summary: string description?: string location?: string - start: { - dateTime: string - timeZone?: string - } - end: { - dateTime: string - timeZone?: string - } + start: GoogleCalendarEventDateTime + end: GoogleCalendarEventDateTime attendees?: Array<{ email: string }> + recurrence?: string[] + conferenceData?: GoogleCalendarConferenceCreateRequest } export interface GoogleCalendarApiEventResponse { id: string status: string htmlLink: string + hangoutLink?: string created?: string updated?: string summary: string description?: string location?: string + recurrence?: string[] + recurringEventId?: string start: { dateTime?: string date?: string @@ -287,6 +343,7 @@ export interface GoogleCalendarApiEventResponse { email: string displayName?: string } + conferenceData?: Record reminders?: { useDefault: boolean overrides?: Array<{ @@ -296,6 +353,34 @@ export interface GoogleCalendarApiEventResponse { } } +export interface GoogleCalendarApiCalendarResponse { + kind: string + etag: string + id: string + summary: string + description?: string + location?: string + timeZone?: string +} + +export interface GoogleCalendarApiAclRule { + kind: string + etag: string + id: string + role: string + scope: { + type: string + value?: string + } +} + +export interface GoogleCalendarApiAclListResponse { + kind: string + etag: string + nextPageToken?: string + items: GoogleCalendarApiAclRule[] +} + export interface GoogleCalendarApiListResponse { kind: string etag: string @@ -402,6 +487,50 @@ interface GoogleCalendarListCalendarsResponse extends ToolResponse { } } +export interface GoogleCalendarCreateCalendarResponse extends ToolResponse { + output: { + content: string + metadata: { + id: string + summary: string + description?: string + location?: string + timeZone?: string + } + } +} + +export interface GoogleCalendarShareCalendarResponse extends ToolResponse { + output: { + content: string + metadata: { + id: string + role: string + scope: { type: string; value?: string } + } + } +} + +export interface GoogleCalendarListAclResponse extends ToolResponse { + output: { + content: string + metadata: { + nextPageToken?: string + rules: Array<{ id: string; role: string; scope: { type: string; value?: string } }> + } + } +} + +export interface GoogleCalendarUnshareCalendarResponse extends ToolResponse { + output: { + content: string + metadata: { + ruleId: string + deleted: boolean + } + } +} + export type GoogleCalendarResponse = | GoogleCalendarCreateResponse | GoogleCalendarListResponse @@ -414,3 +543,7 @@ export type GoogleCalendarResponse = | GoogleCalendarInstancesResponse | GoogleCalendarFreeBusyResponse | GoogleCalendarListCalendarsResponse + | GoogleCalendarCreateCalendarResponse + | GoogleCalendarShareCalendarResponse + | GoogleCalendarListAclResponse + | GoogleCalendarUnshareCalendarResponse diff --git a/apps/sim/tools/google_calendar/unshare_calendar.ts b/apps/sim/tools/google_calendar/unshare_calendar.ts new file mode 100644 index 00000000000..95a42dfb748 --- /dev/null +++ b/apps/sim/tools/google_calendar/unshare_calendar.ts @@ -0,0 +1,122 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarUnshareCalendarParams, + type GoogleCalendarUnshareCalendarResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +const buildUnshareUrl = (params: GoogleCalendarUnshareCalendarParams) => { + const calendarId = params.calendarId?.trim() || 'primary' + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/acl/${encodeURIComponent(params.ruleId.trim())}` +} + +export const unshareCalendarTool: ToolConfig< + GoogleCalendarUnshareCalendarParams, + GoogleCalendarUnshareCalendarResponse +> = { + id: 'google_calendar_unshare_calendar', + name: 'Google Calendar Remove Sharing', + description: 'Revoke an access control rule (sharing) from a calendar', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Calendar ID to modify (e.g., primary or calendar@group.calendar.google.com)', + }, + ruleId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ACL rule ID to remove (e.g., user:person@example.com)', + }, + }, + + request: { + url: buildUnshareUrl, + method: 'DELETE', + headers: (params: GoogleCalendarUnshareCalendarParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response, params) => { + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + content: 'Sharing rule successfully removed', + metadata: { + ruleId: params?.ruleId || '', + deleted: true, + }, + }, + } + } + + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.error?.message || 'Failed to remove sharing rule') + }, + + outputs: { + content: { type: 'string', description: 'Removal confirmation message' }, + metadata: { + type: 'json', + description: 'Removal details including rule ID', + }, + }, +} + +interface GoogleCalendarUnshareCalendarV2Response { + success: boolean + output: { + ruleId: string + deleted: boolean + } +} + +export const unshareCalendarV2Tool: ToolConfig< + GoogleCalendarUnshareCalendarParams, + GoogleCalendarUnshareCalendarV2Response +> = { + id: 'google_calendar_unshare_calendar_v2', + name: 'Google Calendar Remove Sharing', + description: + 'Revoke an access control rule (sharing) from a calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: unshareCalendarTool.oauth, + params: unshareCalendarTool.params, + request: unshareCalendarTool.request, + transformResponse: async (response: Response, params) => { + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + ruleId: params?.ruleId || '', + deleted: true, + }, + } + } + + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.error?.message || 'Failed to remove sharing rule') + }, + outputs: { + ruleId: { type: 'string', description: 'Removed ACL rule ID' }, + deleted: { type: 'boolean', description: 'Whether removal was successful' }, + }, +} diff --git a/apps/sim/tools/google_calendar/update.ts b/apps/sim/tools/google_calendar/update.ts index c59048db3e6..fa795afe02f 100644 --- a/apps/sim/tools/google_calendar/update.ts +++ b/apps/sim/tools/google_calendar/update.ts @@ -1,11 +1,22 @@ import { CALENDAR_API_BASE, + type CalendarAttendee, type GoogleCalendarApiEventResponse, + type GoogleCalendarEventRequestBody, type GoogleCalendarUpdateParams, type GoogleCalendarUpdateResponse, } from '@/tools/google_calendar/types' +import { + assertRecurringTimeZone, + buildEventDateTime, + buildGoogleMeetConferenceData, + normalizeAttendees, + normalizeRecurrence, +} from '@/tools/google_calendar/utils' import type { ToolConfig } from '@/tools/types' +type EventPatchBody = Partial + export const updateTool: ToolConfig = { id: 'google_calendar_update', name: 'Google Calendar Update Event', @@ -59,27 +70,41 @@ export const updateTool: ToolConfig { - const updateData: Record = {} + body: (params: GoogleCalendarUpdateParams): EventPatchBody => { + const updateData: EventPatchBody = {} + const recurrence = normalizeRecurrence(params.recurrence) + const isRecurring = recurrence.length > 0 + + if (isRecurring) { + assertRecurringTimeZone([params.startDateTime, params.endDateTime], params.timeZone) + } if (params.summary !== undefined) { updateData.summary = params.summary @@ -122,38 +156,24 @@ export const updateTool: ToolConfig 0) { + updateData.attendees = attendees + } + + if (isRecurring) { + updateData.recurrence = recurrence } - // Handle attendees - convert to array format - if (params.attendees !== undefined) { - let attendeeList: string[] = [] - const attendees = params.attendees as string | string[] - - if (Array.isArray(attendees)) { - attendeeList = attendees.filter((email: string) => email && email.trim().length > 0) - } else if (typeof attendees === 'string' && attendees.trim().length > 0) { - attendeeList = attendees - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } - - updateData.attendees = attendeeList.map((email: string) => ({ email })) + if (params.addGoogleMeet) { + updateData.conferenceData = buildGoogleMeetConferenceData() } return updateData @@ -170,10 +190,12 @@ export const updateTool: ToolConfig { + if (!attendees) return [] + + const list = Array.isArray(attendees) + ? attendees + : attendees.split(',').map((email) => email.trim()) + + return list.filter((email) => email.length > 0).map((email) => ({ email })) +} + +/** + * Recurring events require a named `timeZone` on their timed start/end — the Calendar API + * rejects them otherwise, and an RFC3339 offset is not a substitute (an IANA zone cannot be + * derived from a fixed offset). Throws a clear error so we fail fast with guidance instead of + * silently guessing a zone (which would misalign the recurrence) or sending an invalid request. + * All-day recurring events (date-only values) do not need a timezone and are allowed. + */ +export function assertRecurringTimeZone( + dateTimes: Array, + timeZone: string | undefined +): void { + if (timeZone) return + const hasTimedValue = dateTimes.some((value) => value?.includes('T')) + if (hasTimedValue) { + throw new Error( + 'Recurring events require a time zone. Provide the timeZone parameter (an IANA name, e.g. America/New_York).' + ) + } +} + +/** Normalize recurrence rules (single string, newline-separated string, or array) into an array. */ +export function normalizeRecurrence(recurrence: string | string[] | undefined): string[] { + if (!recurrence) return [] + + const list = Array.isArray(recurrence) ? recurrence : recurrence.split('\n') + + return list.map((rule) => rule.trim()).filter((rule) => rule.length > 0) +} + +/** Build a `conferenceData.createRequest` payload that asks Google to attach a Meet link. */ +export function buildGoogleMeetConferenceData(): GoogleCalendarEventRequestBody['conferenceData'] { + return { + createRequest: { + requestId: generateId(), + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }, + } +} diff --git a/apps/sim/tools/grafana/check_data_source_health.ts b/apps/sim/tools/grafana/check_data_source_health.ts new file mode 100644 index 00000000000..2a026c9e77a --- /dev/null +++ b/apps/sim/tools/grafana/check_data_source_health.ts @@ -0,0 +1,75 @@ +import type { + GrafanaDataSourceHealthParams, + GrafanaDataSourceHealthResponse, +} from '@/tools/grafana/types' +import type { ToolConfig } from '@/tools/types' + +export const checkDataSourceHealthTool: ToolConfig< + GrafanaDataSourceHealthParams, + GrafanaDataSourceHealthResponse +> = { + id: 'grafana_check_data_source_health', + name: 'Grafana Check Data Source Health', + description: 'Test connectivity to a data source by its UID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana Service Account Token', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana instance URL (e.g., https://your-grafana.com)', + }, + organizationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)', + }, + dataSourceUid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UID of the data source to health-check (e.g., P1234AB5678)', + }, + }, + + request: { + url: (params) => + `${params.baseUrl.replace(/\/$/, '')}/api/datasources/uid/${params.dataSourceUid.trim()}/health`, + method: 'GET', + headers: (params) => { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + return headers + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + status: (data.status as string) ?? 'UNKNOWN', + message: (data.message as string) ?? '', + }, + } + }, + + outputs: { + status: { type: 'string', description: 'Health status of the data source (e.g., OK)' }, + message: { type: 'string', description: 'Detailed health message from the data source' }, + }, +} diff --git a/apps/sim/tools/grafana/create_alert_rule.ts b/apps/sim/tools/grafana/create_alert_rule.ts index 0c07eea7683..360cd74b31c 100644 --- a/apps/sim/tools/grafana/create_alert_rule.ts +++ b/apps/sim/tools/grafana/create_alert_rule.ts @@ -172,7 +172,7 @@ export const createAlertRuleTool: ToolConfig< if (params.organizationId) body.orgID = Number(params.organizationId) if (params.condition) body.condition = params.condition - if (params.uid) body.uid = params.uid + if (params.uid) body.uid = params.uid.trim() if (params.forDuration) body.for = params.forDuration if (params.noDataState) body.noDataState = params.noDataState if (params.execErrState) body.execErrState = params.execErrState diff --git a/apps/sim/tools/grafana/create_contact_point.ts b/apps/sim/tools/grafana/create_contact_point.ts new file mode 100644 index 00000000000..cd264b8ecc9 --- /dev/null +++ b/apps/sim/tools/grafana/create_contact_point.ts @@ -0,0 +1,132 @@ +import type { + GrafanaCreateContactPointParams, + GrafanaCreateContactPointResponse, +} from '@/tools/grafana/types' +import type { ToolConfig } from '@/tools/types' + +export const createContactPointTool: ToolConfig< + GrafanaCreateContactPointParams, + GrafanaCreateContactPointResponse +> = { + id: 'grafana_create_contact_point', + name: 'Grafana Create Contact Point', + description: 'Create a notification contact point (e.g., Slack, email, PagerDuty)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana Service Account Token', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana instance URL (e.g., https://your-grafana.com)', + }, + organizationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the contact point (groups receivers shown in the UI)', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Receiver type (e.g., slack, email, pagerduty, webhook)', + }, + settings: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object of type-specific settings (e.g., {"addresses":"a@b.com"} for email, {"url":"..."} for slack)', + }, + disableResolveMessage: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Do not send a notification when the alert resolves', + }, + disableProvenance: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: + 'Set X-Disable-Provenance header so the contact point remains editable in the UI', + }, + }, + + request: { + url: (params) => `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/contact-points`, + method: 'POST', + headers: (params) => { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + if (params.disableProvenance) { + headers['X-Disable-Provenance'] = 'true' + } + return headers + }, + body: (params) => { + let settings: Record = {} + try { + settings = JSON.parse(params.settings) + } catch { + throw new Error('Invalid JSON for settings parameter') + } + + const body: Record = { + name: params.name, + type: params.type, + settings, + } + if (params.disableResolveMessage !== undefined) { + body.disableResolveMessage = params.disableResolveMessage + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + uid: (data.uid as string) ?? '', + name: (data.name as string) ?? '', + type: (data.type as string) ?? '', + settings: (data.settings as Record) ?? {}, + disableResolveMessage: (data.disableResolveMessage as boolean) ?? false, + provenance: (data.provenance as string) ?? '', + }, + } + }, + + outputs: { + uid: { type: 'string', description: 'UID of the created contact point' }, + name: { type: 'string', description: 'Name of the contact point' }, + type: { type: 'string', description: 'Receiver type' }, + settings: { type: 'json', description: 'Type-specific settings' }, + disableResolveMessage: { + type: 'boolean', + description: 'Whether resolve notifications are suppressed', + }, + provenance: { type: 'string', description: 'Provisioning source (empty if API-managed)' }, + }, +} diff --git a/apps/sim/tools/grafana/delete_alert_rule.ts b/apps/sim/tools/grafana/delete_alert_rule.ts index 8910617b96a..68484c5244c 100644 --- a/apps/sim/tools/grafana/delete_alert_rule.ts +++ b/apps/sim/tools/grafana/delete_alert_rule.ts @@ -42,7 +42,7 @@ export const deleteAlertRuleTool: ToolConfig< request: { url: (params) => - `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid}`, + `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`, method: 'DELETE', headers: (params) => { const headers: Record = { diff --git a/apps/sim/tools/grafana/delete_dashboard.ts b/apps/sim/tools/grafana/delete_dashboard.ts index 5c251150d38..f8da6dd0103 100644 --- a/apps/sim/tools/grafana/delete_dashboard.ts +++ b/apps/sim/tools/grafana/delete_dashboard.ts @@ -42,7 +42,7 @@ export const deleteDashboardTool: ToolConfig< request: { url: (params) => - `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/uid/${params.dashboardUid}`, + `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/uid/${params.dashboardUid.trim()}`, method: 'DELETE', headers: (params) => { const headers: Record = { diff --git a/apps/sim/tools/grafana/delete_folder.ts b/apps/sim/tools/grafana/delete_folder.ts new file mode 100644 index 00000000000..b2c851480c5 --- /dev/null +++ b/apps/sim/tools/grafana/delete_folder.ts @@ -0,0 +1,79 @@ +import type { GrafanaDeleteFolderParams, GrafanaDeleteFolderResponse } from '@/tools/grafana/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteFolderTool: ToolConfig = + { + id: 'grafana_delete_folder', + name: 'Grafana Delete Folder', + description: 'Delete a folder by its UID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana Service Account Token', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana instance URL (e.g., https://your-grafana.com)', + }, + organizationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)', + }, + folderUid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UID of the folder to delete (e.g., folder-abc123)', + }, + forceDeleteRules: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Delete any alert rules stored in the folder along with it (default false)', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.baseUrl.replace(/\/$/, '') + const query = params.forceDeleteRules ? '?forceDeleteRules=true' : '' + return `${baseUrl}/api/folders/${params.folderUid.trim()}${query}` + }, + method: 'DELETE', + headers: (params) => { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + return headers + }, + }, + + transformResponse: async (response: Response, params) => { + const data = await response.json().catch(() => ({})) + + return { + success: true, + output: { + uid: params?.folderUid?.trim() ?? '', + message: (data.message as string) ?? 'Folder deleted', + }, + } + }, + + outputs: { + uid: { type: 'string', description: 'The UID of the deleted folder' }, + message: { type: 'string', description: 'Confirmation message' }, + }, + } diff --git a/apps/sim/tools/grafana/get_alert_rule.ts b/apps/sim/tools/grafana/get_alert_rule.ts index 1389872c620..7a6d85b116f 100644 --- a/apps/sim/tools/grafana/get_alert_rule.ts +++ b/apps/sim/tools/grafana/get_alert_rule.ts @@ -42,7 +42,7 @@ export const getAlertRuleTool: ToolConfig - `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid}`, + `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`, method: 'GET', headers: (params) => { const headers: Record = { diff --git a/apps/sim/tools/grafana/get_dashboard.ts b/apps/sim/tools/grafana/get_dashboard.ts index 7af81bd6780..e72b9d00e45 100644 --- a/apps/sim/tools/grafana/get_dashboard.ts +++ b/apps/sim/tools/grafana/get_dashboard.ts @@ -37,7 +37,7 @@ export const getDashboardTool: ToolConfig - `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/uid/${params.dashboardUid}`, + `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/uid/${params.dashboardUid.trim()}`, method: 'GET', headers: (params) => { const headers: Record = { diff --git a/apps/sim/tools/grafana/get_data_source.ts b/apps/sim/tools/grafana/get_data_source.ts index 1ef7bfaa1bc..d20f9ed7850 100644 --- a/apps/sim/tools/grafana/get_data_source.ts +++ b/apps/sim/tools/grafana/get_data_source.ts @@ -44,8 +44,6 @@ export const getDataSourceTool: ToolConfig< url: (params) => { const baseUrl = params.baseUrl.replace(/\/$/, '') const id = params.dataSourceId.trim() - // Numeric DB id route only matches purely-numeric ids up to int64 length; - // anything else is treated as a UID (Grafana UIDs are short slug strings). const isNumericId = /^\d+$/.test(id) && id.length <= 18 if (isNumericId) { return `${baseUrl}/api/datasources/${id}` diff --git a/apps/sim/tools/grafana/get_folder.ts b/apps/sim/tools/grafana/get_folder.ts new file mode 100644 index 00000000000..267bf5f6b42 --- /dev/null +++ b/apps/sim/tools/grafana/get_folder.ts @@ -0,0 +1,134 @@ +import type { GrafanaGetFolderParams, GrafanaGetFolderResponse } from '@/tools/grafana/types' +import type { ToolConfig } from '@/tools/types' + +export const getFolderTool: ToolConfig = { + id: 'grafana_get_folder', + name: 'Grafana Get Folder', + description: 'Get a folder by its UID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana Service Account Token', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana instance URL (e.g., https://your-grafana.com)', + }, + organizationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)', + }, + folderUid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UID of the folder to retrieve (e.g., folder-abc123)', + }, + }, + + request: { + url: (params) => `${params.baseUrl.replace(/\/$/, '')}/api/folders/${params.folderUid.trim()}`, + method: 'GET', + headers: (params) => { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + return headers + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + id: (data.id as number) ?? null, + uid: (data.uid as string) ?? null, + title: (data.title as string) ?? null, + url: (data.url as string) ?? null, + parentUid: (data.parentUid as string) ?? null, + parents: (data.parents as { uid: string; title: string; url: string }[]) ?? [], + hasAcl: (data.hasAcl as boolean) ?? null, + canSave: (data.canSave as boolean) ?? null, + canEdit: (data.canEdit as boolean) ?? null, + canAdmin: (data.canAdmin as boolean) ?? null, + createdBy: (data.createdBy as string) ?? null, + created: (data.created as string) ?? null, + updatedBy: (data.updatedBy as string) ?? null, + updated: (data.updated as string) ?? null, + version: (data.version as number) ?? null, + }, + } + }, + + outputs: { + id: { type: 'number', description: 'The numeric ID of the folder' }, + uid: { type: 'string', description: 'The UID of the folder' }, + title: { type: 'string', description: 'The title of the folder' }, + url: { type: 'string', description: 'The URL path to the folder', optional: true }, + parentUid: { + type: 'string', + description: 'Parent folder UID (nested folders only)', + optional: true, + }, + parents: { + type: 'array', + description: 'Ancestor folder hierarchy (nested folders only)', + optional: true, + }, + hasAcl: { + type: 'boolean', + description: 'Whether the folder has custom ACL permissions', + optional: true, + }, + canSave: { + type: 'boolean', + description: 'Whether the current user can save the folder', + optional: true, + }, + canEdit: { + type: 'boolean', + description: 'Whether the current user can edit the folder', + optional: true, + }, + canAdmin: { + type: 'boolean', + description: 'Whether the current user has admin rights on the folder', + optional: true, + }, + createdBy: { + type: 'string', + description: 'Username of who created the folder', + optional: true, + }, + created: { + type: 'string', + description: 'Timestamp when the folder was created', + optional: true, + }, + updatedBy: { + type: 'string', + description: 'Username of who last updated the folder', + optional: true, + }, + updated: { + type: 'string', + description: 'Timestamp when the folder was last updated', + optional: true, + }, + version: { type: 'number', description: 'Version number of the folder', optional: true }, + }, +} diff --git a/apps/sim/tools/grafana/get_health.ts b/apps/sim/tools/grafana/get_health.ts new file mode 100644 index 00000000000..5b35c529422 --- /dev/null +++ b/apps/sim/tools/grafana/get_health.ts @@ -0,0 +1,64 @@ +import type { GrafanaHealthCheckParams, GrafanaHealthCheckResponse } from '@/tools/grafana/types' +import type { ToolConfig } from '@/tools/types' + +export const getHealthTool: ToolConfig = { + id: 'grafana_get_health', + name: 'Grafana Get Health', + description: 'Check the health of the Grafana instance (version, database status)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana Service Account Token', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana instance URL (e.g., https://your-grafana.com)', + }, + organizationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)', + }, + }, + + request: { + url: (params) => `${params.baseUrl.replace(/\/$/, '')}/api/health`, + method: 'GET', + headers: (params) => { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + return headers + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + commit: (data.commit as string) ?? '', + database: (data.database as string) ?? '', + version: (data.version as string) ?? '', + }, + } + }, + + outputs: { + commit: { type: 'string', description: 'Git commit hash of the running Grafana build' }, + database: { type: 'string', description: 'Database health status (e.g., ok)' }, + version: { type: 'string', description: 'Grafana version' }, + }, +} diff --git a/apps/sim/tools/grafana/index.ts b/apps/sim/tools/grafana/index.ts index d911a193a8c..3dfd0c2285b 100644 --- a/apps/sim/tools/grafana/index.ts +++ b/apps/sim/tools/grafana/index.ts @@ -1,13 +1,18 @@ +import { checkDataSourceHealthTool } from '@/tools/grafana/check_data_source_health' import { createAlertRuleTool } from '@/tools/grafana/create_alert_rule' import { createAnnotationTool } from '@/tools/grafana/create_annotation' +import { createContactPointTool } from '@/tools/grafana/create_contact_point' import { createDashboardTool } from '@/tools/grafana/create_dashboard' import { createFolderTool } from '@/tools/grafana/create_folder' import { deleteAlertRuleTool } from '@/tools/grafana/delete_alert_rule' import { deleteAnnotationTool } from '@/tools/grafana/delete_annotation' import { deleteDashboardTool } from '@/tools/grafana/delete_dashboard' +import { deleteFolderTool } from '@/tools/grafana/delete_folder' import { getAlertRuleTool } from '@/tools/grafana/get_alert_rule' import { getDashboardTool } from '@/tools/grafana/get_dashboard' import { getDataSourceTool } from '@/tools/grafana/get_data_source' +import { getFolderTool } from '@/tools/grafana/get_folder' +import { getHealthTool } from '@/tools/grafana/get_health' import { listAlertRulesTool } from '@/tools/grafana/list_alert_rules' import { listAnnotationsTool } from '@/tools/grafana/list_annotations' import { listContactPointsTool } from '@/tools/grafana/list_contact_points' @@ -17,32 +22,35 @@ import { listFoldersTool } from '@/tools/grafana/list_folders' import { updateAlertRuleTool } from '@/tools/grafana/update_alert_rule' import { updateAnnotationTool } from '@/tools/grafana/update_annotation' import { updateDashboardTool } from '@/tools/grafana/update_dashboard' +import { updateFolderTool } from '@/tools/grafana/update_folder' -// Dashboard tools export const grafanaGetDashboardTool = getDashboardTool export const grafanaListDashboardsTool = listDashboardsTool export const grafanaCreateDashboardTool = createDashboardTool export const grafanaUpdateDashboardTool = updateDashboardTool export const grafanaDeleteDashboardTool = deleteDashboardTool -// Alert tools export const grafanaListAlertRulesTool = listAlertRulesTool export const grafanaGetAlertRuleTool = getAlertRuleTool export const grafanaCreateAlertRuleTool = createAlertRuleTool export const grafanaUpdateAlertRuleTool = updateAlertRuleTool export const grafanaDeleteAlertRuleTool = deleteAlertRuleTool export const grafanaListContactPointsTool = listContactPointsTool +export const grafanaCreateContactPointTool = createContactPointTool -// Annotation tools export const grafanaCreateAnnotationTool = createAnnotationTool export const grafanaListAnnotationsTool = listAnnotationsTool export const grafanaUpdateAnnotationTool = updateAnnotationTool export const grafanaDeleteAnnotationTool = deleteAnnotationTool -// Data Source tools export const grafanaListDataSourcesTool = listDataSourcesTool export const grafanaGetDataSourceTool = getDataSourceTool +export const grafanaCheckDataSourceHealthTool = checkDataSourceHealthTool -// Folder tools export const grafanaListFoldersTool = listFoldersTool export const grafanaCreateFolderTool = createFolderTool +export const grafanaGetFolderTool = getFolderTool +export const grafanaUpdateFolderTool = updateFolderTool +export const grafanaDeleteFolderTool = deleteFolderTool + +export const grafanaGetHealthTool = getHealthTool diff --git a/apps/sim/tools/grafana/types.ts b/apps/sim/tools/grafana/types.ts index dcab4638e69..a3467d69bd0 100644 --- a/apps/sim/tools/grafana/types.ts +++ b/apps/sim/tools/grafana/types.ts @@ -1,4 +1,3 @@ -// Common types for Grafana API tools import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -43,17 +42,15 @@ export const ALERT_RULE_OUTPUT_FIELDS: Record = { }, } -// Common parameters for all Grafana tools interface GrafanaBaseParams { apiKey: string baseUrl: string organizationId?: string } -// Health Check types -interface GrafanaHealthCheckParams extends GrafanaBaseParams {} +export interface GrafanaHealthCheckParams extends GrafanaBaseParams {} -interface GrafanaHealthCheckResponse extends ToolResponse { +export interface GrafanaHealthCheckResponse extends ToolResponse { output: { commit: string database: string @@ -61,18 +58,17 @@ interface GrafanaHealthCheckResponse extends ToolResponse { } } -interface GrafanaDataSourceHealthParams extends GrafanaBaseParams { - dataSourceId: string +export interface GrafanaDataSourceHealthParams extends GrafanaBaseParams { + dataSourceUid: string } -interface GrafanaDataSourceHealthResponse extends ToolResponse { +export interface GrafanaDataSourceHealthResponse extends ToolResponse { output: { status: string message: string } } -// Dashboard types export interface GrafanaGetDashboardParams extends GrafanaBaseParams { dashboardUid: string } @@ -164,7 +160,7 @@ export interface GrafanaCreateDashboardParams extends GrafanaBaseParams { tags?: string timezone?: string refresh?: string - panels?: string // JSON string of panels array + panels?: string overwrite?: boolean message?: string } @@ -187,7 +183,7 @@ export interface GrafanaUpdateDashboardParams extends GrafanaBaseParams { tags?: string timezone?: string refresh?: string - panels?: string // JSON string of panels array + panels?: string overwrite?: boolean message?: string } @@ -215,7 +211,6 @@ export interface GrafanaDeleteDashboardResponse extends ToolResponse { } } -// Alert Rule types export interface GrafanaListAlertRulesParams extends GrafanaBaseParams {} interface GrafanaAlertRule { @@ -260,18 +255,18 @@ export interface GrafanaCreateAlertRuleParams extends GrafanaBaseParams { folderUid: string ruleGroup: string condition?: string - data: string // JSON string of data array + data: string forDuration?: string noDataState?: string execErrState?: string - annotations?: string // JSON string - labels?: string // JSON string + annotations?: string + labels?: string uid?: string isPaused?: boolean keepFiringFor?: string missingSeriesEvalsToResolve?: number - notificationSettings?: string // JSON string - record?: string // JSON string + notificationSettings?: string + record?: string disableProvenance?: boolean } @@ -285,17 +280,17 @@ export interface GrafanaUpdateAlertRuleParams extends GrafanaBaseParams { folderUid?: string ruleGroup?: string condition?: string - data?: string // JSON string of data array + data?: string forDuration?: string noDataState?: string execErrState?: string - annotations?: string // JSON string - labels?: string // JSON string + annotations?: string + labels?: string isPaused?: boolean keepFiringFor?: string missingSeriesEvalsToResolve?: number - notificationSettings?: string // JSON string - record?: string // JSON string + notificationSettings?: string + record?: string disableProvenance?: boolean } @@ -313,14 +308,13 @@ export interface GrafanaDeleteAlertRuleResponse extends ToolResponse { } } -// Annotation types export interface GrafanaCreateAnnotationParams extends GrafanaBaseParams { text: string - tags?: string // comma-separated + tags?: string dashboardUid?: string panelId?: number - time?: number // epoch ms - timeEnd?: number // epoch ms + time?: number + timeEnd?: number } interface GrafanaAnnotation { @@ -356,7 +350,7 @@ export interface GrafanaListAnnotationsParams extends GrafanaBaseParams { panelId?: number alertId?: number userId?: number - tags?: string // comma-separated + tags?: string type?: string limit?: number } @@ -370,7 +364,7 @@ export interface GrafanaListAnnotationsResponse extends ToolResponse { export interface GrafanaUpdateAnnotationParams extends GrafanaBaseParams { annotationId: number text?: string - tags?: string // comma-separated + tags?: string time?: number timeEnd?: number } @@ -392,7 +386,6 @@ export interface GrafanaDeleteAnnotationResponse extends ToolResponse { } } -// Data Source types export interface GrafanaListDataSourcesParams extends GrafanaBaseParams {} interface GrafanaDataSource { @@ -430,7 +423,6 @@ export interface GrafanaGetDataSourceResponse extends ToolResponse { output: GrafanaDataSource } -// Folder types export interface GrafanaListFoldersParams extends GrafanaBaseParams { limit?: number page?: number @@ -477,7 +469,35 @@ export interface GrafanaCreateFolderResponse extends ToolResponse { output: GrafanaFolder } -// Contact Points types +export interface GrafanaGetFolderParams extends GrafanaBaseParams { + folderUid: string +} + +export interface GrafanaGetFolderResponse extends ToolResponse { + output: GrafanaFolder +} + +export interface GrafanaUpdateFolderParams extends GrafanaBaseParams { + folderUid: string + title?: string +} + +export interface GrafanaUpdateFolderResponse extends ToolResponse { + output: GrafanaFolder +} + +export interface GrafanaDeleteFolderParams extends GrafanaBaseParams { + folderUid: string + forceDeleteRules?: boolean +} + +export interface GrafanaDeleteFolderResponse extends ToolResponse { + output: { + uid: string + message: string + } +} + export interface GrafanaListContactPointsParams extends GrafanaBaseParams { name?: string } @@ -497,7 +517,25 @@ export interface GrafanaListContactPointsResponse extends ToolResponse { } } -// Union type for all Grafana responses +export interface GrafanaCreateContactPointParams extends GrafanaBaseParams { + name: string + type: string + settings: string + disableResolveMessage?: boolean + disableProvenance?: boolean +} + +export interface GrafanaCreateContactPointResponse extends ToolResponse { + output: { + uid: string + name: string + type: string + settings: Record + disableResolveMessage: boolean + provenance: string + } +} + export type GrafanaResponse = | GrafanaHealthCheckResponse | GrafanaDataSourceHealthResponse @@ -519,4 +557,8 @@ export type GrafanaResponse = | GrafanaGetDataSourceResponse | GrafanaListFoldersResponse | GrafanaCreateFolderResponse + | GrafanaGetFolderResponse + | GrafanaUpdateFolderResponse + | GrafanaDeleteFolderResponse | GrafanaListContactPointsResponse + | GrafanaCreateContactPointResponse diff --git a/apps/sim/tools/grafana/update_alert_rule.ts b/apps/sim/tools/grafana/update_alert_rule.ts index ba474e2ed90..1afe913709d 100644 --- a/apps/sim/tools/grafana/update_alert_rule.ts +++ b/apps/sim/tools/grafana/update_alert_rule.ts @@ -1,9 +1,4 @@ -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' import { ALERT_RULE_OUTPUT_FIELDS, type GrafanaUpdateAlertRuleParams } from '@/tools/grafana/types' -import { mapAlertRule } from '@/tools/grafana/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' export const updateAlertRuleTool: ToolConfig = { @@ -136,163 +131,40 @@ export const updateAlertRuleTool: ToolConfig - `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid}`, - method: 'GET', - headers: (params) => { - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.apiKey}`, - } - if (params.organizationId) { - headers['X-Grafana-Org-Id'] = params.organizationId - } - return headers - }, + url: () => '/api/tools/grafana/update_alert_rule', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + baseUrl: params.baseUrl, + organizationId: params.organizationId, + alertRuleUid: params.alertRuleUid, + title: params.title, + folderUid: params.folderUid, + ruleGroup: params.ruleGroup, + condition: params.condition, + data: params.data, + forDuration: params.forDuration, + noDataState: params.noDataState, + execErrState: params.execErrState, + annotations: params.annotations, + labels: params.labels, + isPaused: params.isPaused, + keepFiringFor: params.keepFiringFor, + missingSeriesEvalsToResolve: params.missingSeriesEvalsToResolve, + notificationSettings: params.notificationSettings, + record: params.record, + disableProvenance: params.disableProvenance, + }), }, transformResponse: async (response: Response) => { const data = await response.json() return { - success: true, - output: { - _existingRule: data, - }, - } - }, - - postProcess: async (result, params) => { - const existingRule = result.output._existingRule - - if (!existingRule || !existingRule.uid) { - return { - success: false, - output: {}, - error: 'Failed to fetch existing alert rule', - } - } - - const updatedRule: Record = { - ...existingRule, - } - - if (params.title) updatedRule.title = params.title - if (params.folderUid) updatedRule.folderUID = params.folderUid - if (params.ruleGroup) updatedRule.ruleGroup = params.ruleGroup - if (params.condition) updatedRule.condition = params.condition - if (params.forDuration) updatedRule.for = params.forDuration - if (params.noDataState) updatedRule.noDataState = params.noDataState - if (params.execErrState) updatedRule.execErrState = params.execErrState - if (params.isPaused !== undefined) updatedRule.isPaused = params.isPaused - if (params.keepFiringFor) updatedRule.keep_firing_for = params.keepFiringFor - if (params.missingSeriesEvalsToResolve !== undefined) { - updatedRule.missingSeriesEvalsToResolve = params.missingSeriesEvalsToResolve - } - - if (params.notificationSettings) { - try { - updatedRule.notification_settings = JSON.parse(params.notificationSettings) - } catch { - return { - success: false, - output: {}, - error: 'Invalid JSON for notificationSettings parameter', - } - } - } - - if (params.record) { - try { - updatedRule.record = JSON.parse(params.record) - } catch { - return { - success: false, - output: {}, - error: 'Invalid JSON for record parameter', - } - } - } - - if (params.data) { - try { - updatedRule.data = JSON.parse(params.data) - } catch { - return { - success: false, - output: {}, - error: 'Invalid JSON for data parameter', - } - } + success: data.success ?? true, + output: data.output ?? {}, + ...(data.error ? { error: data.error } : {}), } - - if (params.annotations) { - try { - updatedRule.annotations = { - ...(existingRule.annotations || {}), - ...JSON.parse(params.annotations), - } - } catch { - return { - success: false, - output: {}, - error: 'Invalid JSON for annotations parameter', - } - } - } - - if (params.labels) { - try { - updatedRule.labels = { - ...(existingRule.labels || {}), - ...JSON.parse(params.labels), - } - } catch { - return { - success: false, - output: {}, - error: 'Invalid JSON for labels parameter', - } - } - } - - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.apiKey}`, - } - if (params.organizationId) { - headers['X-Grafana-Org-Id'] = params.organizationId - } - if (params.disableProvenance) { - headers['X-Disable-Provenance'] = 'true' - } - - const updateUrl = `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid}` - const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl') - if (!urlValidation.isValid || !urlValidation.resolvedIP) { - return { - success: false, - output: {}, - error: `Invalid Grafana baseUrl: ${urlValidation.error}`, - } - } - - const updateResponse = await secureFetchWithPinnedIP(updateUrl, urlValidation.resolvedIP, { - method: 'PUT', - headers, - body: JSON.stringify(updatedRule), - }) - - if (!updateResponse.ok) { - const errorText = await updateResponse.text() - return { - success: false, - output: {}, - error: `Failed to update alert rule: ${errorText}`, - } - } - - const data = (await updateResponse.json()) as Record - return { success: true, output: mapAlertRule(data) } }, outputs: ALERT_RULE_OUTPUT_FIELDS, diff --git a/apps/sim/tools/grafana/update_dashboard.ts b/apps/sim/tools/grafana/update_dashboard.ts index 8441a52a398..70026f73d6f 100644 --- a/apps/sim/tools/grafana/update_dashboard.ts +++ b/apps/sim/tools/grafana/update_dashboard.ts @@ -1,7 +1,3 @@ -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' import type { GrafanaUpdateDashboardParams } from '@/tools/grafana/types' import type { ToolConfig, ToolResponse } from '@/tools/types' @@ -89,136 +85,31 @@ export const updateDashboardTool: ToolConfig - `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/uid/${params.dashboardUid}`, - method: 'GET', - headers: (params) => { - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.apiKey}`, - } - if (params.organizationId) { - headers['X-Grafana-Org-Id'] = params.organizationId - } - return headers - }, + url: () => '/api/tools/grafana/update_dashboard', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + baseUrl: params.baseUrl, + organizationId: params.organizationId, + dashboardUid: params.dashboardUid, + title: params.title, + folderUid: params.folderUid, + tags: params.tags, + timezone: params.timezone, + refresh: params.refresh, + panels: params.panels, + overwrite: params.overwrite, + message: params.message, + }), }, transformResponse: async (response: Response) => { const data = await response.json() return { - success: true, - output: { - _existingDashboard: data.dashboard, - _existingMeta: data.meta, - }, - } - }, - - postProcess: async (result, params) => { - const existingDashboard = result.output._existingDashboard - const existingMeta = result.output._existingMeta - - if (!existingDashboard || !existingDashboard.uid) { - return { - success: false, - output: {}, - error: 'Failed to fetch existing dashboard', - } - } - - const updatedDashboard: Record = { - ...existingDashboard, - } - - if (params.title) updatedDashboard.title = params.title - if (params.timezone) updatedDashboard.timezone = params.timezone - if (params.refresh) updatedDashboard.refresh = params.refresh - - if (params.tags) { - updatedDashboard.tags = params.tags - .split(',') - .map((t) => t.trim()) - .filter((t) => t) - } - - if (params.panels) { - try { - updatedDashboard.panels = JSON.parse(params.panels) - } catch {} - } - - if (existingDashboard.version) { - updatedDashboard.version = existingDashboard.version - } - - const body: Record = { - dashboard: updatedDashboard, - overwrite: params.overwrite === true, - } - - if (params.folderUid) { - body.folderUid = params.folderUid - } else if (existingMeta?.folderUid) { - body.folderUid = existingMeta.folderUid - } - - if (params.message) { - body.message = params.message - } - - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.apiKey}`, - } - if (params.organizationId) { - headers['X-Grafana-Org-Id'] = params.organizationId - } - - const updateUrl = `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/db` - const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl') - if (!urlValidation.isValid || !urlValidation.resolvedIP) { - return { - success: false, - output: {}, - error: `Invalid Grafana baseUrl: ${urlValidation.error}`, - } - } - - const updateResponse = await secureFetchWithPinnedIP(updateUrl, urlValidation.resolvedIP, { - method: 'POST', - headers, - body: JSON.stringify(body), - }) - - if (!updateResponse.ok) { - const errorText = await updateResponse.text() - return { - success: false, - output: {}, - error: `Failed to update dashboard: ${errorText}`, - } - } - - const data = (await updateResponse.json()) as { - id?: number - uid?: string - url?: string - status?: string - version?: number - slug?: string - } - - return { - success: true, - output: { - id: data.id, - uid: data.uid, - url: data.url, - status: data.status, - version: data.version, - slug: data.slug, - }, + success: data.success ?? true, + output: data.output ?? {}, + ...(data.error ? { error: data.error } : {}), } }, diff --git a/apps/sim/tools/grafana/update_folder.ts b/apps/sim/tools/grafana/update_folder.ts new file mode 100644 index 00000000000..3c4b16c0889 --- /dev/null +++ b/apps/sim/tools/grafana/update_folder.ts @@ -0,0 +1,122 @@ +import type { GrafanaUpdateFolderParams } from '@/tools/grafana/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export const updateFolderTool: ToolConfig = { + id: 'grafana_update_folder', + name: 'Grafana Update Folder', + description: 'Update (rename) a folder. Fetches the current folder and merges your changes.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana Service Account Token', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grafana instance URL (e.g., https://your-grafana.com)', + }, + organizationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)', + }, + folderUid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UID of the folder to update (e.g., folder-abc123)', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New title for the folder', + }, + }, + + request: { + url: () => '/api/tools/grafana/update_folder', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + baseUrl: params.baseUrl, + organizationId: params.organizationId, + folderUid: params.folderUid, + title: params.title, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: data.success ?? true, + output: data.output ?? {}, + ...(data.error ? { error: data.error } : {}), + } + }, + + outputs: { + id: { type: 'number', description: 'The numeric ID of the folder' }, + uid: { type: 'string', description: 'The UID of the folder' }, + title: { type: 'string', description: 'The updated title of the folder' }, + url: { type: 'string', description: 'The URL path to the folder', optional: true }, + parentUid: { + type: 'string', + description: 'Parent folder UID (nested folders only)', + optional: true, + }, + parents: { + type: 'array', + description: 'Ancestor folder hierarchy (nested folders only)', + optional: true, + }, + hasAcl: { + type: 'boolean', + description: 'Whether the folder has custom ACL permissions', + optional: true, + }, + canSave: { + type: 'boolean', + description: 'Whether the current user can save the folder', + optional: true, + }, + canEdit: { + type: 'boolean', + description: 'Whether the current user can edit the folder', + optional: true, + }, + canAdmin: { + type: 'boolean', + description: 'Whether the current user has admin rights on the folder', + optional: true, + }, + createdBy: { + type: 'string', + description: 'Username of who created the folder', + optional: true, + }, + created: { + type: 'string', + description: 'Timestamp when the folder was created', + optional: true, + }, + updatedBy: { + type: 'string', + description: 'Username of who last updated the folder', + optional: true, + }, + updated: { + type: 'string', + description: 'Timestamp when the folder was last updated', + optional: true, + }, + version: { type: 'number', description: 'Version number of the folder', optional: true }, + }, +} diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index fc22a66fab8..0b4d4ccbd58 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -51,7 +51,7 @@ const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPi const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS // Mock feature flags -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockIsHosted.value }, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index ca122a6c054..28eb17aaebe 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -4,7 +4,7 @@ import { sleep } from '@sim/utils/helpers' 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 { isHosted } from '@/lib/core/config/env-flags' import { DEFAULT_EXECUTION_TIMEOUT_MS, getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter' import { diff --git a/apps/sim/tools/jsm/create_object.ts b/apps/sim/tools/jsm/create_object.ts new file mode 100644 index 00000000000..74c1dd497f0 --- /dev/null +++ b/apps/sim/tools/jsm/create_object.ts @@ -0,0 +1,97 @@ +import type { JsmCreateObjectParams, JsmCreateObjectResponse } from '@/tools/jsm/types' +import { ASSET_OBJECT_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmCreateObjectTool: ToolConfig = { + id: 'jsm_create_object', + name: 'JSM Create Asset Object', + description: + 'Create an Assets (Insight/CMDB) object of a given object type. Attributes use objectTypeAttributeId values from the object type definition.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectTypeId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object type ID to create the object under', + }, + attributes: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of attributes: [{ objectTypeAttributeId, objectAttributeValues: [{ value }] }]', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/create', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectTypeId: params.objectTypeId?.trim(), + attributes: params.attributes, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), object: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), object: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + object: { + type: 'json', + description: 'The created Assets object', + properties: ASSET_OBJECT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/jsm/delete_object.ts b/apps/sim/tools/jsm/delete_object.ts new file mode 100644 index 00000000000..0568a016d00 --- /dev/null +++ b/apps/sim/tools/jsm/delete_object.ts @@ -0,0 +1,84 @@ +import type { JsmDeleteObjectParams, JsmDeleteObjectResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmDeleteObjectTool: ToolConfig = { + id: 'jsm_delete_object', + name: 'JSM Delete Asset Object', + description: 'Delete an Assets (Insight/CMDB) object by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object ID to delete', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/delete', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectId: params.objectId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), objectId: '', deleted: false }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), objectId: '', deleted: false }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + objectId: { type: 'string', description: 'The deleted object ID' }, + deleted: { type: 'boolean', description: 'Whether the object was deleted' }, + }, +} diff --git a/apps/sim/tools/jsm/get_object.ts b/apps/sim/tools/jsm/get_object.ts new file mode 100644 index 00000000000..182e509cd5b --- /dev/null +++ b/apps/sim/tools/jsm/get_object.ts @@ -0,0 +1,88 @@ +import type { JsmGetObjectParams, JsmGetObjectResponse } from '@/tools/jsm/types' +import { ASSET_OBJECT_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetObjectTool: ToolConfig = { + id: 'jsm_get_object', + name: 'JSM Get Asset Object', + description: 'Get a single Assets (Insight/CMDB) object by ID, including its attribute values', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object ID', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/get', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectId: params.objectId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), object: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), object: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + object: { + type: 'json', + description: 'The Assets object', + properties: ASSET_OBJECT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/jsm/get_object_schema.ts b/apps/sim/tools/jsm/get_object_schema.ts new file mode 100644 index 00000000000..62906cf00db --- /dev/null +++ b/apps/sim/tools/jsm/get_object_schema.ts @@ -0,0 +1,102 @@ +import type { JsmGetObjectSchemaParams, JsmGetObjectSchemaResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetObjectSchemaTool: ToolConfig< + JsmGetObjectSchemaParams, + JsmGetObjectSchemaResponse +> = { + id: 'jsm_get_object_schema', + name: 'JSM Get Asset Schema', + description: 'Get a single Assets (Insight/CMDB) object schema by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + schemaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object schema ID', + }, + }, + + request: { + url: '/api/tools/jsm/assets/schema', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + schemaId: params.schemaId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), schema: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), schema: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + schema: { + type: 'json', + description: 'The Assets object schema', + properties: { + id: { type: 'string', description: 'Schema ID' }, + name: { type: 'string', description: 'Schema name' }, + objectSchemaKey: { type: 'string', description: 'Schema key' }, + status: { type: 'string', description: 'Schema status' }, + description: { type: 'string', description: 'Schema description', optional: true }, + objectCount: { type: 'number', description: 'Number of objects', optional: true }, + objectTypeCount: { + type: 'number', + description: 'Number of object types', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jsm/get_object_type_attributes.ts b/apps/sim/tools/jsm/get_object_type_attributes.ts new file mode 100644 index 00000000000..5a58bd71bf3 --- /dev/null +++ b/apps/sim/tools/jsm/get_object_type_attributes.ts @@ -0,0 +1,136 @@ +import type { + JsmGetObjectTypeAttributesParams, + JsmGetObjectTypeAttributesResponse, +} from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetObjectTypeAttributesTool: ToolConfig< + JsmGetObjectTypeAttributesParams, + JsmGetObjectTypeAttributesResponse +> = { + id: 'jsm_get_object_type_attributes', + name: 'JSM Get Asset Object Type Attributes', + description: + 'Get the attribute definitions for an Assets (Insight/CMDB) object type. Use the returned attribute IDs to build create/update payloads or map columns.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectTypeId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object type ID', + }, + onlyValueEditable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return only attributes whose values can be edited', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter attributes by a search query', + }, + }, + + request: { + url: '/api/tools/jsm/assets/attributes', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectTypeId: params.objectTypeId?.trim(), + onlyValueEditable: params.onlyValueEditable, + query: params.query, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), attributes: [], total: 0 }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), attributes: [], total: 0 }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + attributes: { + type: 'array', + description: 'Attribute definitions for the object type', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Attribute definition ID — use as objectTypeAttributeId in create/update', + }, + name: { type: 'string', description: 'Attribute name' }, + label: { type: 'boolean', description: 'Whether this attribute is the object label' }, + type: { type: 'number', description: 'Data type discriminator (integer enum)' }, + defaultType: { + type: 'json', + description: 'Default data type { id, name }', + optional: true, + }, + editable: { type: 'boolean', description: 'Whether the value is editable' }, + minimumCardinality: { + type: 'number', + description: 'Minimum number of values (>= 1 means required)', + }, + maximumCardinality: { type: 'number', description: 'Maximum number of values' }, + uniqueAttribute: { + type: 'boolean', + description: 'Whether values must be unique', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of attributes' }, + }, +} diff --git a/apps/sim/tools/jsm/index.ts b/apps/sim/tools/jsm/index.ts index e11696f0f3a..6f4fe50bc0b 100644 --- a/apps/sim/tools/jsm/index.ts +++ b/apps/sim/tools/jsm/index.ts @@ -5,9 +5,11 @@ import { jsmAddParticipantsTool } from '@/tools/jsm/add_participants' import { jsmAnswerApprovalTool } from '@/tools/jsm/answer_approval' import { jsmAttachFormTool } from '@/tools/jsm/attach_form' import { jsmCopyFormsTool } from '@/tools/jsm/copy_forms' +import { jsmCreateObjectTool } from '@/tools/jsm/create_object' import { jsmCreateOrganizationTool } from '@/tools/jsm/create_organization' import { jsmCreateRequestTool } from '@/tools/jsm/create_request' import { jsmDeleteFormTool } from '@/tools/jsm/delete_form' +import { jsmDeleteObjectTool } from '@/tools/jsm/delete_object' import { jsmExternaliseFormTool } from '@/tools/jsm/externalise_form' import { jsmGetApprovalsTool } from '@/tools/jsm/get_approvals' import { jsmGetCommentsTool } from '@/tools/jsm/get_comments' @@ -17,6 +19,9 @@ import { jsmGetFormAnswersTool } from '@/tools/jsm/get_form_answers' import { jsmGetFormStructureTool } from '@/tools/jsm/get_form_structure' import { jsmGetFormTemplatesTool } from '@/tools/jsm/get_form_templates' import { jsmGetIssueFormsTool } from '@/tools/jsm/get_issue_forms' +import { jsmGetObjectTool } from '@/tools/jsm/get_object' +import { jsmGetObjectSchemaTool } from '@/tools/jsm/get_object_schema' +import { jsmGetObjectTypeAttributesTool } from '@/tools/jsm/get_object_type_attributes' import { jsmGetOrganizationsTool } from '@/tools/jsm/get_organizations' import { jsmGetParticipantsTool } from '@/tools/jsm/get_participants' import { jsmGetQueuesTool } from '@/tools/jsm/get_queues' @@ -28,10 +33,14 @@ import { jsmGetServiceDesksTool } from '@/tools/jsm/get_service_desks' import { jsmGetSlaTool } from '@/tools/jsm/get_sla' import { jsmGetTransitionsTool } from '@/tools/jsm/get_transitions' import { jsmInternaliseFormTool } from '@/tools/jsm/internalise_form' +import { jsmListObjectSchemasTool } from '@/tools/jsm/list_object_schemas' +import { jsmListObjectTypesTool } from '@/tools/jsm/list_object_types' import { jsmReopenFormTool } from '@/tools/jsm/reopen_form' import { jsmSaveFormAnswersTool } from '@/tools/jsm/save_form_answers' +import { jsmSearchObjectsAqlTool } from '@/tools/jsm/search_objects_aql' import { jsmSubmitFormTool } from '@/tools/jsm/submit_form' import { jsmTransitionRequestTool } from '@/tools/jsm/transition_request' +import { jsmUpdateObjectTool } from '@/tools/jsm/update_object' export { jsmAddCommentTool, @@ -41,9 +50,11 @@ export { jsmAnswerApprovalTool, jsmAttachFormTool, jsmCopyFormsTool, + jsmCreateObjectTool, jsmCreateOrganizationTool, jsmCreateRequestTool, jsmDeleteFormTool, + jsmDeleteObjectTool, jsmExternaliseFormTool, jsmGetApprovalsTool, jsmGetCommentsTool, @@ -53,6 +64,9 @@ export { jsmGetFormStructureTool, jsmGetFormTemplatesTool, jsmGetIssueFormsTool, + jsmGetObjectTool, + jsmGetObjectSchemaTool, + jsmGetObjectTypeAttributesTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, jsmGetQueuesTool, @@ -64,8 +78,12 @@ export { jsmGetSlaTool, jsmGetTransitionsTool, jsmInternaliseFormTool, + jsmListObjectSchemasTool, + jsmListObjectTypesTool, jsmReopenFormTool, jsmSaveFormAnswersTool, + jsmSearchObjectsAqlTool, jsmSubmitFormTool, jsmTransitionRequestTool, + jsmUpdateObjectTool, } diff --git a/apps/sim/tools/jsm/list_object_schemas.ts b/apps/sim/tools/jsm/list_object_schemas.ts new file mode 100644 index 00000000000..d08fbdb8527 --- /dev/null +++ b/apps/sim/tools/jsm/list_object_schemas.ts @@ -0,0 +1,126 @@ +import type { JsmListObjectSchemasParams, JsmListObjectSchemasResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmListObjectSchemasTool: ToolConfig< + JsmListObjectSchemasParams, + JsmListObjectSchemasResponse +> = { + id: 'jsm_list_object_schemas', + name: 'JSM List Asset Schemas', + description: 'List Assets (Insight/CMDB) object schemas in Jira Service Management', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + startAt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination start index (e.g., 0, 50)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum schemas to return (e.g., 25, 50)', + }, + includeCounts: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include object and object-type counts per schema', + }, + }, + + request: { + url: '/api/tools/jsm/assets/schemas', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + startAt: params.startAt, + maxResults: params.maxResults, + includeCounts: params.includeCounts, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), schemas: [], total: 0, isLast: true }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + schemas: [], + total: 0, + isLast: true, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + schemas: { + type: 'array', + description: 'List of Assets object schemas', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Schema ID' }, + name: { type: 'string', description: 'Schema name' }, + objectSchemaKey: { type: 'string', description: 'Schema key' }, + status: { type: 'string', description: 'Schema status' }, + description: { type: 'string', description: 'Schema description', optional: true }, + objectCount: { type: 'number', description: 'Number of objects', optional: true }, + objectTypeCount: { + type: 'number', + description: 'Number of object types', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of schemas' }, + isLast: { type: 'boolean', description: 'Whether this is the last page' }, + }, +} diff --git a/apps/sim/tools/jsm/list_object_types.ts b/apps/sim/tools/jsm/list_object_types.ts new file mode 100644 index 00000000000..324963549c1 --- /dev/null +++ b/apps/sim/tools/jsm/list_object_types.ts @@ -0,0 +1,117 @@ +import type { JsmListObjectTypesParams, JsmListObjectTypesResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmListObjectTypesTool: ToolConfig< + JsmListObjectTypesParams, + JsmListObjectTypesResponse +> = { + id: 'jsm_list_object_types', + name: 'JSM List Asset Object Types', + description: 'List object types within an Assets (Insight/CMDB) object schema', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + schemaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object schema ID to list object types for', + }, + excludeAbstract: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Exclude abstract object types from the result', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object-types', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + schemaId: params.schemaId?.trim(), + excludeAbstract: params.excludeAbstract, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), objectTypes: [], total: 0 }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), objectTypes: [], total: 0 }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + objectTypes: { + type: 'array', + description: 'List of object types in the schema', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Object type ID' }, + name: { type: 'string', description: 'Object type name' }, + description: { type: 'string', description: 'Object type description', optional: true }, + objectSchemaId: { type: 'string', description: 'Parent schema ID' }, + objectCount: { type: 'number', description: 'Number of objects', optional: true }, + abstractObjectType: { + type: 'boolean', + description: 'Whether the type is abstract', + optional: true, + }, + inherited: { + type: 'boolean', + description: 'Whether the type inherits attributes', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of object types' }, + }, +} diff --git a/apps/sim/tools/jsm/search_objects_aql.ts b/apps/sim/tools/jsm/search_objects_aql.ts new file mode 100644 index 00000000000..872cc90cc22 --- /dev/null +++ b/apps/sim/tools/jsm/search_objects_aql.ts @@ -0,0 +1,150 @@ +import type { JsmSearchObjectsAqlParams, JsmSearchObjectsAqlResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmSearchObjectsAqlTool: ToolConfig< + JsmSearchObjectsAqlParams, + JsmSearchObjectsAqlResponse +> = { + id: 'jsm_search_objects_aql', + name: 'JSM Search Assets (AQL)', + description: + 'Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. objectType = "Host" AND Status = "Running". Supports pagination.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + qlQuery: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'AQL query string (e.g., objectType = "Host" AND "Operating System" = "Ubuntu")', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number (1-based, defaults to 1)', + }, + resultsPerPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Results per page (e.g., 25, 50)', + }, + includeAttributes: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include resolved attribute values on each object (defaults to true)', + }, + objectTypeId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optionally scope the search to a single object type ID', + }, + objectSchemaId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optionally scope the search to a single object schema ID', + }, + }, + + request: { + url: '/api/tools/jsm/assets/search', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + qlQuery: params.qlQuery, + page: params.page, + resultsPerPage: params.resultsPerPage, + includeAttributes: params.includeAttributes, + objectTypeId: params.objectTypeId, + objectSchemaId: params.objectSchemaId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + objects: [], + total: 0, + pageNumber: 0, + pageSize: 0, + }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + objects: [], + total: 0, + pageNumber: 0, + pageSize: 0, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + objects: { + type: 'array', + description: 'Matching Assets objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Object ID' }, + label: { type: 'string', description: 'Object label', optional: true }, + objectKey: { type: 'string', description: 'Object key (e.g., HOST-123)', optional: true }, + objectType: { type: 'json', description: 'Object type metadata', optional: true }, + attributes: { type: 'json', description: 'Resolved attribute values', optional: true }, + }, + }, + }, + total: { type: 'number', description: 'Total number of matching objects (totalFilterCount)' }, + pageNumber: { type: 'number', description: 'Current page number' }, + pageSize: { type: 'number', description: 'Number of objects on this page' }, + }, +} diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index a0160854bba..e35c97c74c6 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -1080,3 +1080,229 @@ export type JsmResponse = | JsmCopyFormsResponse | JsmGetFormAnswersResponse | JsmReopenFormResponse + | JsmListObjectSchemasResponse + | JsmGetObjectSchemaResponse + | JsmListObjectTypesResponse + | JsmGetObjectTypeAttributesResponse + | JsmSearchObjectsAqlResponse + | JsmGetObjectResponse + | JsmCreateObjectResponse + | JsmUpdateObjectResponse + | JsmDeleteObjectResponse + +/** + * JSM Assets (Insight / CMDB) tool types. + * + * The Assets API is keyed by an Assets `workspaceId` (resolved server-side from + * the Jira `cloudId`). All tools share {@link JsmAssetsBaseParams}. + */ + +/** Base params shared by every JSM Assets tool */ +export interface JsmAssetsBaseParams { + accessToken: string + domain: string + /** Jira Cloud ID (resolved server-side from the domain when omitted) */ + cloudId?: string + /** Assets workspace ID (resolved server-side from the cloudId when omitted) */ + workspaceId?: string +} + +/** A single attribute value entry on an Assets object */ +export interface AssetObjectAttributeValue { + value: string | null + displayValue: string | null + searchValue?: string | null + referencedType?: boolean + referencedObject?: Record | null +} + +/** A resolved attribute on an Assets object (read shape) */ +export interface AssetObjectAttribute { + id: string + objectTypeAttributeId: string + objectAttributeValues: AssetObjectAttributeValue[] +} + +/** An Assets object as returned by get/create/update */ +export interface AssetObject { + id: string + label: string | null + objectKey: string | null + globalId: string | null + created: string | null + updated: string | null + hasAvatar: boolean + objectType: Record | null + attributes: AssetObjectAttribute[] + link: string | null +} + +/** Attribute payload for creating/updating an Assets object */ +export interface AssetObjectAttributeInput { + objectTypeAttributeId: string + objectAttributeValues: Array<{ value: unknown }> +} + +/** Raw attribute value as returned by the Assets API (before normalization) */ +export interface RawAssetObjectAttributeValue { + value?: string | null + displayValue?: string | null + searchValue?: string | null + referencedType?: boolean + referencedObject?: Record | null +} + +/** Raw attribute as returned by the Assets API (before normalization) */ +export interface RawAssetObjectAttribute { + id?: string + objectTypeAttributeId?: string + objectAttributeValues?: RawAssetObjectAttributeValue[] +} + +/** Raw Assets object as returned by get/create/update/AQL (before normalization) */ +export interface RawAssetObject { + id: string + label?: string | null + objectKey?: string | null + globalId?: string | null + created?: string | null + updated?: string | null + hasAvatar?: boolean + objectType?: Record | null + attributes?: RawAssetObjectAttribute[] + _links?: { self?: string } | null +} + +/** Output property descriptors reused across Assets object responses */ +export const ASSET_OBJECT_PROPERTIES = { + id: { type: 'string', description: 'Object ID' }, + label: { type: 'string', description: 'Human-readable object label', optional: true }, + objectKey: { type: 'string', description: 'Object key (e.g., HOST-123)', optional: true }, + globalId: { type: 'string', description: 'Global object ID', optional: true }, + objectType: { type: 'json', description: 'Object type metadata', optional: true }, + attributes: { type: 'json', description: 'Resolved attribute values for the object' }, + hasAvatar: { type: 'boolean', description: 'Whether the object has an avatar', optional: true }, + created: { type: 'string', description: 'Creation timestamp', optional: true }, + updated: { type: 'string', description: 'Last update timestamp', optional: true }, + link: { type: 'string', description: 'Self link to the object', optional: true }, +} as const + +export interface JsmListObjectSchemasParams extends JsmAssetsBaseParams { + startAt?: number + maxResults?: number + includeCounts?: boolean +} + +export interface JsmListObjectSchemasResponse extends ToolResponse { + output: { + ts: string + schemas: Array> + total: number + isLast: boolean + } +} + +export interface JsmGetObjectSchemaParams extends JsmAssetsBaseParams { + schemaId: string +} + +export interface JsmGetObjectSchemaResponse extends ToolResponse { + output: { + ts: string + schema: Record | null + } +} + +export interface JsmListObjectTypesParams extends JsmAssetsBaseParams { + schemaId: string + excludeAbstract?: boolean +} + +export interface JsmListObjectTypesResponse extends ToolResponse { + output: { + ts: string + objectTypes: Array> + total: number + } +} + +export interface JsmGetObjectTypeAttributesParams extends JsmAssetsBaseParams { + objectTypeId: string + onlyValueEditable?: boolean + query?: string +} + +export interface JsmGetObjectTypeAttributesResponse extends ToolResponse { + output: { + ts: string + attributes: Array> + total: number + } +} + +export interface JsmSearchObjectsAqlParams extends JsmAssetsBaseParams { + qlQuery: string + page?: number + resultsPerPage?: number + includeAttributes?: boolean + objectTypeId?: string + objectSchemaId?: string +} + +export interface JsmSearchObjectsAqlResponse extends ToolResponse { + output: { + ts: string + objects: Array> + total: number + pageNumber: number + pageSize: number + } +} + +export interface JsmGetObjectParams extends JsmAssetsBaseParams { + objectId: string +} + +export interface JsmGetObjectResponse extends ToolResponse { + output: { + ts: string + object: AssetObject | null + } +} + +export interface JsmCreateObjectParams extends JsmAssetsBaseParams { + objectTypeId: string + attributes: AssetObjectAttributeInput[] +} + +export interface JsmCreateObjectResponse extends ToolResponse { + output: { + ts: string + object: AssetObject | null + } +} + +export interface JsmUpdateObjectParams extends JsmAssetsBaseParams { + objectId: string + attributes: AssetObjectAttributeInput[] + objectTypeId?: string +} + +export interface JsmUpdateObjectResponse extends ToolResponse { + output: { + ts: string + object: AssetObject | null + } +} + +export interface JsmDeleteObjectParams extends JsmAssetsBaseParams { + objectId: string +} + +export interface JsmDeleteObjectResponse extends ToolResponse { + output: { + ts: string + objectId: string + deleted: boolean + } +} diff --git a/apps/sim/tools/jsm/update_object.ts b/apps/sim/tools/jsm/update_object.ts new file mode 100644 index 00000000000..4457cee9828 --- /dev/null +++ b/apps/sim/tools/jsm/update_object.ts @@ -0,0 +1,104 @@ +import type { JsmUpdateObjectParams, JsmUpdateObjectResponse } from '@/tools/jsm/types' +import { ASSET_OBJECT_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmUpdateObjectTool: ToolConfig = { + id: 'jsm_update_object', + name: 'JSM Update Asset Object', + description: + 'Update an existing Assets (Insight/CMDB) object. Provide the attributes to change using their objectTypeAttributeId values.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object ID to update', + }, + attributes: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of attributes to set: [{ objectTypeAttributeId, objectAttributeValues: [{ value }] }]', + }, + objectTypeId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional object type ID (only if changing the type)', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/update', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectId: params.objectId?.trim(), + objectTypeId: params.objectTypeId?.trim(), + attributes: params.attributes, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), object: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), object: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + object: { + type: 'json', + description: 'The updated Assets object', + properties: ASSET_OBJECT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/jsm/utils.ts b/apps/sim/tools/jsm/utils.ts index aaf4dad250a..df9a46e4af3 100644 --- a/apps/sim/tools/jsm/utils.ts +++ b/apps/sim/tools/jsm/utils.ts @@ -2,6 +2,58 @@ * Shared utilities for Jira Service Management tools */ +import { getJiraCloudId } from '@/tools/jira/utils' +import type { AssetObject, RawAssetObject } from '@/tools/jsm/types' + +/** + * Resolve the Jira `cloudId` and Assets `workspaceId` needed for an Assets API + * call, using the request params when present and falling back to discovery. + * @param domain - The Jira site domain + * @param accessToken - The OAuth access token + * @param cloudIdParam - Optional cloudId already supplied by the caller + * @param workspaceIdParam - Optional workspaceId already supplied by the caller + */ +export async function resolveAssetsContext( + domain: string, + accessToken: string, + cloudIdParam?: string, + workspaceIdParam?: string +): Promise<{ cloudId: string; workspaceId: string }> { + const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken)) + const workspaceId = workspaceIdParam || (await getAssetsWorkspaceId(cloudId, accessToken)) + return { cloudId, workspaceId } +} + +/** + * Normalize a raw Assets object (from get/create/update) into the trimmed + * {@link AssetObject} shape returned by the tools. + * @param data - The raw object payload from the Assets API + */ +export function mapAssetObject(data: RawAssetObject): AssetObject { + return { + id: data.id, + label: data.label ?? null, + objectKey: data.objectKey ?? null, + globalId: data.globalId ?? null, + created: data.created ?? null, + updated: data.updated ?? null, + hasAvatar: data.hasAvatar ?? false, + objectType: data.objectType ?? null, + attributes: (data.attributes ?? []).map((attr) => ({ + id: attr.id ?? '', + objectTypeAttributeId: attr.objectTypeAttributeId ?? '', + objectAttributeValues: (attr.objectAttributeValues ?? []).map((v) => ({ + value: v.value ?? null, + displayValue: v.displayValue ?? null, + searchValue: v.searchValue ?? null, + referencedType: v.referencedType ?? false, + referencedObject: v.referencedObject ?? null, + })), + })), + link: data._links?.self ?? null, + } +} + /** * Build the base URL for JSM Service Desk API * @param cloudId - The Jira Cloud ID @@ -33,3 +85,55 @@ export function getJsmHeaders(accessToken: string): Record { 'X-ExperimentalApi': 'opt-in', } } + +/** + * Build the base URL for the JSM Assets (Insight/CMDB) API. + * + * Uses the OAuth 2.0 (3LO) gateway form `/ex/jira/{cloudId}/...` — matching + * {@link getJsmApiBaseUrl} — keyed by both the Jira `cloudId` and the Assets + * `workspaceId` (resolved via {@link getAssetsWorkspaceId}). + * @param cloudId - The Jira Cloud ID + * @param workspaceId - The Assets workspace ID + * @returns The base URL for the Assets API (v1) + */ +export function getAssetsApiBaseUrl(cloudId: string, workspaceId: string): string { + return `https://api.atlassian.com/ex/jira/${cloudId}/jsm/assets/workspace/${workspaceId}/v1` +} + +/** + * Resolve the Assets `workspaceId` for a Jira site. + * + * Calls the Service Desk discovery endpoint and uses the first workspace. + * Atlassian provisions a single Assets workspace per site, so this is the + * canonical workspace; callers on a multi-workspace site can pass an explicit + * `workspaceId` to {@link resolveAssetsContext} to override it. Requires the + * `read:servicedesk-request` scope (already granted by the `jira` provider). + * @param cloudId - The Jira Cloud ID + * @param accessToken - The OAuth access token + * @returns The Assets workspace ID for the site + * @throws If discovery fails or no workspace is provisioned + */ +export async function getAssetsWorkspaceId(cloudId: string, accessToken: string): Promise { + const response = await fetch( + `https://api.atlassian.com/ex/jira/${cloudId}/rest/servicedeskapi/assets/workspace`, + { method: 'GET', headers: getJsmHeaders(accessToken) } + ) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `Failed to resolve Assets workspace: ${response.status} - ${errorText || response.statusText}` + ) + } + + const data = await response.json() + const workspaceId: string | undefined = data?.values?.[0]?.workspaceId + + if (!workspaceId) { + throw new Error( + 'No Assets workspace found for this site. Assets (Insight) may not be enabled on the Jira instance.' + ) + } + + return workspaceId +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 45fef1980aa..e7c8f701984 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1169,6 +1169,8 @@ import { } from '@/tools/google_bigquery' import { googleBooksVolumeDetailsTool, googleBooksVolumeSearchTool } from '@/tools/google_books' import { + googleCalendarCreateCalendarTool, + googleCalendarCreateCalendarV2Tool, googleCalendarCreateTool, googleCalendarCreateV2Tool, googleCalendarDeleteTool, @@ -1181,6 +1183,8 @@ import { googleCalendarInstancesV2Tool, googleCalendarInviteTool, googleCalendarInviteV2Tool, + googleCalendarListAclTool, + googleCalendarListAclV2Tool, googleCalendarListCalendarsTool, googleCalendarListCalendarsV2Tool, googleCalendarListTool, @@ -1189,6 +1193,10 @@ import { googleCalendarMoveV2Tool, googleCalendarQuickAddTool, googleCalendarQuickAddV2Tool, + googleCalendarShareCalendarTool, + googleCalendarShareCalendarV2Tool, + googleCalendarUnshareCalendarTool, + googleCalendarUnshareCalendarV2Tool, googleCalendarUpdateTool, googleCalendarUpdateV2Tool, } from '@/tools/google_calendar' @@ -1366,16 +1374,21 @@ import { listMattersTool, } from '@/tools/google_vault' import { + grafanaCheckDataSourceHealthTool, grafanaCreateAlertRuleTool, grafanaCreateAnnotationTool, + grafanaCreateContactPointTool, grafanaCreateDashboardTool, grafanaCreateFolderTool, grafanaDeleteAlertRuleTool, grafanaDeleteAnnotationTool, grafanaDeleteDashboardTool, + grafanaDeleteFolderTool, grafanaGetAlertRuleTool, grafanaGetDashboardTool, grafanaGetDataSourceTool, + grafanaGetFolderTool, + grafanaGetHealthTool, grafanaListAlertRulesTool, grafanaListAnnotationsTool, grafanaListContactPointsTool, @@ -1385,6 +1398,7 @@ import { grafanaUpdateAlertRuleTool, grafanaUpdateAnnotationTool, grafanaUpdateDashboardTool, + grafanaUpdateFolderTool, } from '@/tools/grafana' import { grainCreateHookTool, @@ -1691,9 +1705,11 @@ import { jsmAnswerApprovalTool, jsmAttachFormTool, jsmCopyFormsTool, + jsmCreateObjectTool, jsmCreateOrganizationTool, jsmCreateRequestTool, jsmDeleteFormTool, + jsmDeleteObjectTool, jsmExternaliseFormTool, jsmGetApprovalsTool, jsmGetCommentsTool, @@ -1703,6 +1719,9 @@ import { jsmGetFormTemplatesTool, jsmGetFormTool, jsmGetIssueFormsTool, + jsmGetObjectSchemaTool, + jsmGetObjectTool, + jsmGetObjectTypeAttributesTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, jsmGetQueuesTool, @@ -1714,10 +1733,14 @@ import { jsmGetSlaTool, jsmGetTransitionsTool, jsmInternaliseFormTool, + jsmListObjectSchemasTool, + jsmListObjectTypesTool, jsmReopenFormTool, jsmSaveFormAnswersTool, + jsmSearchObjectsAqlTool, jsmSubmitFormTool, jsmTransitionRequestTool, + jsmUpdateObjectTool, } from '@/tools/jsm' import { kalshiAmendOrderTool, @@ -3989,14 +4012,20 @@ export const tools: Record = { grafana_update_alert_rule: grafanaUpdateAlertRuleTool, grafana_delete_alert_rule: grafanaDeleteAlertRuleTool, grafana_list_contact_points: grafanaListContactPointsTool, + grafana_create_contact_point: grafanaCreateContactPointTool, grafana_create_annotation: grafanaCreateAnnotationTool, grafana_list_annotations: grafanaListAnnotationsTool, grafana_update_annotation: grafanaUpdateAnnotationTool, grafana_delete_annotation: grafanaDeleteAnnotationTool, grafana_list_data_sources: grafanaListDataSourcesTool, grafana_get_data_source: grafanaGetDataSourceTool, + grafana_check_data_source_health: grafanaCheckDataSourceHealthTool, grafana_list_folders: grafanaListFoldersTool, grafana_create_folder: grafanaCreateFolderTool, + grafana_get_folder: grafanaGetFolderTool, + grafana_update_folder: grafanaUpdateFolderTool, + grafana_delete_folder: grafanaDeleteFolderTool, + grafana_get_health: grafanaGetHealthTool, google_search: googleSearchTool, greenhouse_list_candidates: greenhouseListCandidatesTool, greenhouse_get_candidate: greenhouseGetCandidateTool, @@ -4228,6 +4257,15 @@ export const tools: Record = { jsm_externalise_form: jsmExternaliseFormTool, jsm_internalise_form: jsmInternaliseFormTool, jsm_copy_forms: jsmCopyFormsTool, + jsm_list_object_schemas: jsmListObjectSchemasTool, + jsm_get_object_schema: jsmGetObjectSchemaTool, + jsm_list_object_types: jsmListObjectTypesTool, + jsm_get_object_type_attributes: jsmGetObjectTypeAttributesTool, + jsm_search_objects_aql: jsmSearchObjectsAqlTool, + jsm_get_object: jsmGetObjectTool, + jsm_create_object: jsmCreateObjectTool, + jsm_update_object: jsmUpdateObjectTool, + jsm_delete_object: jsmDeleteObjectTool, kalshi_get_markets: kalshiGetMarketsTool, kalshi_get_markets_v2: kalshiGetMarketsV2Tool, kalshi_get_market: kalshiGetMarketTool, @@ -6314,8 +6352,12 @@ export const tools: Record = { microsoft_planner_update_task_details: microsoftPlannerUpdateTaskDetailsTool, google_calendar_create: googleCalendarCreateTool, google_calendar_create_v2: googleCalendarCreateV2Tool, + google_calendar_create_calendar: googleCalendarCreateCalendarTool, + google_calendar_create_calendar_v2: googleCalendarCreateCalendarV2Tool, google_calendar_delete: googleCalendarDeleteTool, google_calendar_delete_v2: googleCalendarDeleteV2Tool, + google_calendar_freebusy: googleCalendarFreeBusyTool, + google_calendar_freebusy_v2: googleCalendarFreeBusyV2Tool, google_calendar_get: googleCalendarGetTool, google_calendar_get_v2: googleCalendarGetV2Tool, google_calendar_instances: googleCalendarInstancesTool, @@ -6324,12 +6366,18 @@ export const tools: Record = { google_calendar_invite_v2: googleCalendarInviteV2Tool, google_calendar_list: googleCalendarListTool, google_calendar_list_v2: googleCalendarListV2Tool, + google_calendar_list_acl: googleCalendarListAclTool, + google_calendar_list_acl_v2: googleCalendarListAclV2Tool, google_calendar_list_calendars: googleCalendarListCalendarsTool, google_calendar_list_calendars_v2: googleCalendarListCalendarsV2Tool, google_calendar_move: googleCalendarMoveTool, google_calendar_move_v2: googleCalendarMoveV2Tool, google_calendar_quick_add: googleCalendarQuickAddTool, google_calendar_quick_add_v2: googleCalendarQuickAddV2Tool, + google_calendar_share_calendar: googleCalendarShareCalendarTool, + google_calendar_share_calendar_v2: googleCalendarShareCalendarV2Tool, + google_calendar_unshare_calendar: googleCalendarUnshareCalendarTool, + google_calendar_unshare_calendar_v2: googleCalendarUnshareCalendarV2Tool, google_calendar_update: googleCalendarUpdateTool, google_calendar_update_v2: googleCalendarUpdateV2Tool, google_contacts_create: googleContactsCreateTool, @@ -6338,8 +6386,6 @@ export const tools: Record = { google_contacts_list: googleContactsListTool, google_contacts_search: googleContactsSearchTool, google_contacts_update: googleContactsUpdateTool, - google_calendar_freebusy: googleCalendarFreeBusyTool, - google_calendar_freebusy_v2: googleCalendarFreeBusyV2Tool, google_forms_get_responses: googleFormsGetResponsesTool, google_forms_get_form: googleFormsGetFormTool, google_forms_create_form: googleFormsCreateFormTool, diff --git a/apps/sim/tools/supabase/utils.test.ts b/apps/sim/tools/supabase/utils.test.ts index ac649b008f9..b12e98ac421 100644 --- a/apps/sim/tools/supabase/utils.test.ts +++ b/apps/sim/tools/supabase/utils.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) import { supabaseBaseUrl } from '@/tools/supabase/utils' diff --git a/bun.lock b/bun.lock index 447ad2121cc..722893ecaac 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -206,7 +205,7 @@ "isolated-vm": "6.0.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "json5": "2.2.3", "jszip": "3.10.1", "jwt-decode": "^4.0.0", @@ -223,7 +222,7 @@ "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", - "nodemailer": "8.0.7", + "nodemailer": "8.0.9", "officeparser": "^5.2.0", "openai": "^4.91.1", "papaparse": "5.5.3", @@ -504,39 +503,39 @@ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.101", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.81", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MU4KXBasSVTcP9U0mtfcnW9ME8fo9Hsf9ZOaz0SK0qHAYwxck9Dmh4dyBGZqcopYHkhYQPskTzYJq0ARm0hHsg=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.102", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.82", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CeNdbx2gmtwnWdRoHQNbLbly2n43hgytqM4J1OqsBLLEkuSgPDaf+ZrFcQBgxZKv9xQX606yaNN43FGS4VSIpQ=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.81", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0iqx9hZc9xqdhxOdZkYJAKuCs9o+5a86gStYl0M7IBZzmx6jTDrynXiOigDjH3SQrmLclLCspTjW5E6YFrlyHQ=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.82", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bMiG4rUwdgQ6+E2klFSaJ1BHO65zCrfHRqC/hkdhA19tmx8FqLoAmXpx92dcWsADFMtAzQd3Q9kRJ8zk4/PpnA=="], - "@ai-sdk/azure": ["@ai-sdk/azure@2.0.109", "", { "dependencies": { "@ai-sdk/openai": "2.0.106", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0Cd/YzLkF12v8NEyowNdZ3WLGXzdaQeBd4I6EreuQuCnSgO+SwhsS7RbnVB4/FVISgoctSf8+/ojkO+gAC47Sg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@2.0.111", "", { "dependencies": { "@ai-sdk/deepseek": "1.0.43", "@ai-sdk/openai": "2.0.107", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bnTur5rNw4hH/AUjv8QSy60lJ8FmHAS22IwsSSta48dmLirxXdEIB3AEMFYEhKd6TFaFeQA8unN7HOGu2xF32g=="], - "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.44", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2w7+jq0bWEF6McgWPb2gjaEx1TpqdUq4eyX/gPLTp7HzfDZKEVmmVXRvnKvjzBP/VH7xW4OT5jhTpTPTfYNYYQ=="], + "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.45", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.40", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xP3/RC0oG0e7zLssjLn/aFCzVlqTfePYq/LmMtPDBH2whrcFXP++UkhOd/t20HftO0LNcMORLLp8xkLwkhKUKw=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.41", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7L2Do5wk0xRe0Ox8CVRF9B5b5SPemZP16ZbyBUAlNtO16EMFLSX8LXGeQREZ2SOQ4pC95BwSXThcTkt1JbFNlA=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.43", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RQ2FiCUUHzbGchFaYWvpDpOpDGYypPkgtB1NJKvOXwiqD7UqaCBa6yYjKFHHZMXqeS+lkkU8v6E2pZXigwrh1Q=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.98", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JNMc5Fbz8AwiLIR3Ar/lV2egbLFE+A5nfwbRKrdfgusoVN2VjgMX2U2KCLux5iWD/Q9+rg9+njHPZNw4HmzBJQ=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.102", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5OxMbtF4AFeyHjKS2smzCYDvjkE+nWktLCS0xsWNnI71cP0mTKXqkygta5lq1WTytVVkonLMP9J0OqEbteBzfQ=="], - "@ai-sdk/google": ["@ai-sdk/google@2.0.74", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Lhw1742RXc+4pRIvqVXa0jdl5+qdpmw8lj0lm6OchUg9rVGHzymlaxe7CDiYX5U2af4jbjKeTY22LDi3bIycgQ=="], + "@ai-sdk/google": ["@ai-sdk/google@2.0.75", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Aw2pN+6Ur4dOxG0Bs39gb+Q6KMbPBrYl8e7fZzmh9Sp9vEKyLA0EuaXzjVEX2qlpSNC9gV8YPgkoyWE647uXVA=="], - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.141", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.81", "@ai-sdk/google": "2.0.74", "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+PjbZu63x+7RABQpAnNcJ0+EEZjKt3nQESQszA4Gyv9rLajob+FvxRJWeiLcKDsGIQdEFBknDrI5KLLSm7Doeg=="], + "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.142", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.82", "@ai-sdk/google": "2.0.75", "@ai-sdk/openai-compatible": "1.0.40", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bQuZKTMRZ2OrVgk7Tq2hBdOjMuNwktcDx00asmRF9nGThluAqOF+KZE04H6+24xcWgf1LWddV5okL2CAWsJKNw=="], - "@ai-sdk/groq": ["@ai-sdk/groq@2.0.40", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1EL8D1tyjOKjCFUt8XspDoA6zxDcalMsLR2O56ji8QklWsAPaf4TuMJAvf5x5KDrkuJaSAjk94KvPH5hOX+VNQ=="], + "@ai-sdk/groq": ["@ai-sdk/groq@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rDz7BSEuZaozWm6PAz1GuJgEltQdX1GBFk3gmdTF5a9THTUZLB1YOxMt1BUWrYjwxyIJkO8mUtpC2s/ksjmSkw=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oBR9nJQ8TRFU0JIIXF+0cFTo8VVEreA1V8AMD3c77BJj/1NUSBLrhyqAbX9k7YAtztvZHUdFcm3+vK8KIx0sUQ=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hoaNkUPgNQdTn8o0lgGERiov6b4WvgKcUXqACJt/YJ2Eoh5bvVsmfP+EofgWMnizAO/FLKhvLVS7+1jEnMPejw=="], - "@ai-sdk/openai": ["@ai-sdk/openai@2.0.106", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EFC0rpo1wfe4HIz5KZCE72edP2J7fOeR7wPXzjCDljaTRB1wectKDIKRLowpU4F0mbcJ+XScAsoYNPK/Z20aVQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.107", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cWU7w1c0FhDiZBFfi0rZO+5qYlD+rCDE8Il1X0P6E7wejtghgyW2I6ihPNTXfrLT2J8DVeXnrwQMFfxfVNzstA=="], - "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.39", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-001hdQPPXxYBWrz5d+eAmBVYmwzsB+guIey1DFXi1ZEE5H3j7fRrhPpX55MdM9Fle2DS7WZ8b3qkumCIWE92YQ=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.40", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-K8SzN+Hpr25Fx23VurW9rtg8dmUgqX+rDI7LKJFCIJcM3TIu3PfknxYlRGivDIrmsmgzH3PjvMGuCq+E8ZW7rA=="], - "@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ymXWoItR4tRCIQlJcpn0zk4jBUU+j4SDnliz/z1f5U6rWxNY1ttxFCk4uZ+6Zt9e3VjQTpA9FK6cOJt18JRrKQ=="], + "@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.31", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1oyQEfVG2GtNAUqvn9XvlngGwHN1U8fCmHOullKmVdE37yxh1a3Af3CZNEMf/0lYqaVmQBOitnPMmb+d75G7Xw=="], "@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dNciNI4knep6z3cqDNng7yORCcBnEDBHZYj8rJLcLn9pLzEtNVkf6WLg9HR6AnVDDRxHsUeGAEqyF8M+FAtRgg=="], - "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V9reHPfWeaIt6fu03lVbjZDuxfdplS5jdmzVchVBeUug9VqIK+9KQELcPvdWKdxf+ov+sCoShN/O6dYfPPD5Ng=="], + "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.43", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.40", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FwcKwNKSjkCc80yD7JFtHOXxroTpFD/FF84jfiGlRGTfv2rQpv3Pto9h/NcKjmDXXr/H01Rzo/TodrsLXq0B8g=="], - "@ai-sdk/xai": ["@ai-sdk/xai@2.0.73", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U+/rdtqgDcloNSX7TIdRjYQooVydYdauQvLSP74oQcnE5N0/DD81yi+RvQXYYq47dDIn2H4exgr7XkBm4x1yDw=="], + "@ai-sdk/xai": ["@ai-sdk/xai@2.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.40", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ff24f/G13EjKKw411XQh08/owBorFvaLf3LEuDvDnDz4XePpkYN6oXdyHpZCDrvUD0/+7hbwaGwTXfm5nmqPGQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -568,7 +567,7 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Pt1JEVLu02jTWzpcUzUHciiWScyZg3JpHCTB1h9DtDPWY3dBufBnFJAevVHali/bAkmMdMhYUD8tH/VvPuBkUg=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-RMCrCteiUwYTEv2G9zfP/BEuKHv57665vVieJyp9cf8VgilWxP/KrWVtMdfdDlIH8nFhvu3rIMc29z3ebGEZ1w=="], "@aws-sdk/client-appconfig": ["@aws-sdk/client-appconfig@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" } }, "sha512-WcS820syhSamz1PcZUvdUXf7FUm3cpze+hfMvDKzPojrh/zFO5eVopzhBGEkDFXiHFD0qel1ZgE5s5AkmH9fyg=="], @@ -608,85 +607,85 @@ "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/signature-v4-multi-region": "^3.996.18", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FCLc5VWb+yz1xb/Jv0sXFGqIIs+bHZQWBKbPQKCuypF3wU/7UFygXuSXo9uJfwISKNGVHJwp+0136f8mqmzRcA=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.20", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.30", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3YoPwJczcc+MtX2xxXaYaOOWO6xKUJr1ZIIDIFuninr51BYONVVcF/CP8K2xfVRC/PztJjqKWxNGFH7BWQAw1Q=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-2UtGUPy+x3lqyceHrtC1uEuVxBZbDalPF6KAFqBwYgm4edWdBrZKNnCqzDs7KynWUvEC6mrR+ojRk+ZgQz9C2w=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", "@aws-sdk/credential-provider-login": "^3.972.51", "@aws-sdk/credential-provider-process": "^3.972.46", "@aws-sdk/credential-provider-sso": "^3.972.51", "@aws-sdk/credential-provider-web-identity": "^3.972.51", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-szg1nnebqC+Svv6Vfsdf6P/QK8x5g/ghG2CKa/1WkHifRnq0BBmDELj2Qnqk9nPsUvEu/OEcYic97CPLpKqF9g=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/credential-provider-env": "^3.972.47", "@aws-sdk/credential-provider-http": "^3.972.49", "@aws-sdk/credential-provider-login": "^3.972.53", "@aws-sdk/credential-provider-process": "^3.972.47", "@aws-sdk/credential-provider-sso": "^3.972.53", "@aws-sdk/credential-provider-web-identity": "^3.972.53", "@aws-sdk/nested-clients": "^3.997.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Hx4gO4YRjFwitf3MVl3cDwYe1aryJthC4txVl9b+JAURovA50M2ywf9r8j1E/Q6SCTPT4qQpjOAbKYIC9CG+Vw=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-csHFsH+/VjnI40oqm1l1OqMY4B4kza36DbfcbHcgcbobgjebasqUbTU34xvwUkvtoNGGizbfyMSlMzJWUPv3dQ=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/nested-clients": "^3.997.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+71sluhkgPqdhbbD3UDwUpj24GCkng9HQx6z7qoBFb8dwkF4ktpOcVKDeHpgg8PvBgLYwAnUYLTEGRC/PniCiQ=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.54", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", "@aws-sdk/credential-provider-ini": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.46", "@aws-sdk/credential-provider-sso": "^3.972.51", "@aws-sdk/credential-provider-web-identity": "^3.972.51", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-vinTSQtziNHxi2nqXF+76jr2sO44q88Ind1qFFVaotNgBaC1rcWDjBug8yoE8n0ov33s21xks9WY5XDHH9SENw=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.56", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.47", "@aws-sdk/credential-provider-http": "^3.972.49", "@aws-sdk/credential-provider-ini": "^3.972.54", "@aws-sdk/credential-provider-process": "^3.972.47", "@aws-sdk/credential-provider-sso": "^3.972.53", "@aws-sdk/credential-provider-web-identity": "^3.972.53", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-iI+4o0dvQQ4NHel4FMDiFy5q2gaU/ryLK3niOsoPccAt9WLFRkV4XTYPWRr9XvmBUqEzXG73S4p/8gm0Lu/W3A=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tAizPm9IFo/PHn06c+LQJlzfY2AGOlyF0CUljFejrU6LcZBjnk8pmbZK3/xoIDdnIzjEdbClfvY3mXfr818ZEg=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/token-providers": "3.1065.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-60qhpQcSDIKIr0AuBlmJezKX0b5nbJPCINiR49N9yJXrEI5tTRwsXVBr0IdSvvsNJyqgiINyoBd++Ed0yvggbw=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/nested-clients": "^3.997.21", "@aws-sdk/token-providers": "3.1069.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pUXE3fu4tfEDV8BksIgf4dXvuIH10FhwHMl/wu8rBD5T1sMpryQWFVitH3kdPS90wlgrGYJQ/meQTSPacyZfeg=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0X5eWsUIp8ItRJeJBBrhQAPzc9AQelDetRTVTsycCAISCCzM17R4hs/vFAPeQ0o0B35sciLiqe/Pwmml909cZA=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/nested-clients": "^3.997.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-JmMGlhVvSj8uSG9CpeDkJAXT35H89tc6v84iMgEIE75q4yp1MKVVKvopv6Gg28HJIR7hMNkojRF8H2m5W44wyg=="], - "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.20", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SFfxiqVgWeIe+RsJJNAMD//2IfehT4bLpGyNJRB0MgHmOIJtdcfMnR1k7KYyaHokSoQVdncVa9O9DIGa4eqcwg=="], + "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wfAWZ6oIrsDOFyYm9bDQNva/WCmvIrVqP3dSCePN5YYWCGWWXkikn5YC0wPSxF92M8kQFPfdVpMaTTV1mRk4Lw=="], - "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.7", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-LkwS3ZOUNL5kHzmz3dDx8lE3HOhZmf2VGjbJ/tMUZJYWWl3J0RJTZM7RFz1MLt06WDVvlShcAjY/RzhYlqLL7g=="], + "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.8", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-bBmkG0Dnhfq0/T4Z0PpUr7HkncBVaWvvCbvafeaUM+yC9wa8GGjLJmonq0QL17REB9WivgGeYgWQ5A80Uw5UnQ=="], - "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ=="], "@aws-sdk/lib-dynamodb": ["@aws-sdk/lib-dynamodb@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/util-dynamodb": "^3.996.2", "@smithy/core": "^3.23.15", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1032.0" } }, "sha512-rYGhqP1H0Fy4r1yvWTmEAx0qqy1Zd9OzI8pPkXo6KSEDjZ4EwU+6QN1V+KLX3XTU6FQouF5LTvqLtl/CW4gxyQ=="], - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.23", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-X0kemMevWxLa/ai/iBV6Lh6V+DqdKbuY5zX4nJ0HlEL3jgPdRSnxTNrGO33Er+2N+fLLriDyriw1O3DFFRR+zw=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.25", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.52", "tslib": "^2.6.2" } }, "sha512-zcRjdhS46gQ+omEKod2Q83A+42dQlFgQP9GfsK2XcDCli8kzA3q1QH+hDpIZUDbKaXmkTSn0JG3WP5yds5j38g=="], - "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.18", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.7", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1vKJt/6MBB/MBMRM3qzCMdW70syJY8u2DH+dq7yCnPn7wVJmyeAzAa/sK1lIbbYh8BVLbM5FspsT4zbe885gOw=="], + "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.19", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.8", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FMgyzUq3Jh+ONRYxryBRNdBd+FUX8PwRl07ccQknNdoms6KCeAEusCkl6whqpDrPQ6OH0ddeSifKyqYSs2DLIw=="], - "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A=="], - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.19", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-jg1aPMLMCBcaF34ZyqvP9Fbv2s9xlbkEMiQZWsT5F3k9bulA/wrCejLMgAQxHSCruvuK5IEmi4MErST/Q2ZAzQ=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.21", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.52", "tslib": "^2.6.2" } }, "sha512-LmmKxy26I++tBdJVTGnghGFff2xrv+vryfKfbxoRb1la50DY77N1ePclYdDat8/tO6nRXA2EFXUBjY72jfHEcw=="], - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.29", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.4", "tslib": "^2.6.2" } }, "sha512-ALTHDXk6YWVDfAWIHzXyaTZ82QFoMWhHENXlO61lv4ZqSMl3cvh2s0ZVOS89qbtw9LRJhIDoZaaC9FYo/Z4KLQ=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.31", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.6", "tslib": "^2.6.2" } }, "sha512-Yzj6NRYVZdBaCp7o1BwHGyeDBfixdeToLIAMprshIITEdl9wKVSiidVOfeaiH8FyeC1hBmBfDZFvs/aH1Y3xpw=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-+SRZL54/T9Ryxa/NmHM7WZAliU6wnfzeosT/+4IVuTgq0zSCpPx2j3yaEP4JFZlvWvoOCbKgr+4tBqSAG/dl5Q=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-nLTYWmLcXy1qwiDZdXMs7PHrQ8sFc75vDplmC73u91WzpXCDGplcMnhTYltKijybXtUFkGCj4WRwZsmjBjQh2Q=="], - "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-c7qT6vMXwzdDbXjexG8jknN7itfa4N1thiZMEmZzTn/t/ev/j0J2HF/60ympIO/iYq69qHOprU6WZXBcppDDJQ=="], + "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.18", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.52", "tslib": "^2.6.2" } }, "sha512-pnAGLICTuUV+79LC5rEtCapqSBzNU8LUMgNP5mdfFsfQXxDcwyTEP1mcVwz3id18QOcB6jSqHbqd0MJZodyN+A=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-8oOvo7XNDeOlwa5ys2Q0UTLCKmtvRiqf8rOU63lKniPmP3dnI5lnoy9dteZ79lxb9hmXCrO28aZMqds6C5AEoA=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-8VkkGI7+uxaX5LLeTGE1okITrM9wZinFDDDuLm2J1kBiOvID1bx5p84tpa5k4v0o1asq+5nZFsdKLdyfc9o6hA=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-q4H/CoOYrTbyAW0d9RrHf9kTYKVXpAwoK0VEy3UT2Asad+6aa6vzQgz35dh20tRA7zlEo/Nsyjy9PVlHgdq0Vg=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-ZaGQf0chuk6akH6+yfbM/1TCYU+ktaCcE9ZBHTmk009lKknQRrnjZDSXJhBCe4QbylcBhTbIV+x2tVluSgm6dg=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-yOXn9mmJQQODpbmwQB224IX1PLLneyqInX2Fv2nEmSHWpJj54nrzdrUT1TGQk/s8mr+XPssDQy1at/8GS4EFVQ=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-rerjP08onRqkBh0AcCqip6GkKvESapmLoTgi1xysZ4C6a1xMrIMtTBcEbUb6EY71oeajnigeUD4KwZjtIO+aWQ=="], - "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-PVAj7VgWK/ZxCXnkgC4B7cdJyUN99Nsr7IEduHt4A1GieuB+ZnU5bSifHwapbr17wrFkmdxfSh+aA0Lj+Ads6w=="], + "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-56ifsBmK9bLn5EE/t6c0nmjOB1BO8cJDLkA1VOlsN1GR85ROqnaCwVDspqcwsLaBDgPlwyYNedoDIoT3t6Ho1A=="], - "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-o0oPX8JkZd2Fep0TFAE0VY4dzi86Q5alxSwd2O3wR2M9zg8/zJ4dEpkw9kGCr3mRghP3E5nWLgsfzJ9RKFwVnQ=="], + "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.18", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.52", "tslib": "^2.6.2" } }, "sha512-Emeqtr7HJbVmkcMeiz86nxVbekdEYRMKkKuYKqSSE3M5GMOkG1eKacgQn3LS/iZ3m0mzApGN/6LRnyomgHjpKA=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-5JIDcDFWy3DUW95sOlXLbeb7RkGFUcNh1QidKsznqtlm5YGsXP0EGWaqzxBTvVmOhqKs2RmNmI6w9V/5dS3CLQ=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-J7+fiPR7axyvUSvckXnAiutX0/6O+0MvXS7BphQAkm5gnMqQPhw5Np15AnPZdjp/DW9WJeTczjiR4W484Rlz9Q=="], - "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Agv95NCgYyvuYUXt2PcFcOMrKCkhBFPhoH+nVMQh85RcXSCQrhAa4475plBOeomCihP26vKHT5KinVQT3iD14w=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.19", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-P2Otgf15GBJMKzG6j5Ddf7w+Kz6z2jvesMy874TD3jlMfDWNK7clJeUd7hgigdeVOotjoUP4emcTWVdS9sfZDw=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.21", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.21", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-eC7Vl7Qom/BGhZjG9GEqPwdQ/fk45hg1t5LP4EUxG5d1fdshLbaxCiwh/tszUzDX/4mW40mu2QsbeJJRPBbqUw=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-WY6uVMsq0EvxY4BcYhZmG2Ivd1EzNvZAqsXFlL3pTPMG0P4J83TYVQIs8P0nd5lc+Bp3llrYwggruvXzrfUtsQ=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-aFc/pn5pfnhZCEhyjv/D9kR2c9WSZdkX+FPrsb/AGvY7TiAkHqJFeIw2xqbgeiAhy7W3/w41Mi8Vr52A74EDug=="], "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1032.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "^3.996.18", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-LFaI5JQhiOmJDjKK02ir9oERU9AmxdyEvzv332oPDzAzWeNH06sZ1WsF3xRBBE5tbEH2jIc79N8EqDCY0s5kKQ=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.33", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.35", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg=="], "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-n+PU8Z+gll7p3wDrH+Wo6fkt8sPrVnq30YYM6Ryga95oJlEneNMEbDHj0iqjMX3V7gaGdJo/hJWyPo4lscP+mA=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], - "@aws-sdk/util-dynamodb": ["@aws-sdk/util-dynamodb@3.996.4", "", { "dependencies": { "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1064.0" } }, "sha512-Gel6Qiof4NWdtGT548FxoAkmmKmvsEVQYzbtC4RJnwgh9z33gmiYSJOGnKZHvZJWC2SgjS6AKe6DfuGCqU4vdg=="], + "@aws-sdk/util-dynamodb": ["@aws-sdk/util-dynamodb@3.996.5", "", { "dependencies": { "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1069.0" } }, "sha512-m9bdmYq3WtbMHAKGALw9XWiMBfKu5T8ukgdJT7Mc/d2oOwDGNFmhsnnkQ18xomoXo/ZHxAuIDi3Y6slsblW1Mg=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.19", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-W79LutZYmCV1WGe0UVGALMna3xLGP3wv2zLzrBEgc73CtzxKBxb5IpMedbS6Ej80LCknSgqFjsTtECG0IInckw=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.20", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-mwbTLCYFICfvigDoQggOe8utOXSlYLJVWD5KAs7vmx2gZ6b3HpQotAcrti3hrxlBX8mjr6101bIXd4Cz6fbqAQ=="], - "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-ygzBtG3xDxvMWTg3EjlZ5FSYxUiRCsCZvKPk+sOhXeMcDTdzPqIGQipiUiIYJm/Om8h8qXyhchMb0baW1PhE+w=="], + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-+KAkQBGX3CSFd7/xs+pz+j+D7rWXj4fviHn+Ykb4T4cfrOJFI3yfy+y+wvZ0vUIhVNy2wEKwFkhk4+tAoMFppw=="], - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.7", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-Y9MPH4VZaIebwxiVK6dMzSt5L04oyNiK3NNwFe4qP5B2Hfo+pmEVpSJSa+gARPtcJeRyehUMPu5/I9DLdW0cBg=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-Fa040urz+8bwxgnG5KoSglP53d4l3jtby65qO564mjQ28o5PO4FmkNWy2atSln2pUjKmCmpbSsV2pLgcGULRIA=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-wsmSmHTrK9lV+5SKBekp1J7W6PufdZE08X0xv5Lz1OdgYRmyuYDy/++SslKdXhcVQjxLtzMTZaLqqLZmTx6OaQ=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.37", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "tslib": "^2.6.2" } }, "sha512-/F4Y0+iREEUvVCPQkJqzRnly8MAihbQy/s9847yEl9TLsXYEKMnrMIptsjV4owcNgm2l6zqKEZ7SDXv6JPjrRg=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.29", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.30", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], @@ -842,7 +841,7 @@ "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], @@ -1052,7 +1051,7 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], @@ -1076,7 +1075,7 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], + "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1154,17 +1153,17 @@ "@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.217.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.217.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A=="], - "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.217.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.217.0", "@opentelemetry/configuration": "0.217.0", "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-logs-otlp-grpc": "0.217.0", "@opentelemetry/exporter-logs-otlp-http": "0.217.0", "@opentelemetry/exporter-logs-otlp-proto": "0.217.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.217.0", "@opentelemetry/exporter-metrics-otlp-http": "0.217.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.217.0", "@opentelemetry/exporter-prometheus": "0.217.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.217.0", "@opentelemetry/exporter-trace-otlp-http": "0.217.0", "@opentelemetry/exporter-trace-otlp-proto": "0.217.0", "@opentelemetry/exporter-zipkin": "2.7.1", "@opentelemetry/instrumentation": "0.217.0", "@opentelemetry/otlp-exporter-base": "0.217.0", "@opentelemetry/propagator-b3": "2.7.1", "@opentelemetry/propagator-jaeger": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.217.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-K/60pSv42+NQiZKy1pAH18nYDkxltsDV4O3SJ233J0E9raU1ksyL9gsKuS8p30bYBb4AMPCfDuutHQaHYpcv0Q=="], - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], - "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.7.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg=="], + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], @@ -1212,75 +1211,75 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.13", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="], - "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.16", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg=="], - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], - "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA=="], - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="], - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg=="], - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.17", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw=="], "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ=="], + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw=="], - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q=="], + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ=="], - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg=="], + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g=="], - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.0", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="], - "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.9", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw=="], - "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw=="], - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.11", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.3.0", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g=="], - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.0", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw=="], + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], - "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw=="], - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="], - "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw=="], + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -1402,7 +1401,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], @@ -1452,87 +1451,87 @@ "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], - "@smithy/config-resolver": ["@smithy/config-resolver@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-AXbvUX9aNY2qCLOMCikpl1Df5w2CNFEqbEb6XafG81FJbAbB8avIT7BOx1KDqiO86J/38qKQ3YuakfAfY3iBkQ=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="], - "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], + "@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-pPQmNdEvMJttv9z2kdYxoui83p/nr32zjMf0aMfmzmGmFEgKXUfy0vXiNg0fx4R5XLQzmJBLM9Wg0guEq2/q8A=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-Ussyv240JxwQP8AmkYdm26wGP/1I8QmIv0ZosgDJDlSzD73FEdj1BOpXMc06VrxX5KxTKhadFNomT2SWutUnpg=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-fON1WZ+FGrHt/nLH290DoH47tYLK1kiAn+reMnc5P19IbxLZTZbrJi05Kv1Tjekinxrs4f5c0SA2eicLzy+rog=="], - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-BQao/dBhLCJqo953N1hadkcF3M/9G+i6qIgnMupfdpBQomwyhfV7Xfc5jjpCkm8HxfzaWAGrM/2nNnzronFqVQ=="], + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-VIViKuJIgQ5eb66Su0LshXQNf3oJ0QXQ3gDg/rXJ47mFN6wmoolUT7OwczRdjpHGIH4T99aPSLURb9YYoLZqmQ=="], - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-OUoNRXJGZMM4ivoU7QIzOvCLbavD1YnadNEairrtYhTi+gmGhyn3c2wToL9CxEs4Cw2Ab/KeQM39T1K+/e9YdQ=="], + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-JtUvQ4EP4+KtNkwSLFRIzHdFftfZ+CQ8g95xT6uBzCFCu23Lt4sr6neQXmNHLM9RJ9Vw20LdcTBXtw3h4J1qeA=="], - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-M6FeKRMi3oecpTy4EL5n1hLPWydw+xInFYQIzjbGYGBnFtW7IlJjnXrKr/Ev1GpMtmw44QCmrl8+ACEFPmRsIg=="], + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-N8TeITQmnPZNKgjVaoHm4pOUPAeIPWAqTZVhEltxEbWsYciC6NezCw/TjUudgoiU8ljpvpzxQiZ3TLWMvadNMg=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-OG8kBYAgX7lf32+xLzgirvuLffn1KNoszaSiButt45i2cRa5irk8LQXLYQ5Smij1SBTN4KMNcBsRwRrLPfIGyA=="], - "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-/8D8rOFs2VEwvHwsx68sb6nE7XfVr2wbJTbC1YuKBHPhHeMnOt7IHxr7CoT5wBWujdV4fjVoLPn1BXXP4Ijlow=="], + "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-3R52ZjnJhey3zlp9K1ixVGQIO9NOaHF/MJR5wLbFsEOn4EG8M41Cp6a7duMLEw7FYEO+ikO/5Q0vghErT3uaKg=="], - "@smithy/hash-node": ["@smithy/hash-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-lIZyQ7gDxURrnfkjalM0lKmDnfZYuPzNBYlkza3czPTQNVYsg4e0o90Zx/RpxhamKKOGsQGCsopp0ULsJqltNQ=="], + "@smithy/hash-node": ["@smithy/hash-node@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-MkyiJfdnDlBdmq26Cxskw2dtX6V/EgTjCriPc7Gq0084hncjIFVJ26IwHpauXJT2w79B4umF0erKi4epBR/WDA=="], - "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-Ziap41FoxpKqmlO9IE68NeFwPKhUJD4PVNcCQ2tl6IUCPSj0KykIuAPnJNWIQbWXvApwCauhRNlAFdt9KRvDpw=="], + "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-wdhUbsxG+xpUhtpPr6Qb5si1JizQjLJwZKP5uQQrHLIxEBJ27wrnWt6FEKs/6BBUA0aqfTbiZ/aUr6IqRfl8SA=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-jUH1Eth7Sgn4KPBX5OKYDRpNjzul7AzsIhxKXT1rHXPTSfY00/7Kb9RtNil5SDAlPPsxaUiesR/rql2wjackmw=="], + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-KWyzbLxpEcr4iU8A/Bu4zZN9w9LdXT6SO2jfbwP21xdNr2JyW8XBowOKViG/dHp912ekAmtJ7SDfPapj7yS7JQ=="], "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/md5-js": ["@smithy/md5-js@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-LYcuBrO9oiajdRFHyFx3FJAWNKrP89s0grI6mcfpwTAeX2ZJ/9Xyi7Imghh9LT6CIcAy6/k6/MpoUiPNjXr1/w=="], + "@smithy/md5-js": ["@smithy/md5-js@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-/TkyVulxTrirvSUs8hvyYH1V/sKxaO2RTmAW7oh6D6wyNPh8TPUxpk1RYSY5wd8bMZpKfh8FSEMIkLSTnN/Pjw=="], - "@smithy/middleware-compression": ["@smithy/middleware-compression@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-wZQpnjrGSO2IFxhwWNaeRzHh2swSwRGWaCVgQN9zqYdtP98tcNYyqI7YvPeVTwf9CvQTas7xlmR3NY5L1i32mg=="], + "@smithy/middleware-compression": ["@smithy/middleware-compression@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-Kt/HTMOuG3YwaWc06e+PiFziIlDdK8fO2KcYZXUTqzLF4na5XewzNwvmgaOao8TcT63paPdqVSsVdBy7FTg2dA=="], - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-nfpYCrzSFAgfIXmIHFTjOGNeTV3DVF5E5rfi3ZuNfsOjKSpePBOJF3rjyXlWYND0anvxVoqioIwClWCNdKt4Og=="], + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-hLdaOvB2JIZhOa6REhHJHXQavMQC5EvewIiWM/mk9AWGlwoo6QyAXlYsp621AexTqY44558s3e3vzLHwyPhlsA=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-zdG5bJZOiM2PRgL2lwcgui6uwZ+s5y6Qsk/rk05Q69sZJT6oi1x+v8Kn++V/q9VY94EgOtEe5kivpu+eGau0wQ=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-yPaTGexBoXq70QMw/dIq/E4pLQMgBtSmAV23XyZm9UcMoGMS7efa2HMy+LvhlnDgyqCeXn8mQ7k+e4uD6rbjew=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.6.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-MWppaYUlc+W4cU2JZnYuMFeOxCWbKO4A57BWti6aCb7hRBK3+CL6llADGpX084hjImsqr3EvCGewArOj7G81eA=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.7.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-Br+n69+Hc6HwZZmRfhrEB7q7C6MZBghxlCugZHnvnPJN/bsMYG3d4hzhXjJr4EyBkxhe5hcvtZpgUDJhdmV22g=="], - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-I3fPVYKKEog3a3qdqt1nttP1NBuQOAlNoQxEp6j5pMogSx0HHfid63difhcDgslV6p1XsTXG6D6ieTe13ycJtQ=="], + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-bDnLiVuVciCC4d2n/PCcGJrKwgQupNIeuMNZvkStsGGeeVJ9WDjTpDwEYZTiXSIFszvzt7FVX3l5rsB3puNDbA=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-QhNiWfg47Kl4SJHmuQvnlzCtlD1eX1J7d/vuuttIE17Ra2YUKp9Srv5lCwa3OvoYaSNWMKYn0PjGIsfCLMJsEA=="], + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-tZUD0fE+/aLzLS4b75SDyQXBybPCI9UqwEAhDRmME8ObjEtnMnA6Hrt0FCNMN+JPoCtcrbUS0cHPXFTQMDtgoA=="], - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-M+gG6eQ0y073mSmNB+erRXJvwpsqsN72ol2w6vcd8FEKeG7pqYK0JvzfVqONkPj2ElBB2pg+cU13I850b//Wag=="], + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-hwea2f5OKcsZMKGgMYzWyclQKoMMbXzFVuv4033sc23dEjGOscqQ0hGHLDQcSneSsIZ8WcwxCV9y+ou34xoizA=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg=="], - "@smithy/property-provider": ["@smithy/property-provider@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-0rhHv1Ww27kajF6qewme2aRtJmKFtSwE6EZ2dj5KxdX/R3ANsUugqTnH0tvpZwGiQ3MOMhetuCGFAeKVv3/Onw=="], + "@smithy/property-provider": ["@smithy/property-provider@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-btjt5ZSO1JOrFJM8Wzzuqlzu3pc+iGb3InhyRbHX/LzK4gn4/SnfjCEdNAf912tcgcjfi5b2kiaNGz9vlT1Eog=="], - "@smithy/protocol-http": ["@smithy/protocol-http@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-H6S7NyaaL+7qO8kIL7VQ7KyrGnKXdllGzJqvtp3hvDen25UOydKV51qGDVK0UciW125jV3CoLJQy/ihc0OEC6A=="], + "@smithy/protocol-http": ["@smithy/protocol-http@5.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-gqvRWWZIcqmj7iS68p+hrxiOg1fGQcfzNPUlSGJ69hzLHyCyIRApasCpAp/xMGRgb6QqVH/YQhztOYgs+ZI3kA=="], - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-In8gYD2R66EKlGAq9QrNKVrMOGaGBD7LUNp2kUjeQ4V9zNktFIXBPmrCySr4YYo+jVeVL6CnWj26sOamcF0qIg=="], + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-O/cWx1tDsuyi5I1EkfsrJMnVNfcSvpKmAqp/dKtVfFSIm9Wa/IgVYV7x98OAK7T38eLfRU5/xpVgolC84Ul2UQ=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-vW6UdK7e7gV2wU/tXRsPq4pMQMusb8VymdVOyIFNA1FtyRmEClRFkYDtYI8UcO/HM0wK3qqjvvQs3HOlbgMbdg=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.13.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tAf35/JW/DvMlACcazcoIOKOV0JBqyOvxjPTEME9W+m9wLcE0G1rwADc7Ntu38rY5C9OH8jZjpo4tbtjmIjEBQ=="], + "@smithy/smithy-client": ["@smithy/smithy-client@4.14.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-pBJs2oWyl/drgw1lQOdwjXEwEeL36PN/CeRt33lwBu1OZTmoKqQjp93vcjM9fjv5ETsgEzB7WLSX6rYKKP0Eqw=="], - "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], + "@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], - "@smithy/url-parser": ["@smithy/url-parser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-9MRJzwUrlswwHogOR7raDcykuzojZn74qGdQdbEQLVaixlvJuMiIT0g/CejKcmAIgrUVs8brBrnGtmYmBc0iuA=="], + "@smithy/url-parser": ["@smithy/url-parser@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-E73GGqNThq6SLLOgQKU5re/iDc1oPk21zPr0t4KUD/sj6qlB06vQX/5xu3H8lTnCqWh9oLr1tXsv2Cpu74TTLg=="], - "@smithy/util-base64": ["@smithy/util-base64@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA=="], + "@smithy/util-base64": ["@smithy/util-base64@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-SF3V9ZZ9KotchuyxHdOvi1Y8OO7ZS+mDzoasCIrni9HEDf/BsBqCA9BAKHG+waerz4nutHPGDMRQw8B6VtVCsg=="], - "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-+3vGcNHuvzuFLVWL9/wJgucOuQWufhuGhb3oxVDj9SWFGtwkOmtC2nFUwVC2IJoPe45uhs6TAb8bgE4IXDSPzA=="], + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-JU3CDQScfAA9inuNyIQVNbHJ54fhtwXQqwBkR0xQN9lyGkFgFKnzHFgNQonfu67O5kdcnv1bOxhqsfrwmg2i1A=="], - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-T15zTQJ/xKYdS0/3CFckhz1QBbhxmhk/xjL6FKvHKgkJPN4E985If2FI9CcV2kh2v0sfiWMfXVEOKFbqgw4m4w=="], + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-Hu7UCgEGGxjT8pUsaYq4K7tfhShBXYnRU68GRia3H7dzjtU4AX9/jdVS4qhNn4lSdxA+d76iRESNu0jduT1Pjg=="], "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-dRCZKu05AL7KQWrVuRJPotfjCRnvGkCjV56XNP067CRfyTtvgi/Ygu44qrBKb814Hsa52bWwDJ+Vt3pd04BjPA=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-T4/V3fCSnhNg5xLlxxo5H8YsBblVtCnvrSb+XLhUjngUzu8W53uAxdUOKXQTN3HWVBlBOa5sD+BJb6FOqNtkYg=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-tTR8tayMoa0WeRhtMH7j3WpHUtggBXjh7rBdf7j6POYI69R85gpWBW6B32kaJRnlQU8+0gOGAzJj50S7SU1Egw=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-4ZjhBmU8Dt1OFBY8GfKHalfPy0BF4/IrSGMuhiPRc81bbRbLP/rPH65LrLgokm3rd/wzRpTwSEKNeKSAnYHSdg=="], - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-kaB41eVUYC7ajVWUsZRqagxwRaa3VupjQ/Z2Z2v/Vffh/gJ/fFOS25s6mTyR2Lw1FrnBbRWo1iShR9BhekpPeQ=="], + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-g8tR/yXtx08j1NMdaFsMy0caBFeTl6l4fbQWvyjKQJ5rUMf5oqV69iyrqwfl7tuD9N9cJo23yqpzrGmbYp8r3g=="], - "@smithy/util-middleware": ["@smithy/util-middleware@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-TrAgOcL63TRi7G92arTzq0n+VDrmZifwP1I1T9y2xU3lJpybsHdm33S2d3xaFfG0c8zJNIF9yYRqLSe6rbhH/A=="], + "@smithy/util-middleware": ["@smithy/util-middleware@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-XMhUiohsBJVwzJeS+w8y6E43I4rz/5ZpreSQAa6/gtNiXVBFhSw0inCKod5sJxuEETY2tTtK132lKcHVZAFgEQ=="], - "@smithy/util-retry": ["@smithy/util-retry@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-E/kFnvWQL6rIPr0Ucjk8oDgJSkKx2bv0nJkJ/cB3ywys7xCqeL1AXP9liHjgYONdQ+MKw/xT06IQK3vgbtu2Ww=="], + "@smithy/util-retry": ["@smithy/util-retry@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-l8i4lcA4AzvOc+aiMz8UyU7lSEgOmXd1Xktrhp7h1sO55j1VygpVUr/dAIfX9liY5HbDvDhTFZCgVHsYGlAoWw=="], - "@smithy/util-stream": ["@smithy/util-stream@4.6.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-g+hQ45sPnaIDU4CnaG8EufmeWwziQlcpIvPG6hVY7v65RcUgasM63J/WNfSsXEcZ1zFu9rS/r/qqfDxkIrQtDw=="], + "@smithy/util-stream": ["@smithy/util-stream@4.7.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-lZfQFdsC48pRqCSv8R1jrjaAOJadldqH6ZbnaWgv9mKy77yYrMsqFam131hoa1obeydN2Qz52uUu+k9Og4W9sQ=="], - "@smithy/util-utf8": ["@smithy/util-utf8@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-tAa4sePYB7mlJzdYbdBqdv37KwFKWixmM/r3ihcI0HFOVjf+a5oGvtcLXcGm4S1bY4DFsLAIOHgjubtp+oRufw=="], + "@smithy/util-utf8": ["@smithy/util-utf8@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-dMvQY14daYwEfKR+/ACROrUwJ5onUue7d9o4KJo4gaecn5eVzxlCbSeU9GSh0ojFpIiI1bpnJJxO1wY2VXDEtQ=="], - "@smithy/util-waiter": ["@smithy/util-waiter@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-oTt3OP9NcJkrySCSCCdSbP6XLSMNgOmt/ulaiYtb0Ng6tfEWtXQ1mwfyqmLd+GapmDUjbU2mgkf7QIq9H4ij/g=="], + "@smithy/util-waiter": ["@smithy/util-waiter@4.5.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-BbTtz3ULP1na8PvteT1buTof7rUxcQd127FEjCT6jO99G2H3BR/OAlBRjWPZKJ9QvJPdYupR9/ai+rrnA8xneg=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], @@ -1554,35 +1553,35 @@ "@tabler/icons-react": ["@tabler/icons-react@3.44.0", "", { "dependencies": { "@tabler/icons": "3.44.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "postcss": "8.5.15", "tailwindcss": "4.3.1" } }, "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], @@ -1720,7 +1719,7 @@ "@types/html-to-text": ["@types/html-to-text@9.0.4", "", {}, "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ=="], - "@types/inquirer": ["@types/inquirer@8.2.12", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ=="], + "@types/inquirer": ["@types/inquirer@8.2.13", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-shSvl3mn4Z8AK627kA1vx8PYkyH6CdIjV5NYYj7a0xIxzmG3ZgzEpzCi3CWfktjAlq+0Z0wHJGtWNiACaYpeOg=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], @@ -1738,7 +1737,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="], + "@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -1788,21 +1787,21 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.9", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.9", "vitest": "4.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g=="], - "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="], + "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="], + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], + "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], "@webgpu/types": ["@webgpu/types@0.1.70", "", {}, "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA=="], @@ -1816,7 +1815,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -1826,7 +1825,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@5.0.197", "", { "dependencies": { "@ai-sdk/gateway": "2.0.98", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iUzFb2M3ZUL/Bbmfonh75DIZ354svWO5xh8VPC2wYNR6zzEMFghPOlJG5rtEpqRa037lHfdcjt0qmzg3em/WDw=="], + "ai": ["ai@5.0.203", "", { "dependencies": { "@ai-sdk/gateway": "2.0.102", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.26", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U50l+Np0IDzkUJ59QOBb80y3x1QH8+XJqzoIIxkO756mPlOuoRfj80CFIBKtaHmDSTJoEAMdt0iaaDOqMCGZkQ=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -1860,7 +1859,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -1876,7 +1875,7 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - "axios": ["axios@1.17.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw=="], + "axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -1886,7 +1885,7 @@ "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], @@ -1908,7 +1907,7 @@ "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -1950,7 +1949,7 @@ "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], @@ -2230,7 +2229,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -2248,7 +2247,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "e2b": ["e2b@2.28.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-ZlP8Qw5SA0o+SLynqXugNwNoWMoQYyZWf8v/Z2oUSvzNxglH2SUQYcRCklscsH5WBsoB0X0biOh2S6P7LSWa8w=="], + "e2b": ["e2b@2.30.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-4kvfwh3QfPukrYmWEhrVLxL3WnQabzHabvhIRmvk6oU/YTWQtCrlZX+jaA9XBtVI/vQUbu5E5a6GlOhDXmcKzg=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -2260,7 +2259,7 @@ "effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.370", "", {}, "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g=="], + "electron-to-chromium": ["electron-to-chromium@1.5.373", "", {}, "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -2280,7 +2279,7 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], @@ -2380,7 +2379,7 @@ "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - "fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], + "fast-xml-parser": ["fast-xml-parser@5.9.0", "", { "dependencies": { "@nodable/entities": "^2.2.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^1.0.1", "path-expression-matcher": "^1.5.0", "strnum": "^2.4.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-duBuXbyIhEeNO4GjFuVqr0nF047oNwr18aum+zJyqo0MUG/n7Afgs3Qv3D6VN3ONedUKxiuFlPiMGIa0Z11chA=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -2408,7 +2407,7 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], @@ -2640,6 +2639,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@1.0.1", "", {}, "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -2668,7 +2669,7 @@ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], @@ -3040,7 +3041,7 @@ "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], - "nodemailer": ["nodemailer@8.0.7", "", {}, "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow=="], + "nodemailer": ["nodemailer@8.0.9", "", {}, "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -3064,7 +3065,7 @@ "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], - "obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], "officeparser": ["officeparser@5.2.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10", "concat-stream": "^2.0.0", "file-type": "^16.5.4", "node-ensure": "^0.0.0", "pdfjs-dist": "^5.3.31", "yauzl": "^3.1.3" }, "bin": { "officeparser": "officeParser.js" } }, "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA=="], @@ -3254,7 +3255,7 @@ "react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="], - "react-hook-form": ["react-hook-form@7.78.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA=="], + "react-hook-form": ["react-hook-form@7.79.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw=="], "react-pdf": ["react-pdf@10.4.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="], @@ -3408,7 +3409,7 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], + "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -3564,7 +3565,7 @@ "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -3602,7 +3603,7 @@ "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], - "tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], + "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3620,7 +3621,7 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -3722,7 +3723,7 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], - "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], @@ -3822,7 +3823,7 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1065.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-qdHQntq82gMqG6Tf8xrgmhJxacaYkxW4PEeDg/ISMVJ84EWe7iD6JyCTgbyox3uNDH6vqEJ8GUiTaXCq307zVw=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1069.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/nested-clients": "^3.997.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ks4X+kngC3PA5howV7Qu1TgG4bfC4jPykKdvw3nmBSXR9yZxRJouBholFSNQ5kY3L+Fgwyw+LCjzQmNi+KR91g=="], "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], @@ -3856,7 +3857,7 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -3866,7 +3867,67 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], - "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/exporter-prometheus/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-prometheus/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/exporter-zipkin/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/exporter-zipkin/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/sdk-node/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.7.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], @@ -3874,9 +3935,9 @@ "@radix-ui/react-collapsible/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], @@ -3884,7 +3945,7 @@ "@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "@radix-ui/react-menu/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], @@ -3892,9 +3953,9 @@ "@radix-ui/react-navigation-menu/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.5", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ=="], - "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], @@ -3904,7 +3965,7 @@ "@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "@radix-ui/react-roving-focus/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], @@ -3912,13 +3973,13 @@ "@radix-ui/react-scroll-area/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "@radix-ui/react-select/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], "@radix-ui/react-select/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.5", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg=="], + "@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ=="], "@radix-ui/react-slider/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], @@ -3966,13 +4027,13 @@ "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -4028,13 +4089,13 @@ "@types/busboy/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], - "@types/cors/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/cors/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/fluent-ffmpeg/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@types/jsdom/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], - "@types/node-fetch/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/nodemailer/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4042,9 +4103,9 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@types/through/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/through/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/ws/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -4062,15 +4123,17 @@ "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - "c12/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], - "chrome-launcher/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4078,8 +4141,6 @@ "cmdk/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "cmdk/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4096,7 +4157,7 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "docx/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -4108,7 +4169,7 @@ "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "engine.io/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "engine.io/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], @@ -4132,19 +4193,19 @@ "fumadocs-core/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], - "fumadocs-mdx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "fumadocs-mdx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "fumadocs-openapi/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], "fumadocs-openapi/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], - "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], "fumadocs-ui/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], @@ -4242,7 +4303,7 @@ "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], - "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -4252,7 +4313,7 @@ "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], - "protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -4290,11 +4351,11 @@ "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - "simstudio/@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="], + "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "simstudio/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "simstudio-ts-sdk/@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="], + "simstudio-ts-sdk/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -4324,7 +4385,7 @@ "tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], - "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "twilio/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -4406,7 +4467,7 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -4520,57 +4581,57 @@ "fumadocs-core/shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "fumadocs-mdx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "fumadocs-mdx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "fumadocs-mdx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "fumadocs-mdx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "fumadocs-mdx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "fumadocs-mdx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "fumadocs-mdx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "fumadocs-mdx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "fumadocs-mdx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "fumadocs-mdx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "fumadocs-mdx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "fumadocs-mdx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "fumadocs-mdx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "fumadocs-mdx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "fumadocs-mdx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "fumadocs-mdx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "fumadocs-mdx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "fumadocs-mdx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "fumadocs-mdx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "fumadocs-mdx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "fumadocs-mdx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "fumadocs-mdx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "fumadocs-mdx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "fumadocs-mdx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "fumadocs-mdx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "fumadocs-mdx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "fumadocs-mdx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "fumadocs-mdx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "fumadocs-mdx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "fumadocs-mdx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "fumadocs-mdx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "fumadocs-mdx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "fumadocs-mdx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "fumadocs-mdx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "fumadocs-mdx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "fumadocs-mdx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "fumadocs-mdx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "fumadocs-mdx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "fumadocs-mdx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "fumadocs-mdx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "fumadocs-mdx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "fumadocs-mdx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "fumadocs-mdx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "fumadocs-mdx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "fumadocs-mdx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "fumadocs-mdx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "fumadocs-openapi/shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], @@ -4718,63 +4779,63 @@ "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "sim/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + "sim/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "twilio/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -4796,11 +4857,11 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], "@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], @@ -4808,7 +4869,7 @@ "@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="], - "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@trigger.dev/core/socket.io/engine.io/cookie": ["cookie@0.4.2", "", {}, "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="], @@ -4868,7 +4929,7 @@ "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], "rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -4882,11 +4943,11 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -4904,7 +4965,7 @@ "log-update/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/package.json b/package.json index 63e3be668c6..c552dbad165 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", "check:react-query": "bun run scripts/check-react-query-patterns.ts --check", "check:utils": "bun run scripts/check-utils-enforcement.ts", + "check:migrations": "bun run scripts/check-migrations-safety.ts", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", "mship-tools:generate": "bun run scripts/sync-tool-catalog.ts", @@ -41,6 +42,8 @@ "trace-attribute-values-contract:check": "bun run scripts/sync-trace-attribute-values-contract.ts --check", "trace-events-contract:generate": "bun run scripts/sync-trace-events-contract.ts", "trace-events-contract:check": "bun run scripts/sync-trace-events-contract.ts --check", + "metrics-contract:generate": "bun run scripts/sync-metrics-contract.ts", + "metrics-contract:check": "bun run scripts/sync-metrics-contract.ts --check", "mship:generate": "bun run scripts/generate-mship-contracts.ts", "mship:check": "bun run scripts/generate-mship-contracts.ts --check", "prepare": "bun husky", diff --git a/packages/db/migrations/0238_workspace_scoped_permission_groups.sql b/packages/db/migrations/0238_workspace_scoped_permission_groups.sql new file mode 100644 index 00000000000..87b4dde2ce3 --- /dev/null +++ b/packages/db/migrations/0238_workspace_scoped_permission_groups.sql @@ -0,0 +1,34 @@ +-- Replay-safety: this file ends in CONCURRENTLY index ops below an embedded COMMIT, +-- so a failure there replays the whole file from the top — every statement here is idempotent. +CREATE TABLE IF NOT EXISTS "permission_group_workspace" ( + "id" text PRIMARY KEY NOT NULL, + "permission_group_id" text NOT NULL, + "workspace_id" text NOT NULL, + "organization_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "permission_group" ADD COLUMN IF NOT EXISTS "applies_to_all_workspaces" boolean DEFAULT true NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "permission_group_workspace" ADD CONSTRAINT "permission_group_workspace_permission_group_id_permission_group_id_fk" FOREIGN KEY ("permission_group_id") REFERENCES "public"."permission_group"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "permission_group_workspace" ADD CONSTRAINT "permission_group_workspace_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "permission_group_workspace" ADD CONSTRAINT "permission_group_workspace_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "permission_group_workspace_group_id_idx" ON "permission_group_workspace" USING btree ("permission_group_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "permission_group_workspace_workspace_id_idx" ON "permission_group_workspace" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "permission_group_workspace_group_workspace_unique" ON "permission_group_workspace" USING btree ("permission_group_id","workspace_id");--> statement-breakpoint +-- permission_group_member is an existing table: swap its (organization_id, user_id) +-- index CONCURRENTLY so the build/drop never write-locks the relation (runner +-- convention — plain CREATE/DROP INDEX takes ACCESS EXCLUSIVE for the whole op). +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "permission_group_member_organization_user_idx" ON "permission_group_member" USING btree ("organization_id","user_id");--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "permission_group_member_organization_user_unique";--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0238_snapshot.json b/packages/db/migrations/meta/0238_snapshot.json new file mode 100644 index 00000000000..f669541d81d --- /dev/null +++ b/packages/db/migrations/meta/0238_snapshot.json @@ -0,0 +1,16584 @@ +{ + "id": "ec49405c-a007-4cf2-9706-ddb27a78ef19", + "prevId": "52122264-d797-4980-bf94-301ea12da917", + "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_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.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.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applies_to_all_workspaces": { + "name": "applies_to_all_workspaces", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_group_id_idx": { + "name": "permission_group_workspace_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "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_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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 + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + } + }, + "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.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "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 1ebad533fb8..b7ce43b7f77 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1660,6 +1660,13 @@ "when": 1781398805524, "tag": "0237_user_settings_timezone", "breakpoints": true + }, + { + "idx": 238, + "version": "7", + "when": 1781554278388, + "tag": "0238_workspace_scoped_permission_groups", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 96fd728c1c1..7a2c3e4d0b9 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2940,6 +2940,7 @@ export const permissionGroup = pgTable( createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), isDefault: boolean('is_default').notNull().default(false), + appliesToAllWorkspaces: boolean('applies_to_all_workspaces').notNull().default(true), }, (table) => ({ createdByIdx: index('permission_group_created_by_idx').on(table.createdBy), @@ -2953,6 +2954,38 @@ export const permissionGroup = pgTable( }) ) +/** + * Workspaces a `permission_group` targets when `applies_to_all_workspaces` is + * false. Rows are absent for organization-wide groups. A group with zero rows + * while `applies_to_all_workspaces = false` governs no workspace. + */ +export const permissionGroupWorkspace = pgTable( + 'permission_group_workspace', + { + id: text('id').primaryKey(), + permissionGroupId: text('permission_group_id') + .notNull() + .references(() => permissionGroup.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + permissionGroupIdIdx: index('permission_group_workspace_group_id_idx').on( + table.permissionGroupId + ), + workspaceIdIdx: index('permission_group_workspace_workspace_id_idx').on(table.workspaceId), + groupWorkspaceUnique: uniqueIndex('permission_group_workspace_group_workspace_unique').on( + table.permissionGroupId, + table.workspaceId + ), + }) +) + export const permissionGroupMember = pgTable( 'permission_group_member', { @@ -2975,7 +3008,7 @@ export const permissionGroupMember = pgTable( table.permissionGroupId, table.userId ), - organizationUserUnique: uniqueIndex('permission_group_member_organization_user_unique').on( + organizationUserIdx: index('permission_group_member_organization_user_idx').on( table.organizationId, table.userId ), diff --git a/packages/testing/src/mocks/feature-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts similarity index 88% rename from packages/testing/src/mocks/feature-flags.mock.ts rename to packages/testing/src/mocks/env-flags.mock.ts index da512c99a8d..02be7bfbbff 100644 --- a/packages/testing/src/mocks/feature-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -1,15 +1,15 @@ import { vi } from 'vitest' /** - * Static mock module for `@/lib/core/config/feature-flags`. + * Static mock module for `@/lib/core/config/env-flags`. * All boolean flags default to `false` for safe test isolation. * * @example * ```ts - * vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + * vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) * ``` */ -export const featureFlagsMock = { +export const envFlagsMock = { isProd: false, isDev: false, isTest: true, diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index e8e4de49e24..7bff8617850 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -52,13 +52,13 @@ export { export { encryptionMock, encryptionMockFns } from './encryption.mock' // Env mocks export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' +// Env flag mocks +export { envFlagsMock } from './env-flags.mock' // Execution preprocessing mocks (for @/lib/execution/preprocessing) export { executionPreprocessingMock, executionPreprocessingMockFns, } from './execution-preprocessing.mock' -// Feature flag mocks -export { featureFlagsMock } from './feature-flags.mock' // Executor mocks - use side-effect import: import '@sim/testing/mocks/executor' // Fetch mocks export { diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index b2bfbb42513..fb72acb5b1c 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1025,6 +1025,14 @@ export const schemaMock = { createdAt: 'createdAt', updatedAt: 'updatedAt', isDefault: 'isDefault', + appliesToAllWorkspaces: 'appliesToAllWorkspaces', + }, + permissionGroupWorkspace: { + id: 'id', + permissionGroupId: 'permissionGroupId', + workspaceId: 'workspaceId', + organizationId: 'organizationId', + createdAt: 'createdAt', }, permissionGroupMember: { id: 'id', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index d82996804ca..cee05f4d2b1 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: 828, - zodRoutes: 828, + totalRoutes: 852, + zodRoutes: 852, nonZodRoutes: 0, } as const diff --git a/scripts/check-migrations-safety.test.ts b/scripts/check-migrations-safety.test.ts new file mode 100644 index 00000000000..af966d6e8dd --- /dev/null +++ b/scripts/check-migrations-safety.test.ts @@ -0,0 +1,200 @@ +/** + * Run with: bun test scripts/check-migrations-safety.test.ts + * (Root scripts are bun-native and not part of the turbo/vitest workspaces.) + */ +import { describe, expect, test } from 'bun:test' +import { lintSql } from './check-migrations-safety.ts' + +const rules = (sql: string) => lintSql(sql).map((f) => `${f.tier}:${f.rule}`) + +describe('additive / safe', () => { + test('nullable add column passes', () => { + expect(lintSql('ALTER TABLE "webhook" ADD COLUMN "provider_config" json;')).toEqual([]) + }) + + test('NOT NULL with DEFAULT passes', () => { + expect(lintSql('ALTER TABLE "user" ADD COLUMN "flag" boolean DEFAULT false NOT NULL;')).toEqual( + [] + ) + }) + + test('CREATE TABLE plus index and FK on that new table passes', () => { + const sql = `CREATE TABLE "kb" ("id" text PRIMARY KEY NOT NULL, "user_id" text NOT NULL); +--> statement-breakpoint +CREATE INDEX "kb_user_id_idx" ON "kb" USING btree ("user_id"); +--> statement-breakpoint +ALTER TABLE "kb" ADD CONSTRAINT "kb_user_fk" FOREIGN KEY ("user_id") REFERENCES "user"("id");` + expect(lintSql(sql)).toEqual([]) + }) + + test('CONCURRENTLY index after a COMMIT breakpoint passes', () => { + const sql = `COMMIT; +--> statement-breakpoint +SET lock_timeout = 0; +--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_x" ON "embedding" ("kb_id");` + expect(lintSql(sql)).toEqual([]) + }) +}) + +describe('hard errors', () => { + test('ADD COLUMN NOT NULL without default', () => { + expect(rules('ALTER TABLE "user" ADD COLUMN "email" text NOT NULL;')).toEqual([ + 'error:add-not-null-no-default', + ]) + }) + + test('RENAME column', () => { + expect(rules('ALTER TABLE "marketplace" RENAME COLUMN "executions" TO "views";')).toEqual([ + 'error:rename', + ]) + }) + + test('CREATE INDEX on existing table without CONCURRENTLY', () => { + expect(rules('CREATE INDEX "idx_y" ON "embedding" ("kb_id");')).toEqual([ + 'error:index-not-concurrent', + ]) + }) + + test('CONCURRENTLY index without IF NOT EXISTS', () => { + const sql = `COMMIT; +--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_z" ON "embedding" ("kb_id");` + expect(rules(sql)).toEqual(['error:concurrent-index-not-idempotent']) + }) + + test('CONCURRENTLY index without a preceding COMMIT', () => { + expect( + rules('CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_z" ON "embedding" ("kb_id");') + ).toEqual(['error:concurrent-index-no-commit']) + }) + + test('ADD FOREIGN KEY on existing table without NOT VALID', () => { + expect( + rules( + 'ALTER TABLE "session" ADD CONSTRAINT "s_fk" FOREIGN KEY ("uid") REFERENCES "user"("id");' + ) + ).toEqual(['error:constraint-not-valid']) + }) +}) + +describe('annotate tier', () => { + const drop = 'ALTER TABLE "webhook" DROP COLUMN "secret";' + + test('DROP COLUMN unannotated fails', () => { + expect(rules(drop)).toEqual(['error:drop-column']) + }) + + test('DROP COLUMN annotated passes', () => { + const sql = `-- migration-safe: secret read removed in v0.6.1 (#1234), shipped two deploys ago\n${drop}` + expect(lintSql(sql)).toEqual([]) + }) + + test('annotation tolerates an intervening statement-breakpoint line', () => { + const sql = `ALTER TABLE "webhook" ADD COLUMN "provider_config" json; +--> statement-breakpoint +-- migration-safe: secret read removed in v0.6.1 (#1234) +${drop}` + expect(lintSql(sql)).toEqual([]) + }) + + test('dangling annotation with empty reason fails', () => { + const sql = `-- migration-safe:\n${drop}` + const found = lintSql(sql) + expect(found).toHaveLength(1) + expect(found[0].tier).toBe('error') + expect(found[0].message).toContain('no reason') + }) + + test('annotation on the wrong statement does not bleed', () => { + const sql = `-- migration-safe: removing secret +ALTER TABLE "webhook" ADD COLUMN "x" json; +--> statement-breakpoint +${drop}` + expect(rules(sql)).toEqual(['error:drop-column']) + }) + + test('type change and DROP TABLE are annotate-tier', () => { + expect( + rules( + 'ALTER TABLE "user_table_rows" ALTER COLUMN "order_key" SET DATA TYPE text COLLATE "C";' + ) + ).toEqual(['error:alter-type']) + expect(rules('DROP TABLE "marketplace_execution" CASCADE;')).toEqual(['error:drop-table']) + }) +}) + +describe('warnings (non-blocking)', () => { + test('UPDATE backfill warns but does not error', () => { + const found = lintSql('UPDATE "user_table_definitions" SET "schema" = \'{}\' WHERE id = \'1\';') + expect(found.map((f) => f.tier)).toEqual(['warn']) + }) + + test('UPDATE without WHERE flags the whole-table note', () => { + const found = lintSql('UPDATE "user" SET "active" = true;') + expect(found[0].tier).toBe('warn') + expect(found[0].message).toContain('no WHERE') + }) +}) + +describe('review fixes', () => { + test('RENAME CONSTRAINT is metadata-only — not flagged', () => { + expect( + lintSql('ALTER TABLE "permission_group" RENAME CONSTRAINT "old_fk" TO "new_fk";') + ).toEqual([]) + }) + + test('ALTER INDEX ... RENAME is metadata-only — not flagged', () => { + expect(lintSql('ALTER INDEX "old_idx" RENAME TO "new_idx";')).toEqual([]) + }) + + test('table RENAME TO is still a hard error', () => { + expect(rules('ALTER TABLE "marketplace" RENAME TO "listings";')).toEqual(['error:rename']) + }) + + test('plain DROP INDEX is a hard error (ACCESS EXCLUSIVE lock)', () => { + expect(rules('DROP INDEX "permission_group_workspace_name_unique";')).toEqual([ + 'error:drop-index-not-concurrent', + ]) + }) + + test('DROP INDEX CONCURRENTLY after a COMMIT passes clean', () => { + const sql = `COMMIT; +--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "stale_idx";` + expect(lintSql(sql)).toEqual([]) + }) + + test('DROP INDEX CONCURRENTLY without IF EXISTS is not idempotent', () => { + const sql = `COMMIT; +--> statement-breakpoint +DROP INDEX CONCURRENTLY "stale_idx";` + expect(rules(sql)).toEqual(['error:concurrent-drop-index-not-idempotent']) + }) + + test('DROP INDEX CONCURRENTLY without a preceding COMMIT errors', () => { + expect(rules('DROP INDEX CONCURRENTLY IF EXISTS "stale_idx";')).toEqual([ + 'error:concurrent-drop-index-no-commit', + ]) + }) + + test('alter-type does not match TYPE inside a string default', () => { + expect(lintSql(`ALTER TABLE "x" ALTER COLUMN "y" SET DEFAULT 'change TYPE later';`)).toEqual([]) + }) +}) + +describe('parser robustness', () => { + test('semicolon inside a string literal does not split', () => { + expect(lintSql(`ALTER TABLE "x" ADD COLUMN "y" text DEFAULT 'a;b' NOT NULL;`)).toEqual([]) + }) + + test('dollar-quoted DO block is one statement; FK on a new table is suppressed', () => { + const sql = `CREATE TABLE "jobs" ("id" text PRIMARY KEY NOT NULL, "wid" text NOT NULL); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "jobs" ADD CONSTRAINT "jobs_fk" FOREIGN KEY ("wid") REFERENCES "workspace"("id"); +EXCEPTION WHEN duplicate_object THEN null; +END $$;` + expect(lintSql(sql)).toEqual([]) + }) +}) diff --git a/scripts/check-migrations-safety.ts b/scripts/check-migrations-safety.ts new file mode 100644 index 00000000000..5c4e299af23 --- /dev/null +++ b/scripts/check-migrations-safety.ts @@ -0,0 +1,491 @@ +#!/usr/bin/env bun +/** + * Guards new Drizzle migrations against deploy-window downtime. + * + * During a deploy the previously-deployed app code keeps serving against the + * freshly-migrated schema (blue/green keeps both versions live). A migration + * that is backward-incompatible with that older code — drops a column it still + * reads, renames, adds a NOT NULL its inserts don't populate — throws until the + * new code takes over. The fix is the expand/contract discipline: additive now, + * destructive only after the dependent code is gone. + * + * This lint is the deterministic half of that guard (the `/db-migrate` skill is + * the judgment half). It classifies every statement in migrations added on this + * branch: + * - HARD ERROR: ops that are essentially never one-deploy-safe. Rewrite them. + * - ANNOTATE: legitimate contract-phase ops. Acknowledge each with a + * `-- migration-safe: ` comment on the preceding line(s), + * only after confirming the dependent code already shipped out. + * - WARN: data backfills — surfaced for review, never block. + * + * Scope is new migration files only (git diff vs base); the existing corpus is + * grandfathered. Usage: + * bun run scripts/check-migrations-safety.ts [baseRef] # base defaults to origin/staging + * bun run scripts/check-migrations-safety.ts --all # whole corpus + * bun run scripts/check-migrations-safety.ts --dir # a directory + */ +import { execFileSync } from 'node:child_process' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') +const MIGRATIONS_DIR = 'packages/db/migrations' +const ANNOTATION_PREFIX = '-- migration-safe:' + +type Tier = 'error' | 'warn' + +interface Finding { + line: number + statement: string + tier: Tier + rule: string + message: string +} + +interface Statement { + sql: string + startLine: number +} + +/** Strip quotes and any schema prefix so `"public"."user"` and `"user"` match. */ +function bareName(raw: string): string { + const unquoted = raw.replace(/"/g, '') + const parts = unquoted.split('.') + return (parts[parts.length - 1] ?? unquoted).toLowerCase() +} + +/** + * Split SQL into statements with their 1-based start line, respecting line + * comments (`--`), block comments, and single-quoted strings so a `;` inside + * any of them does not terminate a statement. + */ +function parseStatements(content: string): Statement[] { + const statements: Statement[] = [] + let buf = '' + let startOffset = -1 + let inLine = false + let inBlock = false + let inStr = false + let dollarTag: string | null = null + + const lineAt = (offset: number): number => { + let line = 1 + for (let i = 0; i < offset; i++) if (content[i] === '\n') line++ + return line + } + const flush = () => { + const sql = buf.trim() + if (sql.length > 0 && startOffset >= 0) statements.push({ sql, startLine: lineAt(startOffset) }) + buf = '' + startOffset = -1 + } + + for (let i = 0; i < content.length; i++) { + const c = content[i] + const next = content[i + 1] + + if (inLine) { + if (c === '\n') inLine = false + continue + } + if (inBlock) { + if (c === '*' && next === '/') { + inBlock = false + i++ + } + continue + } + if (inStr) { + buf += c + if (c === "'") { + if (next === "'") { + buf += "'" + i++ + } else { + inStr = false + } + } + continue + } + if (dollarTag) { + if (c === '$' && content.startsWith(dollarTag, i)) { + buf += dollarTag + i += dollarTag.length - 1 + dollarTag = null + } else { + buf += c + } + continue + } + if (c === '$') { + const tag = /^\$[A-Za-z_]*\$/.exec(content.slice(i))?.[0] + if (tag) { + if (startOffset < 0) startOffset = i + dollarTag = tag + buf += tag + i += tag.length - 1 + continue + } + } + if (c === '-' && next === '-') { + inLine = true + i++ + continue + } + if (c === '/' && next === '*') { + inBlock = true + i++ + continue + } + if (c === "'") { + inStr = true + if (startOffset < 0) startOffset = i + buf += c + continue + } + if (c === ';') { + flush() + continue + } + if (startOffset < 0 && !/\s/.test(c)) startOffset = i + buf += c + } + flush() + return statements +} + +/** + * Mirror of the api-validation annotation reader, for `--` SQL comments: + * scans up to three consecutive non-empty preceding lines for the prefix. + * `allowed` when a non-empty reason follows; `missingReason` flags a dangling + * annotation so it fails rather than silently passing. + */ +function readAnnotation( + lines: string[], + startLine: number +): { allowed: boolean; missingReason: boolean } { + let inspected = 0 + for (let i = startLine - 2; i >= 0 && inspected < 3; i--) { + const trimmed = lines[i]?.trim() ?? '' + if (trimmed.length === 0) continue + inspected++ + if (!trimmed.startsWith('--')) return { allowed: false, missingReason: false } + const idx = trimmed.indexOf(ANNOTATION_PREFIX) + if (idx === -1) continue + const reason = trimmed.slice(idx + ANNOTATION_PREFIX.length).trim() + if (reason.length === 0) return { allowed: false, missingReason: true } + return { allowed: true, missingReason: false } + } + return { allowed: false, missingReason: false } +} + +interface RawMatch { + kind: 'error' | 'annotate' | 'warn' + rule: string + message: string +} + +/** + * Classify one statement. `createdTables` holds tables created in the same + * migration — ops against a brand-new table have no old rows and no live + * traffic, so they are always safe and skipped. `sawCommit` tracks whether a + * `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts). + */ +function classify(sql: string, createdTables: Set, sawCommit: boolean): RawMatch[] { + const s = sql.replace(/\s+/g, ' ').trim() + const matches: RawMatch[] = [] + + const alterTable = s.match(/\bALTER TABLE (?:IF EXISTS )?(?:ONLY )?("?[.\w]+"?)/i) + const targetTable = alterTable ? bareName(alterTable[1]) : null + const onNewTable = targetTable !== null && createdTables.has(targetTable) + + if (/^CREATE (?:UNIQUE )?INDEX\b/i.test(s)) { + const on = s.match(/\bON ("?[.\w]+"?)/i) + const indexTable = on ? bareName(on[1]) : null + const concurrent = /\bCONCURRENTLY\b/i.test(s) + if (!(indexTable && createdTables.has(indexTable))) { + if (!concurrent) { + matches.push({ + kind: 'error', + rule: 'index-not-concurrent', + message: + 'CREATE INDEX on an existing table write-locks it for the whole build. Use CREATE INDEX CONCURRENTLY IF NOT EXISTS after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).', + }) + } else if (!/\bIF NOT EXISTS\b/i.test(s)) { + matches.push({ + kind: 'error', + rule: 'concurrent-index-not-idempotent', + message: + 'CREATE INDEX CONCURRENTLY must be IF NOT EXISTS — a failed build replays from the top and a partial INVALID index would be skipped forever.', + }) + } else if (!sawCommit) { + matches.push({ + kind: 'error', + rule: 'concurrent-index-no-commit', + message: + 'CREATE INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint and SET lock_timeout = 0 (see packages/db/scripts/migrate.ts).', + }) + } + } + } + + if ( + !onNewTable && + /\bADD COLUMN\b/i.test(s) && + /\bNOT NULL\b/i.test(s) && + !/\bDEFAULT\b/i.test(s) + ) { + matches.push({ + kind: 'error', + rule: 'add-not-null-no-default', + message: + 'ADD COLUMN NOT NULL with no DEFAULT breaks old inserts (and fails on existing rows). Add it nullable or with a DEFAULT, backfill, then SET NOT NULL in a later migration once code populates it.', + }) + } + + if (/\bRENAME COLUMN\b/i.test(s) || /^ALTER TABLE\b[^;]*\bRENAME TO\b/i.test(s)) { + matches.push({ + kind: 'error', + rule: 'rename', + message: + 'RENAME of a column/table breaks old code reading the old name. Add the new column/table, dual-write in code, then drop the old one in a later deploy.', + }) + } + + if ( + !onNewTable && + /\bADD CONSTRAINT\b/i.test(s) && + /\b(FOREIGN KEY|CHECK)\b/i.test(s) && + !/\bNOT VALID\b/i.test(s) + ) { + matches.push({ + kind: 'error', + rule: 'constraint-not-valid', + message: + 'ADD CONSTRAINT FOREIGN KEY/CHECK on an existing table locks it and rejects old writes that violate it. Add it NOT VALID, then VALIDATE CONSTRAINT in a separate step.', + }) + } + + if (!onNewTable) { + if (/^DROP TABLE\b/i.test(s)) { + matches.push({ kind: 'annotate', rule: 'drop-table', message: 'DROP TABLE' }) + } + if (/\bDROP COLUMN\b/i.test(s)) { + matches.push({ kind: 'annotate', rule: 'drop-column', message: 'DROP COLUMN' }) + } + if (/\bDROP CONSTRAINT\b/i.test(s)) { + matches.push({ kind: 'annotate', rule: 'drop-constraint', message: 'DROP CONSTRAINT' }) + } + if (/\bDROP DEFAULT\b/i.test(s)) { + matches.push({ kind: 'annotate', rule: 'drop-default', message: 'DROP DEFAULT' }) + } + if (/\bSET NOT NULL\b/i.test(s)) { + matches.push({ kind: 'annotate', rule: 'set-not-null', message: 'SET NOT NULL' }) + } + if (/\bSET DATA TYPE\b/i.test(s) || /\bALTER COLUMN ("?[.\w]+"?) TYPE\b/i.test(s)) { + matches.push({ kind: 'annotate', rule: 'alter-type', message: 'column type change' }) + } + } + if (/^DROP INDEX\b/i.test(s)) { + if (!/\bCONCURRENTLY\b/i.test(s)) { + matches.push({ + kind: 'error', + rule: 'drop-index-not-concurrent', + message: + 'Plain DROP INDEX takes an ACCESS EXCLUSIVE lock on the table for the whole drop. Use DROP INDEX CONCURRENTLY after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).', + }) + } else if (!/\bIF EXISTS\b/i.test(s)) { + matches.push({ + kind: 'error', + rule: 'concurrent-drop-index-not-idempotent', + message: + 'DROP INDEX CONCURRENTLY must be IF EXISTS — a failed run replays from the top and would abort re-dropping an already-gone index.', + }) + } else if (!sawCommit) { + matches.push({ + kind: 'error', + rule: 'concurrent-drop-index-no-commit', + message: + 'DROP INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).', + }) + } + } + + if (/^(UPDATE|DELETE)\b/i.test(s)) { + const noWhere = !/\bWHERE\b/i.test(s) + matches.push({ + kind: 'warn', + rule: 'data-backfill', + message: noWhere + ? 'data backfill with no WHERE rewrites/locks the whole table. Confirm it is batched, idempotent, and safe under concurrent writes.' + : 'data backfill. Confirm it is batched, idempotent, and safe under concurrent writes.', + }) + } + + return matches +} + +const ANNOTATE_GUIDANCE = + 'is a contract-phase op. Confirm the old code no longer reads/writes it (it must have shipped in an earlier deploy — not this same PR), then acknowledge with a `-- migration-safe: ` comment on the line above.' + +/** Lint a single migration's SQL. Returns only actionable findings. */ +export function lintSql(content: string): Finding[] { + const lines = content.split('\n') + const statements = parseStatements(content) + const createdTables = new Set() + for (const { sql } of statements) { + const m = sql.match(/^CREATE TABLE (?:IF NOT EXISTS )?("?[.\w]+"?)/i) + if (m) createdTables.add(bareName(m[1])) + } + + const findings: Finding[] = [] + let sawCommit = false + for (const { sql, startLine } of statements) { + for (const match of classify(sql, createdTables, sawCommit)) { + if (match.kind === 'error') { + findings.push({ + line: startLine, + statement: sql, + tier: 'error', + rule: match.rule, + message: match.message, + }) + } else if (match.kind === 'warn') { + findings.push({ + line: startLine, + statement: sql, + tier: 'warn', + rule: match.rule, + message: match.message, + }) + } else { + const ann = readAnnotation(lines, startLine) + if (ann.allowed) continue + findings.push({ + line: startLine, + statement: sql, + tier: 'error', + rule: match.rule, + message: ann.missingReason + ? `${match.message}: \`-- migration-safe:\` annotation has no reason. Give it a real justification.` + : `${match.message} ${ANNOTATE_GUIDANCE}`, + }) + } + } + if (/^COMMIT\b/i.test(sql.trim())) sawCommit = true + } + return findings +} + +function git(args: string[]): string | null { + try { + return execFileSync('git', args, { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return null + } +} + +/** New migration files on this branch vs base, plus uncommitted ones locally. */ +function changedMigrationFiles(baseRef: string): string[] { + const files = new Set() + const inDir = (p: string) => p.startsWith(`${MIGRATIONS_DIR}/`) && p.endsWith('.sql') + + const mergeBase = git(['merge-base', baseRef, 'HEAD']) ?? baseRef + const committed = git([ + 'diff', + '--name-only', + '--diff-filter=AM', + mergeBase, + 'HEAD', + '--', + MIGRATIONS_DIR, + ]) + if (committed === null) return [] // git unavailable → fail open (handled by caller) + for (const f of committed.split('\n')) if (inDir(f)) files.add(f) + + const status = git(['status', '--porcelain', '--', MIGRATIONS_DIR]) + if (status) { + for (const raw of status.split('\n')) { + const p = raw.slice(3).trim() + if (inDir(p)) files.add(p) + } + } + return [...files] +} + +async function listSqlFiles(dir: string): Promise { + const out: string[] = [] + const entries = await readdir(dir, { withFileTypes: true }) + for (const e of entries) { + const full = path.join(dir, e.name) + if (e.isDirectory()) out.push(...(await listSqlFiles(full))) + else if (e.name.endsWith('.sql')) out.push(full) + } + return out +} + +async function resolveFiles(argv: string[]): Promise { + if (argv.includes('--all')) { + return (await listSqlFiles(path.join(ROOT, MIGRATIONS_DIR))).map((f) => path.relative(ROOT, f)) + } + const dirIdx = argv.indexOf('--dir') + if (dirIdx !== -1) { + const dir = argv[dirIdx + 1] + if (!dir) throw new Error('--dir requires a path') + return (await listSqlFiles(path.resolve(dir))).map((f) => path.relative(ROOT, f)) + } + const baseRef = argv.find((a) => !a.startsWith('--')) ?? 'origin/staging' + const files = changedMigrationFiles(baseRef) + if (files.length === 0 && git(['rev-parse', 'HEAD']) === null) { + console.warn('⚠ git unavailable — skipping migration safety check.') + return null + } + return files +} + +async function main() { + const files = await resolveFiles(process.argv.slice(2)) + if (files === null) process.exit(0) + + if (files.length === 0) { + console.log('✓ No new migrations to check.') + process.exit(0) + } + + let errors = 0 + let warnings = 0 + for (const rel of files) { + const content = await readFile(path.join(ROOT, rel), 'utf8') + const findings = lintSql(content) + if (findings.length === 0) continue + + console.error(`\n${rel}`) + for (const f of findings.sort((a, b) => a.line - b.line)) { + const icon = f.tier === 'error' ? '✗' : '⚠' + if (f.tier === 'error') errors++ + else warnings++ + console.error(` ${icon} ${rel}:${f.line} [${f.rule}]`) + console.error(` ${f.statement.replace(/\s+/g, ' ').slice(0, 120)}`) + console.error(` → ${f.message}`) + } + } + + if (errors === 0) { + console.log( + warnings > 0 + ? `\n✓ No blocking migration issues (${warnings} warning(s) to review).` + : '\n✓ Migrations are backward-compatible.' + ) + process.exit(0) + } + console.error( + `\nFound ${errors} blocking migration issue(s). Rewrite hard errors into expand/contract, or annotate contract ops once safe. See the /db-migrate skill.` + ) + process.exit(1) +} + +if (import.meta.main) main() diff --git a/scripts/generate-mship-contracts.ts b/scripts/generate-mship-contracts.ts index 7509216598a..a789f0825f4 100644 --- a/scripts/generate-mship-contracts.ts +++ b/scripts/generate-mship-contracts.ts @@ -9,8 +9,7 @@ // old per-script `--check`, but accounts for post-generate formatting. import { spawnSync } from 'node:child_process' -import { copyFileSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -23,6 +22,7 @@ const GENERATORS = [ 'scripts/sync-trace-attributes-contract.ts', 'scripts/sync-trace-attribute-values-contract.ts', 'scripts/sync-trace-events-contract.ts', + 'scripts/sync-metrics-contract.ts', ] // Generated files under this path. We biome-format this whole dir on @@ -102,9 +102,7 @@ function runCheck(): void { } if (stale.length > 0) { - console.error( - `Generated contracts are stale: ${stale.join(', ')}. Run: bun run mship:generate`, - ) + console.error(`Generated contracts are stale: ${stale.join(', ')}. Run: bun run mship:generate`) process.exit(1) } console.log('All generated contracts up to date.') diff --git a/scripts/sync-metrics-contract.ts b/scripts/sync-metrics-contract.ts new file mode 100644 index 00000000000..4a71a9f7c19 --- /dev/null +++ b/scripts/sync-metrics-contract.ts @@ -0,0 +1,142 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +/** + * Generate `apps/sim/lib/copilot/generated/metrics-v1.ts` from the Go-side + * `contracts/metrics-v1.schema.json` contract. + * + * The contract is a single-enum JSON Schema listing every canonical mothership + * OTel METRIC name. Go and Sim BOTH emit mothership metrics (the agent loop in + * Go; server-side tool/VFS/file instrumentation in Sim), so both sides MUST + * emit identical metric names for `histogram_quantile(sum by (le) …)` over the + * Go∪Sim union to be valid. We emit: + * - A `Metric` const object keyed by PascalCase identifier whose values are + * the exact wire names, so call sites read `meter.createHistogram( + * Metric.CopilotToolDuration)` instead of a raw string literal. + * - A `MetricKey` / `MetricValue` union pair. + * - A sorted `MetricValues` readonly array for tests/enumeration. + * + * Label allowlists and histogram bucket boundaries are NOT encoded in the + * schema (name-only). The Go side owns the label-cardinality allowlist + * (contracts/metrics_v1.go) and the shared bucket constant + * (internal/telemetry/metrics.go); the Sim emitter MUST use the identical + * label keys and bucket boundaries by hand. + * + * This is the metric-name twin of `sync-trace-attributes-contract.ts`; the two + * share the enum-extraction + PascalCase + collision-detection pattern. + */ +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DEFAULT_CONTRACT_PATH = resolve(ROOT, '../copilot/copilot/contracts/metrics-v1.schema.json') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/metrics-v1.ts') + +function extractMetricNames(schema: Record): string[] { + const defs = (schema.$defs ?? {}) as Record + const nameDef = defs.MetricsV1Name + if ( + !nameDef || + typeof nameDef !== 'object' || + !Array.isArray((nameDef as Record).enum) + ) { + throw new Error('metrics-v1.schema.json is missing $defs.MetricsV1Name.enum') + } + const enumValues = (nameDef as Record).enum as unknown[] + if (!enumValues.every((v) => typeof v === 'string')) { + throw new Error('MetricsV1Name enum must be string-only') + } + return (enumValues as string[]).slice().sort() +} + +/** + * Convert a wire metric name like `copilot.request.duration` into an + * identifier-safe PascalCase key like `CopilotRequestDuration`. Same algorithm + * as the trace-attributes sync script so readers learn one and reuse it. + */ +function toIdentifier(name: string): string { + const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean) + if (parts.length === 0) { + throw new Error(`Cannot derive identifier for metric name: ${name}`) + } + const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('') + if (/^[0-9]/.test(ident)) { + throw new Error(`Derived identifier "${ident}" for metric "${name}" starts with a digit`) + } + return ident +} + +function render(metricNames: string[]): string { + const pairs = metricNames.map((name) => ({ name, ident: toIdentifier(name) })) + + const seen = new Map() + for (const p of pairs) { + const prev = seen.get(p.ident) + if (prev && prev !== p.name) { + throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`) + } + seen.set(p.ident, p.name) + } + + const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n') + const arrayEntries = metricNames.map((n) => ` ${JSON.stringify(n)},`).join('\n') + + return `// AUTO-GENERATED FILE. DO NOT EDIT. +// +// Source: copilot/copilot/contracts/metrics-v1.schema.json +// Regenerate with: bun run metrics-contract:generate +// +// Canonical mothership OTel metric names. Call sites should reference +// \`Metric.\` (e.g. \`Metric.CopilotToolDuration\`) rather than raw +// string literals, so the Go-side contract is the single source of truth and +// typos become compile errors. +// +// NAMES ONLY. Label keys and histogram bucket boundaries are NOT in this +// contract — Go owns the label-cardinality allowlist and the shared bucket +// constant, and the Sim emitter MUST mirror those by hand so the Go∪Sim metric +// union is queryable as one series set. + +export const Metric = { +${constLines} +} as const; + +export type MetricKey = keyof typeof Metric; +export type MetricValue = (typeof Metric)[MetricKey]; + +/** Readonly sorted list of every canonical mothership metric name. */ +export const MetricValues: readonly MetricValue[] = [ +${arrayEntries} +] as const; +` +} + +async function main() { + const checkOnly = process.argv.includes('--check') + const inputArg = process.argv.find((a) => a.startsWith('--input=')) + const inputPath = inputArg + ? resolve(ROOT, inputArg.slice('--input='.length)) + : DEFAULT_CONTRACT_PATH + + const raw = await readFile(inputPath, 'utf8') + const schema = JSON.parse(raw) + const metricNames = extractMetricNames(schema) + const rendered = formatGeneratedSource(render(metricNames), OUTPUT_PATH, ROOT) + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error('Generated metrics contract is stale. Run: bun run metrics-contract:generate') + } + console.log('Metrics contract is up to date.') + return + } + + await mkdir(dirname(OUTPUT_PATH), { recursive: true }) + await writeFile(OUTPUT_PATH, rendered, 'utf8') + console.log(`Generated metrics types -> ${OUTPUT_PATH}`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})