From a2f8eab30a4ae5939a08492add4d8e599dc511ce Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 16:56:28 -0700 Subject: [PATCH 01/14] fix(connectors): count hard-kill failures and cap deletion blast radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An OOM is a SIGKILL, so executeSync's catch and finally never run. The out-of-process stale-lock reaper was the only survivor, and it cleared the lock without ever incrementing consecutiveFailures — so MAX_CONSECUTIVE_FAILURES was unreachable for hard kills and a crashing connector looped indefinitely on a flat 10-minute retry, faster than any healthy interval. - Move the failure threshold and backoff ladder into sync-limits.ts so the two writers cannot drift; the reaper now increments, backs off, and disables in the same statement that clears the lock - Sweep sync-log rows left `started` by a killed run, keyed off the row's own startedAt so the sweep is self-healing and drains the existing backlog - Delete the unreachable finally block; report a lock-contended run as skipped rather than as a successful zero-document sync - Stop stamping lastSyncAt on the failure path Deletion reconciliation only questioned listings that looked broken, leaving every partial-outage shape between 10% and 100% unguarded: a source serving half its documents tombstones the other half and hard-deletes it on the next pass. - Hold a reconciliation pass whose deletions exceed a share of the corpus, all-or-nothing; fullSync remains the documented escape hatch - Never make a user-excluded document deletion-eligible, guarded at deletion eligibility rather than at the select so resurrection still works - Count listed documents over the same population as the owned count - Judge the previous run against a corpus at least as large as the one present, which un-jams the two-strike purge — shipped with the cap, never before it --- .../knowledge/connectors/sync/route.test.ts | 207 ++++++++++++++ .../api/knowledge/connectors/sync/route.ts | 87 +++++- .../knowledge/connectors/sync-engine.test.ts | 259 ++++++++++++++++++ .../lib/knowledge/connectors/sync-engine.ts | 239 +++++++++++++--- .../lib/knowledge/connectors/sync-limits.ts | 34 +++ 5 files changed, 779 insertions(+), 47 deletions(-) create mode 100644 apps/sim/app/api/knowledge/connectors/sync/route.test.ts diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts new file mode 100644 index 00000000000..a1a3e43d1dc --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -0,0 +1,207 @@ +/** + * Tests for the connector sync scheduler's stale-lock reaper. + * + * A hard kill (OOM/SIGKILL) skips `executeSync`'s `catch` and `finally`, so this + * reaper is the only writer that ever records that failure. These tests pin the + * shape of the SQL it writes, which is the part no shape-agnostic mock can enforce. + * + * @vitest-environment node + */ +import { + dbChainMockFns, + hasMockCondition, + type MockCondition, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + connectorFailureBackoffMinutes, + MAX_CONSECUTIVE_FAILURES, +} from '@/lib/knowledge/connectors/sync-limits' + +const { mockVerifyCronAuth, mockDispatchSync, mockResolveSystemBillingAttribution } = vi.hoisted( + () => ({ + mockVerifyCronAuth: vi.fn().mockReturnValue(null), + mockDispatchSync: vi.fn().mockResolvedValue(undefined), + mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }), + }) +) + +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) +vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, +})) + +import { GET } from '@/app/api/knowledge/connectors/sync/route' + +/** A drizzle `sql` fragment as the shared test mock renders it. */ +interface MockSqlFragment { + values: unknown[] + toSQL: () => { sql: string; params: unknown[] } +} + +function isSqlFragment(value: unknown): value is MockSqlFragment { + return typeof value === 'object' && value !== null && 'toSQL' in value && 'values' in value +} + +function asFragment(value: unknown): MockSqlFragment { + expect(isSqlFragment(value)).toBe(true) + return value as MockSqlFragment +} + +function renderedSql(value: unknown): string { + return asFragment(value).toSQL().sql +} + +function numericBinds(value: unknown): number[] { + return asFragment(value).values.filter((v): v is number => typeof v === 'number') +} + +function cronRequest(): NextRequest { + return new Request('https://sim.ai/api/knowledge/connectors/sync', { + headers: { authorization: 'Bearer test-cron-secret' }, + }) as unknown as NextRequest +} + +/** Runs one scheduler tick that reclaims the given stale connector ids. */ +async function runTickRecovering(ids: string[]) { + dbChainMockFns.returning.mockResolvedValueOnce(ids.map((id) => ({ id }))) + const response = await GET(cronRequest()) + expect(response.status).toBe(200) +} + +/** The `.set()` payload of the nth `db.update()` chain in call order. */ +function setPayloadForUpdate(index: number): Record { + return dbChainMockFns.set.mock.calls[index][0] as Record +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockVerifyCronAuth.mockReturnValue(null) +}) + +describe('connector sync scheduler stale-lock reaper', () => { + it('increments consecutiveFailures in the same statement that flips the lock', async () => { + await runTickRecovering(['connector-1']) + + expect(dbChainMockFns.update.mock.calls[0][0]).toBe(schemaMock.knowledgeConnector) + + const payload = setPayloadForUpdate(0) + expect(payload.consecutiveFailures).toBeDefined() + expect(typeof payload.consecutiveFailures).not.toBe('number') + + const rendered = renderedSql(payload.consecutiveFailures) + expect(rendered).toContain('COALESCE(') + expect(rendered).toContain(', 0) + 1') + expect(asFragment(payload.consecutiveFailures).values).toContain( + schemaMock.knowledgeConnector.consecutiveFailures + ) + }) + + it('disables at the threshold and errors below it', async () => { + await runTickRecovering(['connector-1']) + + const status = setPayloadForUpdate(0).status + const rendered = renderedSql(status) + + expect(rendered).toContain('CASE WHEN COALESCE(') + expect(rendered).toContain("THEN 'disabled' ELSE 'error' END") + expect(numericBinds(status)).toContain(MAX_CONSECUTIVE_FAILURES) + }) + + it('derives nextSyncAt from the shared failure backoff ladder', async () => { + await runTickRecovering(['connector-1']) + + const nextSyncAt = setPayloadForUpdate(0).nextSyncAt + const rendered = renderedSql(nextSyncAt) + + expect(rendered).toContain('THEN NULL') + expect(rendered).toContain('LEAST(') + expect(rendered).toContain("INTERVAL '1 minute'") + + const [threshold, step, cap] = numericBinds(nextSyncAt) + expect(threshold).toBe(MAX_CONSECUTIVE_FAILURES) + + // Recomputing the ladder from the binds the SQL actually carries makes this + // fail the moment the route and `connectorFailureBackoffMinutes` drift apart. + for (const failures of [1, 2, 5, 10, 47, 48, 100]) { + expect(Math.min(failures * step, cap)).toBe(connectorFailureBackoffMinutes(failures)) + } + }) + + it('does not stamp lastSyncAt when reclaiming a stale lock', async () => { + await runTickRecovering(['connector-1']) + + expect(setPayloadForUpdate(0)).not.toHaveProperty('lastSyncAt') + }) + + it('closes orphaned sync-log rows still marked started', async () => { + await runTickRecovering(['connector-1', 'connector-2']) + + expect(dbChainMockFns.update.mock.calls[1][0]).toBe(schemaMock.knowledgeConnectorSyncLog) + + const payload = setPayloadForUpdate(1) + expect(payload.status).toBe('failed') + expect(renderedSql(payload.completedAt)).toContain('now()') + expect(payload.errorMessage).toEqual(expect.any(String)) + + const where = dbChainMockFns.where.mock.calls[1][0] + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnectorSyncLog.status && + node.right === 'started' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'lte' && node.left === schemaMock.knowledgeConnectorSyncLog.startedAt + ) + ).toBe(true) + }) + + it('closes stale sync-log rows even when no connector was reclaimed this tick', async () => { + /** + * The self-healing assertion. A row orphaned before this sweep existed — + * or by a transient failure of the sweep itself — belongs to a connector + * already flipped out of `syncing`, so it can never appear in a reclaim + * batch again. Scoping the close to this tick's reclaims strands it forever. + */ + const response = await GET(cronRequest()) + + expect(response.status).toBe(200) + + const logUpdateIndex = dbChainMockFns.update.mock.calls.findIndex( + (call) => call[0] === schemaMock.knowledgeConnectorSyncLog + ) + expect(logUpdateIndex).toBeGreaterThanOrEqual(0) + expect(setPayloadForUpdate(logUpdateIndex).status).toBe('failed') + }) + + it('never scopes the sync-log sweep to a connector id', async () => { + await runTickRecovering(['connector-1']) + + const where = dbChainMockFns.where.mock.calls[1][0] + expect( + hasMockCondition( + where, + (node: MockCondition) => node.column === schemaMock.knowledgeConnectorSyncLog.connectorId + ) + ).toBe(false) + }) + + it('drives the connector write off a single clock', async () => { + await runTickRecovering(['connector-1']) + + // `updatedAt` shares the server clock the nextSyncAt interval math uses. + expect(renderedSql(setPayloadForUpdate(0).updatedAt)).toContain('now()') + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index e6dd8177b1f..389eb7e966f 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { knowledgeBase, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, inArray, isNull, lte } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' @@ -9,7 +9,12 @@ import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchSync } from '@/lib/knowledge/connectors/queue' -import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS } from '@/lib/knowledge/connectors/sync-limits' +import { + CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, + CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES, + CONNECTOR_SYNC_STALE_LOCK_TTL_MS, + MAX_CONSECUTIVE_FAILURES, +} from '@/lib/knowledge/connectors/sync-limits' export const dynamic = 'force-dynamic' @@ -24,6 +29,34 @@ const MAX_DISPATCHES_PER_TICK = 200 /** Each dispatch does a joined SELECT + conditional UPDATE against the shared pool. */ const DISPATCH_CONCURRENCY = 10 +const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)' + +/** + * The reclaimed connector's new consecutive-failure count. + * + * A hard kill (OOM/SIGKILL) skips `executeSync`'s `catch` and `finally` + * entirely, so this reaper is the ONLY writer that ever observes that failure. + * Computed in SQL rather than read-then-written because two overlapping cron + * ticks reclaiming the same row would otherwise both read the same value and + * write the same increment, losing one. + */ +function reclaimedFailureCount(): SQL { + return sql`COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1` +} + +/** Disables the connector once the reclaimed count reaches the shared threshold. */ +function reclaimedStatus(): SQL { + return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN 'disabled' ELSE 'error' END` +} + +/** + * The reclaimed connector's next attempt, on the shared failure ladder + * (`connectorFailureBackoffMinutes`). A disabled connector gets no next attempt. + */ +function reclaimedNextSyncAt(): SQL { + return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN NULL ELSE now() + LEAST((COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1) * ${CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES}, ${CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES}) * INTERVAL '1 minute' END` +} + /** * Cron endpoint that checks for connectors due for sync and dispatches sync jobs. * Should be called every 5 minutes by an external cron service. @@ -45,10 +78,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const recoveredConnectors = await db .update(knowledgeConnector) .set({ - status: 'error', - lastSyncError: 'Sync timed out (stale lock recovered)', - nextSyncAt: new Date(now.getTime() + 10 * 60 * 1000), - updatedAt: now, + status: reclaimedStatus(), + lastSyncError: STALE_LOCK_ERROR_MESSAGE, + nextSyncAt: reclaimedNextSyncAt(), + consecutiveFailures: reclaimedFailureCount(), + updatedAt: sql`now()`, }) .where( and( @@ -67,6 +101,45 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } + /** + * Closes sync-log rows left `started` by a killed run. Nothing else ever + * reconciles them, and `loadPreviousListingObservation` reads only + * `completed` rows, so a never-closed run silently ages out the previous + * observation it should have provided. + * + * Deliberately independent of this tick's reclaims rather than scoped to + * them. A row orphaned before this shipped — or by a transient failure of + * this very statement — belongs to a connector already flipped out of + * `syncing`, so it would never appear in a future reclaim batch and would + * stay stranded forever. Keying off the row's own `startedAt` instead makes + * the sweep self-healing and lets it drain the existing backlog. + * + * Safe on liveness: the run ceiling is + * {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS} and this TTL is twice that, so + * a row older than the cutoff belongs to a run the platform has already + * killed and which cannot still be writing. The predicate is per-row on + * `startedAt`, so a fresh run's log row can never be caught by it — even on + * a connector whose previous run is being reclaimed in this same tick. + */ + const closedSyncLogs = await db + .update(knowledgeConnectorSyncLog) + .set({ + status: 'failed', + completedAt: sql`now()`, + errorMessage: STALE_LOCK_ERROR_MESSAGE, + }) + .where( + and( + eq(knowledgeConnectorSyncLog.status, 'started'), + lte(knowledgeConnectorSyncLog.startedAt, staleCutoff) + ) + ) + .returning({ id: knowledgeConnectorSyncLog.id }) + + if (closedSyncLogs.length > 0) { + logger.warn(`[${requestId}] Closed ${closedSyncLogs.length} orphaned connector sync log(s)`) + } + const dueConnectors = await db .select({ id: knowledgeConnector.id, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 6e88d9d3814..7f27d20a73c 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -888,3 +888,262 @@ describe('isStuckDocumentSweepEligible', () => { ).toBe(false) }) }) + +describe('resolveReconciliationDeleteCap', () => { + it('scales with the owned corpus above the absolute floor', async () => { + const { resolveReconciliationDeleteCap } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + expect(resolveReconciliationDeleteCap(1000)).toBe(250) + expect(resolveReconciliationDeleteCap(400)).toBe(100) + expect(resolveReconciliationDeleteCap(401)).toBe(100) + }) + + it('never drops below the absolute floor on a small corpus', async () => { + const { resolveReconciliationDeleteCap } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + expect(resolveReconciliationDeleteCap(0)).toBe(25) + expect(resolveReconciliationDeleteCap(4)).toBe(25) + expect(resolveReconciliationDeleteCap(40)).toBe(25) + expect(resolveReconciliationDeleteCap(100)).toBe(25) + }) + + it('honours an override that raises or lowers the cap', async () => { + const { resolveReconciliationDeleteCap } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + expect(resolveReconciliationDeleteCap(1000, { maxRatio: 0.9 })).toBe(900) + expect(resolveReconciliationDeleteCap(1000, { maxRatio: 0.01, minAbsolute: 0 })).toBe(10) + expect(resolveReconciliationDeleteCap(10, { minAbsolute: 1, maxRatio: 0.25 })).toBe(2) + }) +}) + +describe('capReconciliationDeletions', () => { + const ids = (prefix: string, count: number) => + Array.from({ length: count }, (_, i) => `${prefix}-${i}`) + + it('passes a request exactly at the cap through untouched', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const soft = ids('soft', 250) + const result = capReconciliationDeletions(soft, [], 1000, false) + + expect(result.held).toBe(false) + expect(result.cap).toBe(250) + expect(result.requested).toBe(250) + expect(result.softDeleteIds).toEqual(soft) + }) + + it('holds a request one document over the cap', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = capReconciliationDeletions(ids('soft', 251), [], 1000, false) + + expect(result.held).toBe(true) + expect(result.requested).toBe(251) + }) + + it('returns empty arrays — not the inputs — when held', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = capReconciliationDeletions(ids('soft', 300), ids('hard', 300), 1000, false) + + expect(result.held).toBe(true) + expect(result.softDeleteIds).toEqual([]) + expect(result.hardDeleteIds).toEqual([]) + }) + + it('counts the union of soft and hard deletions against one cap', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const overlapping = ids('doc', 200) + // Same ids on both lists must count once, not twice. + expect(capReconciliationDeletions(overlapping, overlapping, 1000, false).held).toBe(false) + expect(capReconciliationDeletions(ids('a', 200), ids('b', 200), 1000, false).held).toBe(true) + }) + + it('is bypassed by a forced fullSync', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const hard = ids('hard', 1000) + const result = capReconciliationDeletions([], hard, 1000, true) + + expect(result.held).toBe(false) + expect(result.hardDeleteIds).toEqual(hard) + }) + + it('applies the small-corpus floor rather than the ratio', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(capReconciliationDeletions(ids('soft', 25), [], 8, false).held).toBe(false) + expect(capReconciliationDeletions(ids('soft', 26), [], 8, false).held).toBe(true) + }) + + it('honours an override that raises or lowers the cap', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(capReconciliationDeletions(ids('s', 400), [], 1000, false, { maxRatio: 0.5 }).held).toBe( + false + ) + expect( + capReconciliationDeletions(ids('s', 30), [], 1000, false, { + maxRatio: 0.01, + minAbsolute: 5, + }).held + ).toBe(true) + }) + + describe('confirmed data-loss shapes', () => { + it('holds a partial outage that returns half a 1000-document corpus', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = capReconciliationDeletions(ids('missing', 500), [], 1000, false) + + expect(result.held).toBe(true) + expect(result.softDeleteIds).toEqual([]) + expect(result.hardDeleteIds).toEqual([]) + }) + + it('holds an externalId derivation change that orphans the whole corpus', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = capReconciliationDeletions(ids('old-key', 1000), [], 1000, false) + + expect(result.held).toBe(true) + expect(result.softDeleteIds).toEqual([]) + expect(result.hardDeleteIds).toEqual([]) + }) + }) +}) + +describe('resolvePreviousOwnedCount', () => { + it('falls back to the current owned count when the recorded count collapsed', async () => { + const { resolvePreviousOwnedCount } = await import('@/lib/knowledge/connectors/sync-engine') + + // lastSyncDocCount excludes tombstones, so a soft-delete pass drives it to 0. + expect(resolvePreviousOwnedCount(0, 500)).toBe(500) + expect(resolvePreviousOwnedCount(null, 500)).toBe(500) + expect(resolvePreviousOwnedCount(undefined, 500)).toBe(500) + }) + + it('keeps the recorded count when it is the larger observation', async () => { + const { resolvePreviousOwnedCount } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(resolvePreviousOwnedCount(800, 500)).toBe(800) + expect(resolvePreviousOwnedCount(500, 500)).toBe(500) + }) +}) + +describe('partitionSyncReconciliation — user-excluded documents', () => { + const doc = (id: string) => ({ id, externalId: id }) + const excluded = (id: string) => ({ id, externalId: id, userExcluded: true }) + const noFailures = new Set() + + it('never hard-deletes an excluded document that is already pending removal', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [excluded('kept'), doc('gone')], + new Set(), + noFailures, + undefined + ) + + expect(result.hardDeleteIds).toEqual(['gone']) + expect(result.hardDeleteIds).not.toContain('kept') + }) + + it('still resurrects an excluded pending-removal document that reappears', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * The assertion that rejects the select-level filter. Dropping excluded rows + * from the tombstoned read would strand this document permanently: the + * connector-document listing and the restore mutation both require + * `deletedAt IS NULL`, so resurrection is its only route back. + */ + const result = partitionSyncReconciliation( + [], + [excluded('kept')], + new Set(['kept']), + noFailures, + undefined + ) + + expect(result.resurrectIds).toEqual(['kept']) + expect(result.hardDeleteIds).toEqual([]) + }) + + it('never soft-deletes an excluded live document absent from the listing', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [excluded('kept'), doc('gone')], + [], + new Set(), + noFailures, + undefined + ) + + expect(result.softDeleteIds).toEqual(['gone']) + }) + + it('exempts excluded documents from a forced fullSync purge too', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [excluded('kept-live'), doc('gone-live')], + [excluded('kept-tombstoned'), doc('gone-tombstoned')], + new Set(), + noFailures, + true + ) + + expect(result.hardDeleteIds).toEqual(['gone-live', 'gone-tombstoned']) + }) +}) + +describe('countNonExcludedListed', () => { + it('subtracts the excluded documents that appeared in the listing', async () => { + const { countNonExcludedListed } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(countNonExcludedListed(new Set(['a', 'b', 'c']), new Set(['b']))).toBe(2) + expect(countNonExcludedListed(new Set(['a', 'b']), new Set(['a', 'b']))).toBe(0) + }) + + it('ignores excluded documents that were not listed', async () => { + const { countNonExcludedListed } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(countNonExcludedListed(new Set(['a']), new Set(['x', 'y', 'z']))).toBe(1) + expect(countNonExcludedListed(new Set(), new Set(['x']))).toBe(0) + }) + + it('keeps the suspect-listing ratio on one population', async () => { + const { classifySuspectListing, countNonExcludedListed } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + /** + * The shape the asymmetry hid: a connector owning 1,000 documents of which + * 200 are user-excluded, whose source returns 90 — 20 of them excluded. + * The denominator counts only the 800 non-excluded owned documents, so + * comparing the raw listed count (90) against it misses the collapse, + * while the symmetric count (70) catches it. + */ + const ownedDocCount = 800 + const listed = new Set(Array.from({ length: 90 }, (_, i) => `ext-${i}`)) + const excludedExternalIds = new Set(Array.from({ length: 20 }, (_, i) => `ext-${i}`)) + + const listedDocCount = countNonExcludedListed(listed, excludedExternalIds) + + expect(listedDocCount).toBe(70) + expect(classifySuspectListing(listedDocCount, ownedDocCount)).toBe('collapsed') + // The asymmetric numerator this replaced sees a healthy listing. + expect(classifySuspectListing(listed.size, ownedDocCount)).toBeNull() + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 87b89dc100a..9d67af98de3 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -18,6 +18,10 @@ import { } from '@/lib/billing/core/billing-attribution' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' +import { + connectorFailureBackoffMinutes, + MAX_CONSECUTIVE_FAILURES, +} from '@/lib/knowledge/connectors/sync-limits' import type { DocumentData } from '@/lib/knowledge/documents/service' import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' @@ -90,7 +94,6 @@ const QUEUED_DISPATCH_GRACE_MINUTES = Math.ceil( QUEUE_CONTENTION_FACTOR ) const RETRY_WINDOW_DAYS = 7 -const MAX_CONSECUTIVE_FAILURES = 10 /** The processing state the stuck-document sweep decides on, one row at a time. */ export interface StuckDocumentSweepCandidate { @@ -472,6 +475,28 @@ const SUSPECT_COLLAPSE_MIN_OWNED_DOCS = 50 */ const SUSPECT_COLLAPSE_MAX_RATIO = 0.1 +/** + * How many listed documents count toward the suspect-listing ratio. + * + * `seenExternalIds` is populated before the classification loop short-circuits + * user-excluded documents, so it counts them; the owned-document denominator + * does not, because excluded rows are filtered out of the live read. Comparing + * the two directly inflates the ratio and silently weakens the collapse guard — + * with 1,000 owned / 200 excluded, a source returning 90 documents stopped + * tripping `collapsed` entirely. Subtracting the excluded documents that were + * listed puts both sides back on the same population. + */ +export function countNonExcludedListed( + seenExternalIds: ReadonlySet, + excludedExternalIds: ReadonlySet +): number { + let excludedAndListed = 0 + for (const externalId of seenExternalIds) { + if (excludedExternalIds.has(externalId)) excludedAndListed++ + } + return seenExternalIds.size - excludedAndListed +} + /** Why a listing is considered untrustworthy evidence of deletion. */ export type SuspectListingReason = 'empty' | 'collapsed' @@ -521,8 +546,10 @@ export function classifySuspectListing( * consecutive sync, so a single transient upstream fault can never remove * documents — not even reversibly, since a soft delete hides them from search * immediately. A genuinely emptied source keeps reconciling: its second sync - * corroborates the first, tombstones everything, and the third sync completes - * the existing two-strike purge. + * corroborates the first and tombstones everything, and a later sync — once the + * tombstoned set is again absent — completes the two-strike purge, subject to + * {@link capReconciliationDeletions}, which holds a pass whose deletion count + * exceeds the per-sync blast-radius cap. * * A forced `fullSync` overrides the guard, matching its existing meaning * elsewhere here — an explicit human request to reconcile against this listing @@ -544,6 +571,109 @@ export function evaluateListingSafety( return { reason, blocked: !corroborated, corroborated } } +/** + * The document count to attribute to the previous sync when reconstructing its + * listing. + * + * `lastSyncDocCount` counts only *visible* documents, so after a pass that + * tombstoned a corpus it collapses toward 0 — and an owned count of 0 can never + * be classified as suspect, so corroboration silently became impossible and the + * two-strike purge jammed shut. Taking the larger of the recorded count and what + * the connector owns right now (tombstones included) restores the intent: the + * previous run is judged against a corpus at least as large as the one still + * present. + */ +export function resolvePreviousOwnedCount( + lastSyncDocCount: number | null | undefined, + ownedDocCount: number +): number { + return Math.max(lastSyncDocCount ?? 0, ownedDocCount) +} + +/** + * Fraction of a connector's owned documents that a single reconciliation pass + * may remove before the pass is held. + * + * {@link SUSPECT_COLLAPSE_MAX_RATIO} only questions a listing that returns under + * 10% of the corpus, which leaves every partial-outage shape between 10% and + * 100% completely unguarded: a source that serves half its documents produces a + * listing that looks perfectly healthy to every shape guard, tombstones the + * missing half, and hard-deletes it on the next pass. 25% sits well above + * ordinary housekeeping (a quarter of a corpus removed between two syncs is + * already extraordinary) and well below the outage shapes seen in the wild. + */ +const RECONCILIATION_DELETE_MAX_RATIO = 0.25 + +/** + * Deletions always permitted regardless of ratio. + * + * The ratio is meaningless on a small corpus for the same reason + * {@link SUSPECT_COLLAPSE_MIN_OWNED_DOCS} exists — removing 20 of 40 documents + * is ordinary editing — and a floor below the collapse guard's own 50-document + * threshold keeps the cap from being the binding constraint on corpora that + * guard was written to ignore. + */ +const RECONCILIATION_DELETE_MIN_ABSOLUTE = 25 + +/** Per-connector tuning for the reconciliation blast-radius cap. */ +export interface ReconciliationDeleteCapOverride { + maxRatio?: number + minAbsolute?: number +} + +/** + * Maximum number of documents one reconciliation pass may remove. + */ +export function resolveReconciliationDeleteCap( + ownedDocCount: number, + override?: ReconciliationDeleteCapOverride +): number { + const maxRatio = override?.maxRatio ?? RECONCILIATION_DELETE_MAX_RATIO + const minAbsolute = override?.minAbsolute ?? RECONCILIATION_DELETE_MIN_ABSOLUTE + return Math.max(minAbsolute, Math.floor(Math.max(ownedDocCount, 0) * maxRatio)) +} + +/** + * Caps the blast radius of one reconciliation pass. + * + * The shape guards above all reason about listings that look *broken*. Two + * confirmed data-loss paths produce listings that look perfectly healthy and so + * pass every one of them: a partial outage returning half a corpus (above the + * 10% collapse threshold), and a change to a connector's externalId derivation, + * which yields a complete, correct listing of entirely new keys — under which + * every stored document is "absent" and every listed one is new. + * + * The hold is deliberately all-or-nothing rather than a truncation to the cap: + * deleting up to the cap still destroys data, and leaves the knowledge base in a + * state no operator asked for and no later sync can reason about. Holding + * everything keeps the corpus intact and self-heals as soon as the source does. + * + * `fullSync` bypasses the cap, matching its meaning everywhere else here — an + * explicit human request to reconcile against this listing right now, which is + * the documented escape hatch for a genuine mass deletion. + */ +export function capReconciliationDeletions( + softDeleteIds: string[], + hardDeleteIds: string[], + ownedDocCount: number, + fullSync: boolean | undefined, + override?: ReconciliationDeleteCapOverride +): { + softDeleteIds: string[] + hardDeleteIds: string[] + held: boolean + requested: number + cap: number +} { + const requested = new Set([...softDeleteIds, ...hardDeleteIds]).size + const cap = resolveReconciliationDeleteCap(ownedDocCount, override) + + if (fullSync || requested <= cap) { + return { softDeleteIds, hardDeleteIds, held: false, requested, cap } + } + return { softDeleteIds: [], hardDeleteIds: [], held: true, requested, cap } +} + /** * Reconstructs the previous completed sync's listing from its log counters. * @@ -618,8 +748,14 @@ export function shouldRunIncrementalSync( ) } -/** A stored document's identity, as read back for reconciliation. */ -type ReconciliationDoc = { id: string; externalId: string | null } +/** + * A stored document's identity, as read back for reconciliation. + * + * `userExcluded` is optional because only the tombstoned read projects it — the + * live read filters excluded rows out in SQL, so an absent flag there means + * "not excluded" and the deletion guards below read the same either way. + */ +type ReconciliationDoc = { id: string; externalId: string | null; userExcluded?: boolean } /** * Partitions a connector's stored documents against the current listing into @@ -642,6 +778,15 @@ type ReconciliationDoc = { id: string; externalId: string | null } * * A forced `fullSync` is an explicit request to reconcile right now: it skips * the grace period and purges everything absent in one pass. + * + * A `userExcluded` document is never deletion-eligible — the user asked to keep + * the row — but it stays fully resurrection-eligible. The distinction matters: + * `userExcluded` and `enabled` gate visibility on their own in every retrieval + * path, so resurrecting one never re-indexes it; it only clears `deletedAt`. + * Withholding resurrection instead would strand the row permanently, since the + * connector-document listing and the restore mutation both require + * `deletedAt IS NULL` — leaving it invisible, unrestorable, and (by this very + * guard) undeletable. */ export function partitionSyncReconciliation( existingDocs: ReconciliationDoc[], @@ -657,10 +802,10 @@ export function partitionSyncReconciliation( ) .map((d) => d.id) const liveMissingIds = existingDocs - .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) + .filter((d) => d.externalId && !d.userExcluded && !seenExternalIds.has(d.externalId)) .map((d) => d.id) const tombstonedStillMissingIds = tombstonedDocs - .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) + .filter((d) => d.externalId && !d.userExcluded && !seenExternalIds.has(d.externalId)) .map((d) => d.id) if (fullSync) { @@ -866,7 +1011,9 @@ export async function executeSync( if (lockResult.length === 0) { logger.info('Sync already in progress, skipping', { connectorId }) - return result + // Reported as an error so the task wrapper's `success: !result.error` does not + // present a skipped run as a successful zero-document sync. + return { ...result, error: 'sync_in_progress' } } const syncLogId = generateId() @@ -878,8 +1025,6 @@ export async function executeSync( startedAt: syncStartedAt, }) - let syncExitedCleanly = false - try { /** * OAuth credentials are workspace-scoped and shared, so the member who authorized @@ -1037,6 +1182,10 @@ export async function executeSync( .where( and( eq(document.connectorId, connectorId), + // A user's explicit "keep but don't index" choice must never make a + // document eligible for reconciliation deletion: it is deliberately + // never refreshed, so its absence from a listing says nothing. + eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt) ) @@ -1052,6 +1201,9 @@ export async function executeSync( externalId: document.externalId, contentHash: document.contentHash, deletedAt: document.deletedAt, + // Gates hard deletion in partitionSyncReconciliation without gating + // resurrection — see that function's contract. + userExcluded: document.userExcluded, }) .from(document) .where( @@ -1353,15 +1505,20 @@ export async function executeSync( * healthy syncs pay nothing and no existing gate is loosened. */ const ownedDocCount = existingDocs.length + tombstonedDocs.length - if (reconcileDeletionsAllowed && classifySuspectListing(seenExternalIds.size, ownedDocCount)) { + /** + * Counted over the same population as `ownedDocCount`: excluded documents + * are absent from the live read, so they must not inflate the numerator. + */ + const listedDocCount = countNonExcludedListed(seenExternalIds, excludedExternalIds) + if (reconcileDeletionsAllowed && classifySuspectListing(listedDocCount, ownedDocCount)) { const previousObservation = await loadPreviousListingObservation( connectorId, syncLogId, - connector.lastSyncDocCount ?? ownedDocCount, + resolvePreviousOwnedCount(connector.lastSyncDocCount, ownedDocCount), !connectorConfig.supportsIncrementalSync || connector.syncMode === 'full' ) const listingSafety = evaluateListingSafety( - seenExternalIds.size, + listedDocCount, ownedDocCount, previousObservation, options?.fullSync @@ -1370,7 +1527,8 @@ export async function executeSync( connectorId, connectorType: connector.connectorType, reason: listingSafety.reason, - listedDocs: seenExternalIds.size, + listedDocs: listedDocCount, + listedDocsIncludingExcluded: seenExternalIds.size, ownedDocs: ownedDocCount, liveDocs: existingDocs.length, tombstonedDocs: tombstonedDocs.length, @@ -1384,8 +1542,32 @@ export async function executeSync( } } - const gatedSoftDeleteIds = reconcileDeletionsAllowed ? softDeleteIds : [] - const gatedHardDeleteIds = reconcileDeletionsAllowed ? hardDeleteIds : [] + /** + * Last word after every shape guard: even a listing that looks entirely + * healthy may not remove an implausible share of the corpus in one pass. + * Applied here so it covers both the soft-delete UPDATE and the + * `hardDeleteDocuments` call below. + */ + const capped = capReconciliationDeletions( + reconcileDeletionsAllowed ? softDeleteIds : [], + reconcileDeletionsAllowed ? hardDeleteIds : [], + ownedDocCount, + options?.fullSync + ) + if (capped.held) { + logger.error('Reconciliation deletions held — exceeds per-sync blast-radius cap', { + connectorId, + connectorType: connector.connectorType, + requested: capped.requested, + cap: capped.cap, + ownedDocCount, + listedCount: listedDocCount, + syncRunId: syncContext.syncRunId, + }) + } + + const gatedSoftDeleteIds = capped.softDeleteIds + const gatedHardDeleteIds = capped.hardDeleteIds const candidateIds = [ ...new Set([...resurrectIds, ...gatedSoftDeleteIds, ...gatedHardDeleteIds]), @@ -1643,7 +1825,6 @@ export async function executeSync( ) logger.info('Sync completed', { connectorId, ...result }) - syncExitedCleanly = true return result } catch (error) { if (error instanceof ConnectorDeletedException) { @@ -1672,7 +1853,6 @@ export async function executeSync( } result.error = 'Connector deleted during sync' - syncExitedCleanly = true return result } @@ -1685,7 +1865,7 @@ export async function executeSync( const now = new Date() const failures = (connector.consecutiveFailures ?? 0) + 1 const disabled = failures >= MAX_CONSECUTIVE_FAILURES - const backoffMinutes = Math.min(failures * 30, 1440) + const backoffMinutes = connectorFailureBackoffMinutes(failures) const nextSync = disabled ? null : new Date(now.getTime() + backoffMinutes * 60 * 1000) if (disabled) { @@ -1699,7 +1879,6 @@ export async function executeSync( .update(knowledgeConnector) .set({ status: disabled ? 'disabled' : 'error', - lastSyncAt: now, lastSyncError: disabled ? 'Connector disabled after repeated sync failures. Please reconnect.' : errorMessage, @@ -1722,27 +1901,7 @@ export async function executeSync( } result.error = errorMessage - syncExitedCleanly = true return result - } finally { - if (!syncExitedCleanly) { - try { - await db - .update(knowledgeConnector) - .set({ - status: 'error', - lastSyncError: 'Sync terminated unexpectedly', - updatedAt: new Date(), - }) - .where(eq(knowledgeConnector.id, connectorId)) - logger.warn('Reset stale syncing status in finally block', { connectorId }) - } catch (finallyError) { - logger.warn('Failed to reset syncing status in finally block', { - connectorId, - error: toError(finallyError).message, - }) - } - } } } diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index 5752d05c6a1..3c48820d482 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -14,3 +14,37 @@ export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600 * sync while the first is still writing, both racing the same documents. */ export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000 + +/** + * Consecutive failed syncs after which a connector is disabled and stops being + * scheduled. + * + * Shared because two independent writers advance this counter: `executeSync`'s + * in-process failure path, and the scheduler's out-of-process stale-lock + * reclaim (a SIGKILL skips `catch`/`finally`, so only the reaper ever sees that + * failure). A connector that only ever dies hard must still reach the threshold, + * which it cannot if the two disagree on what the threshold is. + */ +export const MAX_CONSECUTIVE_FAILURES = 10 + +/** Minutes of backoff added per consecutive failure. */ +export const CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES = 30 + +/** Ceiling on failure backoff — one day, so a recovered source is retried daily. */ +export const CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES = 1440 + +/** + * Minutes to wait before retrying a connector that has failed `failures` times + * in a row. + * + * Both failure writers must use this ladder. The reaper previously hard-coded a + * flat 10-minute retry — shorter than any healthy sync interval — so a connector + * that kept dying hard was retried faster than a healthy one and never backed + * off at all. + */ +export function connectorFailureBackoffMinutes(failures: number): number { + return Math.min( + Math.max(failures, 1) * CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES, + CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES + ) +} From d3716ed40a86584627d1767a061663f5b74e38b0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 17:19:50 -0700 Subject: [PATCH 02/14] fix(connectors): surface a held reconciliation and scope the cap to eligible rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #6909. - A held pass reported an ordinary successful sync: the cap logged an error and the success update then cleared lastSyncError and reset consecutiveFailures, so source-removed documents stayed indexed with no operator signal. The notice is now threaded into the success update itself — writing it at the hold site would have been clobbered by that same update ~300 lines later, in the same run. status stays active and the failure counter still resets: a held pass is a healthy sync that declined to delete. - The cap denominator counted excluded tombstones, which partitionSyncReconciliation can never delete, inflating a budget against rows that cannot be spent. Both sides are now counted over the deletion-eligible population, matching the numerator. - completeSyncLog now only writes a row still marked started, so a late-finishing in-process run cannot overwrite a row the stale sweep already closed. The TTL is documented as a hard ceiling for both dispatch paths. --- .../knowledge/connectors/sync-engine.test.ts | 146 +++++++++++++++++- .../lib/knowledge/connectors/sync-engine.ts | 131 ++++++++++++++-- .../lib/knowledge/connectors/sync-limits.ts | 15 ++ 3 files changed, 272 insertions(+), 20 deletions(-) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 7f27d20a73c..0ffd1d6cb5f 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -1,7 +1,15 @@ /** * @vitest-environment node */ -import { authOAuthUtilsMock } from '@sim/testing' +import { + authOAuthUtilsMock, + dbChainMockFns, + drizzleOrmMock, + hasMockCondition, + type MockCondition, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { generateShortId } from '@sim/utils/id' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -13,13 +21,7 @@ import { } from '@/lib/knowledge/connectors/sync-engine' import type { ExternalDocument } from '@/connectors/types' -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - inArray: vi.fn(), - isNull: vi.fn(), - ne: vi.fn(), -})) +vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@/lib/knowledge/documents/service', () => ({ hardDeleteDocuments: vi.fn(), isTriggerAvailable: vi.fn(), @@ -1147,3 +1149,131 @@ describe('countNonExcludedListed', () => { expect(classifySuspectListing(listed.size, ownedDocCount)).toBeNull() }) }) + +describe('countDeletionEligibleOwned', () => { + const doc = (id: string) => ({ id, externalId: id }) + const excluded = (id: string) => ({ id, externalId: id, userExcluded: true }) + + it('does not let excluded tombstones inflate the denominator', async () => { + const { countDeletionEligibleOwned } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(countDeletionEligibleOwned([doc('a')], [excluded('t1'), excluded('t2')])).toBe(1) + expect(countDeletionEligibleOwned([doc('a')], [doc('t1'), excluded('t2')])).toBe(2) + }) + + it('excludes user-excluded rows from the live side too', async () => { + const { countDeletionEligibleOwned } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(countDeletionEligibleOwned([doc('a'), excluded('b')], [])).toBe(1) + }) + + it('agrees with the numerator on which population it counts', async () => { + const { classifySuspectListing, countDeletionEligibleOwned, countNonExcludedListed } = + await import('@/lib/knowledge/connectors/sync-engine') + + /** + * 100 live + 100 excluded tombstones. Counting the excluded tombstones would + * put the denominator at 200 and hide a listing that returned nothing but + * excluded documents. + */ + const existing = Array.from({ length: 100 }, (_, i) => doc(`live-${i}`)) + const tombstoned = Array.from({ length: 100 }, (_, i) => excluded(`ex-${i}`)) + const listed = new Set(tombstoned.map((d) => d.externalId)) + const excludedExternalIds = new Set(listed) + + const ownedDocCount = countDeletionEligibleOwned(existing, tombstoned) + const listedDocCount = countNonExcludedListed(listed, excludedExternalIds) + + expect(ownedDocCount).toBe(100) + expect(listedDocCount).toBe(0) + expect(classifySuspectListing(listedDocCount, ownedDocCount)).toBe('empty') + }) +}) + +describe('buildReconciliationHoldNotice', () => { + it('names the counts and the full-sync remedy', async () => { + const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') + + const notice = buildReconciliationHoldNotice(500, 250, 1000) + + expect(notice).toContain('500') + expect(notice).toContain('250') + expect(notice).toContain('1000') + expect(notice).toContain('full sync') + }) +}) + +describe('buildSyncSuccessUpdate', () => { + const now = new Date('2026-08-20T00:00:00.000Z') + + it('carries a hold notice into lastSyncError instead of clearing it', async () => { + const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * The sequencing assertion. This update runs at the end of the sync, long + * after the hold is detected, so writing the notice at the hold site would + * be clobbered here. + */ + const update = buildSyncSuccessUpdate(now, 42, null, 'held: 500 removals withheld') + + expect(update.lastSyncError).toBe('held: 500 removals withheld') + }) + + it('still clears lastSyncError on an ordinary successful sync', async () => { + const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(buildSyncSuccessUpdate(now, 42, null, null).lastSyncError).toBeNull() + }) + + it('does not treat a held pass as a broken connector', async () => { + const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + const update = buildSyncSuccessUpdate(now, 42, null, 'held') + + expect(update.status).toBe('active') + expect(update.consecutiveFailures).toBe(0) + }) +}) + +describe('completeSyncLog', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('only writes a row that is still started', async () => { + const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine') + + await completeSyncLog('log-1', 'completed', { + docsAdded: 1, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsFailed: 0, + }) + + const where = dbChainMockFns.where.mock.calls[0][0] + /** + * Without this the sweep and a late-finishing in-process run race: the sweep + * marks the row failed, then the run overwrites it as completed. + */ + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnectorSyncLog.status && + node.right === 'started' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnectorSyncLog.id && + node.right === 'log-1' + ) + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 9d67af98de3..c8541e26a46 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -408,7 +408,20 @@ function calculateNextSyncTime(syncIntervalMinutes: number): Date | null { return new Date(now + syncIntervalMinutes * 60_000 + jitterMs) } -async function completeSyncLog( +/** + * Records a sync run's outcome on its log row. + * + * Guarded on `status = 'started'` so a run that outlives + * {@link CONNECTOR_SYNC_STALE_LOCK_TTL_MS} cannot overwrite a row the + * scheduler's stale sweep already closed. Without the guard the two writers + * race and produce contradictory history: the sweep marks the row `failed`, + * then the still-running sync reports `completed` on the same row. + * + * A no-op on the normal path — nothing else touches the row between its + * `started` insert and this call, so the guard only ever bites once the sweep + * has declared the run dead, and the sweep's verdict is the one that stands. + */ +export async function completeSyncLog( syncLogId: string, status: 'completed' | 'failed', result: SyncResult, @@ -426,7 +439,12 @@ async function completeSyncLog( docsUnchanged: result.docsUnchanged, docsFailed: result.docsFailed, }) - .where(eq(knowledgeConnectorSyncLog.id, syncLogId)) + .where( + and( + eq(knowledgeConnectorSyncLog.id, syncLogId), + eq(knowledgeConnectorSyncLog.status, 'started') + ) + ) } /** @@ -571,6 +589,73 @@ export function evaluateListingSafety( return { reason, blocked: !corroborated, corroborated } } +/** + * Documents a reconciliation pass could actually remove. + * + * Both reads are filtered, not just the tombstoned one: the live read already + * excludes `userExcluded` rows in SQL, so filtering it again is a no-op today, + * but it keeps this count self-consistent with + * {@link partitionSyncReconciliation}, which gates deletion on the same flag for + * both lists. The result is the denominator for the deletion cap and for + * {@link classifySuspectListing}, whose numerator + * ({@link countNonExcludedListed}) ranges over the same population. + */ +export function countDeletionEligibleOwned( + existingDocs: ReconciliationDoc[], + tombstonedDocs: ReconciliationDoc[] +): number { + return ( + existingDocs.filter((d) => !d.userExcluded).length + + tombstonedDocs.filter((d) => !d.userExcluded).length + ) +} + +/** + * Operator-facing explanation of a held reconciliation pass. + * + * Stored on `knowledgeConnector.lastSyncError` because a hold is otherwise + * invisible: the sync completes normally and an operator sees an ordinary green + * run while source-removed documents stay indexed. Names the forced full sync, + * which is the documented way to apply the removals once the source is verified. + */ +export function buildReconciliationHoldNotice( + requested: number, + cap: number, + ownedDocCount: number +): string { + return ( + `Withheld ${requested} document removal(s) — more than the ${cap} allowed in one sync ` + + `of ${ownedDocCount} documents. Documents deleted at the source are still indexed. ` + + 'Check the source is returning its full contents, then run a full sync to apply the removals.' + ) +} + +/** + * The connector row a successful sync writes. + * + * `holdNotice` is threaded through rather than written when the hold is detected + * because this update runs at the very end of the sync and would otherwise clear + * `lastSyncError` in the same run. `status` stays `active` and + * `consecutiveFailures` still resets: a held pass is a healthy sync that declined + * to delete, not a failure, and marking it broken would stop it syncing at all. + */ +export function buildSyncSuccessUpdate( + now: Date, + actualDocCount: number, + nextSyncAt: Date | null, + holdNotice: string | null +) { + return { + status: 'active' as const, + lastSyncAt: now, + lastSyncError: holdNotice, + lastSyncDocCount: actualDocCount, + nextSyncAt, + consecutiveFailures: 0, + updatedAt: now, + } +} + /** * The document count to attribute to the previous sync when reconstructing its * listing. @@ -1504,7 +1589,14 @@ export async function executeSync( * same thing. Only evaluated when reconciliation would otherwise run, so * healthy syncs pay nothing and no existing gate is loosened. */ - const ownedDocCount = existingDocs.length + tombstonedDocs.length + /** + * Counted over deletion-eligible rows on both sides. The live read filters + * excluded documents in SQL; the tombstoned read only projects the flag, so + * excluded tombstones must be dropped here or they inflate a denominator + * governing a population they are not part of. Matches `listedDocCount`, + * which `countNonExcludedListed` already puts on the same footing. + */ + const ownedDocCount = countDeletionEligibleOwned(existingDocs, tombstonedDocs) /** * Counted over the same population as `ownedDocCount`: excluded documents * are absent from the live read, so they must not inflate the numerator. @@ -1554,7 +1646,23 @@ export async function executeSync( ownedDocCount, options?.fullSync ) + /** + * Surfaced on the connector so a held pass is visible to an operator rather + * than only in logs: without it the sync completes green, clears + * `lastSyncError`, and source-removed documents stay indexed with no signal. + * Written through the success update at the end of this run rather than + * here — that update sets `lastSyncError: null` unconditionally and would + * otherwise clobber this within the same sync. `status` is deliberately left + * `active`: the sync itself succeeded, and marking the connector broken + * would stop it syncing at all. + */ + let reconciliationHoldNotice: string | null = null if (capped.held) { + reconciliationHoldNotice = buildReconciliationHoldNotice( + capped.requested, + capped.cap, + ownedDocCount + ) logger.error('Reconciliation deletions held — exceeds per-sync blast-radius cap', { connectorId, connectorType: connector.connectorType, @@ -1807,15 +1915,14 @@ export async function executeSync( const now = new Date() await db .update(knowledgeConnector) - .set({ - status: 'active', - lastSyncAt: now, - lastSyncError: null, - lastSyncDocCount: actualDocCount, - nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes), - consecutiveFailures: 0, - updatedAt: now, - }) + .set( + buildSyncSuccessUpdate( + now, + actualDocCount, + calculateNextSyncTime(connector.syncIntervalMinutes), + reconciliationHoldNotice + ) + ) .where( and( eq(knowledgeConnector.id, connectorId), diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index 3c48820d482..03b58ef8fa0 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -12,6 +12,21 @@ export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600 * MUST stay above {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}: reclaiming frees the * lock for another sync, so a TTL at or below the run ceiling would start a second * sync while the first is still writing, both racing the same documents. + * + * This is a hard ceiling for BOTH execution paths, not just the queued one. A + * Trigger.dev run is killed at {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}, so it + * is provably dead well before this. The fallback path is not: when Trigger.dev is + * unavailable, `dispatchSync` runs `executeSync` fire-and-forget inside the web + * process with no duration cap, and such a run genuinely can still be executing + * when this TTL expires. + * + * Treating it as dead anyway is deliberate. An unbounded background sync in a + * recyclable web process that has run for two hours is indistinguishable from one + * whose process was recycled out from under it, and the cost of guessing wrong in + * the other direction is a connector locked out of syncing forever. The sweep's + * verdict is therefore authoritative: `completeSyncLog` is guarded on + * `status = 'started'`, so a late finisher cannot overwrite a row already closed + * here, and it loses the race by design rather than by accident. */ export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000 From 8e3185aee92c69bbe5de4c671b51b5c92edc9e10 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 17:40:39 -0700 Subject: [PATCH 03/14] fix(connectors): stop a reclaimed run from overwriting the reaper's verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #6909. The terminal connector writes were unguarded, so a run that outlived the stale lock could still land its result after the reaper reclaimed it: flipping status back to active, zeroing consecutiveFailures, and erasing a backoff or an auto-disable the breaker had just applied. Both terminal paths now go through a single writer that applies the still-holds-the-lock guard itself, so no future terminal path can be added without it. The knowledge-base-deleted write stays outside deliberately — it runs before the lock is acquired, so the guard would silently discard it. A superseded success is reported as an error rather than a clean sync, matching the treatment lock contention already gets. The failure path deliberately keeps its real error message instead: it already reports failure, so overwriting the cause would lose the diagnostic and gain nothing. Falls out of the same guard: a connector paused mid-sync is no longer flipped back to active by the completing run. --- .../knowledge/connectors/sync-engine.test.ts | 143 ++++++++++++++++++ .../lib/knowledge/connectors/sync-engine.ts | 142 +++++++++++++---- 2 files changed, 252 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 0ffd1d6cb5f..cb738cb88e4 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -1277,3 +1277,146 @@ describe('completeSyncLog', () => { ).toBe(true) }) }) + +describe('stillHoldsSyncLock', () => { + it('requires the connector to still be syncing', async () => { + const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * Without this a run reclaimed by the stale sweep still writes its terminal + * result: clearing the backoff, un-disabling the connector, and resetting a + * failure counter the sweep just advanced. + */ + expect( + hasMockCondition( + stillHoldsSyncLock('c-1'), + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.status && + node.right === 'syncing' + ) + ).toBe(true) + }) + + it('still scopes to the connector and skips archived or deleted rows', async () => { + const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + const condition = stillHoldsSyncLock('c-1') + + expect( + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.id && + node.right === 'c-1' + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt + ) + ).toBe(true) + }) +}) + +describe('writeTerminalConnectorState', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('applies the sync-lock guard itself so no caller can omit it', async () => { + const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * The property that closes the gap a shared-helper-by-convention left open: + * both terminal paths route through here and neither builds a WHERE clause, + * so removing the guard is a single-site edit that this assertion catches. + */ + await writeTerminalConnectorState('c-1', { status: 'active' }) + + const where = dbChainMockFns.where.mock.calls[0][0] + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.status && + node.right === 'syncing' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.id && + node.right === 'c-1' + ) + ).toBe(true) + }) + + it('passes the caller values through untouched', async () => { + const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine') + + const values = { status: 'error', consecutiveFailures: 4, nextSyncAt: null } + await writeTerminalConnectorState('c-1', values) + + expect(dbChainMockFns.set.mock.calls[0][0]).toEqual(values) + }) + + it('reports whether the write landed', async () => { + const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine') + + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(true) + + dbChainMockFns.returning.mockResolvedValueOnce([]) + expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(false) + }) +}) + +describe('applySupersededOutcome', () => { + const result = { + docsAdded: 3, + docsUpdated: 1, + docsDeleted: 0, + docsUnchanged: 2, + docsFailed: 0, + } + + it('leaves a run that kept its lock untouched', async () => { + const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(applySupersededOutcome(result, true)).toEqual(result) + }) + + it('flags a discarded run so the task wrapper does not report it as clean', async () => { + const { applySupersededOutcome, SUPERSEDED_SYNC_ERROR } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + const superseded = applySupersededOutcome(result, false) + + // The task wrapper reports `success: !result.error`. + expect(superseded.error).toBe(SUPERSEDED_SYNC_ERROR) + expect(Boolean(superseded.error)).toBe(true) + }) + + it('preserves the document counters of the discarded run', async () => { + const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine') + + // Those writes landed — only the connector-level bookkeeping was discarded. + expect(applySupersededOutcome(result, false)).toMatchObject(result) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index c8541e26a46..59d946319e6 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -447,6 +447,76 @@ export async function completeSyncLog( ) } +/** + * Matches the connector row only while this run still holds its sync lock. + * + * `executeSync` sets `status = 'syncing'` when it acquires the lock, so that + * value means "I am still the writer". Anything else means another actor took + * the row: the scheduler's stale sweep reclaimed it to `error`/`disabled` and + * may already have dispatched a replacement, or a user paused it. In every such + * case this run's terminal write must not land — otherwise it clears a backoff + * the breaker just set, un-disables a connector, or flips a paused connector + * back to `active`. + * + * Guards both terminal paths. The failure path needs it as much as the success + * path: a reclaimed run's failure would double-increment a counter the sweep + * already advanced and overwrite its backoff with a shorter one. + */ +export function stillHoldsSyncLock(connectorId: string) { + return and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.status, 'syncing'), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) +} + +/** Columns a terminal write may set. Both paths write a subset of the same set. */ +type ConnectorTerminalUpdate = Partial + +/** + * The only way a sync run writes its terminal state onto the connector row. + * + * Callers pass their own values and never build a WHERE clause: the + * {@link stillHoldsSyncLock} guard is applied here, so there is exactly one + * place it can be removed from and a terminal path added later cannot forget + * it. Returns whether the write landed — false means the run was reclaimed + * mid-flight and its bookkeeping was discarded in favour of whoever took the + * row. + */ +export async function writeTerminalConnectorState( + connectorId: string, + values: ConnectorTerminalUpdate +): Promise { + const written = await db + .update(knowledgeConnector) + .set(values) + .where(stillHoldsSyncLock(connectorId)) + .returning({ id: knowledgeConnector.id }) + + return written.length > 0 +} + +/** + * Reported when a run's terminal write matched no rows because the run no longer + * held its lock. Its document writes still landed; only its connector-level + * bookkeeping was discarded, in favour of whoever reclaimed the row. + */ +export const SUPERSEDED_SYNC_ERROR = 'sync_superseded' + +/** + * Marks a superseded run so the task wrapper's `success: !result.error` does not + * report a discarded run as a clean sync — the same reason a lock-contended run + * returns `sync_in_progress` rather than an empty success. + */ +export function applySupersededOutcome( + result: SyncResult, + terminalWriteLanded: boolean +): SyncResult { + if (terminalWriteLanded) return result + return { ...result, error: SUPERSEDED_SYNC_ERROR } +} + /** * Decides whether deletion reconciliation may run for a sync. * @@ -1913,23 +1983,24 @@ export async function executeSync( ) const now = new Date() - await db - .update(knowledgeConnector) - .set( - buildSyncSuccessUpdate( - now, - actualDocCount, - calculateNextSyncTime(connector.syncIntervalMinutes), - reconciliationHoldNotice - ) - ) - .where( - and( - eq(knowledgeConnector.id, connectorId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) + const successWriteLanded = await writeTerminalConnectorState( + connectorId, + buildSyncSuccessUpdate( + now, + actualDocCount, + calculateNextSyncTime(connector.syncIntervalMinutes), + reconciliationHoldNotice ) + ) + + if (!successWriteLanded) { + logger.warn('Sync result discarded — connector was reclaimed while this run was executing', { + connectorId, + syncLogId, + ...result, + }) + return applySupersededOutcome(result, false) + } logger.info('Sync completed', { connectorId, ...result }) return result @@ -1982,24 +2053,29 @@ export async function executeSync( }) } - await db - .update(knowledgeConnector) - .set({ - status: disabled ? 'disabled' : 'error', - lastSyncError: disabled - ? 'Connector disabled after repeated sync failures. Please reconnect.' - : errorMessage, - nextSyncAt: nextSync, - consecutiveFailures: failures, - updatedAt: now, - }) - .where( - and( - eq(knowledgeConnector.id, connectorId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) + const failureWriteLanded = await writeTerminalConnectorState(connectorId, { + status: disabled ? 'disabled' : 'error', + lastSyncError: disabled + ? 'Connector disabled after repeated sync failures. Please reconnect.' + : errorMessage, + nextSyncAt: nextSync, + consecutiveFailures: failures, + updatedAt: now, + }) + + /** + * Deliberately does NOT get {@link applySupersededOutcome}. `result.error` + * is set to the real failure cause below and the task wrapper already + * reports this run as unsuccessful, so overwriting it with + * `sync_superseded` would destroy the diagnostic without changing the + * reported outcome. The supersession is carried by this log line instead. + */ + if (!failureWriteLanded) { + logger.warn( + 'Sync failure discarded — connector was reclaimed while this run was executing', + { connectorId, syncLogId, error: errorMessage } ) + } } catch (recoveryError) { logger.error('Failed to record sync failure', { connectorId, From a5e515f18e6be4f57f2015d123f6e601ade00c18 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:18:10 -0700 Subject: [PATCH 04/14] fix(connectors): identify which run holds a connector's sync lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3 on #6909. Guarding terminal writes on status='syncing' proved only that *a* run held the lock, not that this one did. After a stale-lock reclaim dispatched a replacement, the original run's guard matched the replacement's own lock and clobbered both its state and the reaper's bookkeeping — and the live run's write was then rejected. The dead run won and the live one was discarded, which is worse than the last-write-wins behavior the guard replaced. A nullable sync_lock_token is stamped in the same statement that claims the lock, so ownership is established atomically with acquisition and matching it proves the lock is still this run's. status='syncing' stays alongside it as defence in depth and to keep a connector paused mid-sync from being flipped back to active. Rejected using updatedAt as an optimistic-concurrency token: connector updates bump it unconditionally with no status guard, so a user editing config mid-sync would strand the connector in syncing until the reaper cleared it two hours later. Migration is additive, nullable, no backfill. Existing rows read NULL, which no in-flight run can match, so a sync spanning the deploy loses only its terminal write and is re-run by the scheduler. --- .../api/knowledge/connectors/sync/route.ts | 3 + .../knowledge/connectors/sync-engine.test.ts | 119 +++++++++++++++++- .../lib/knowledge/connectors/sync-engine.ts | 56 +++++++-- packages/db/schema.ts | 9 ++ packages/testing/src/mocks/schema.mock.ts | 1 + 5 files changed, 170 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 389eb7e966f..d33f6aa7ff2 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -82,6 +82,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { lastSyncError: STALE_LOCK_ERROR_MESSAGE, nextSyncAt: reclaimedNextSyncAt(), consecutiveFailures: reclaimedFailureCount(), + // Releases the reclaimed run's ownership token so its terminal write can + // no longer match, even before a replacement takes the lock. + syncLockToken: null, updatedAt: sql`now()`, }) .where( diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index cb738cb88e4..7f9891f3856 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -5,6 +5,7 @@ import { authOAuthUtilsMock, dbChainMockFns, drizzleOrmMock, + flattenMockConditions, hasMockCondition, type MockCondition, resetDbChainMock, @@ -1289,7 +1290,7 @@ describe('stillHoldsSyncLock', () => { */ expect( hasMockCondition( - stillHoldsSyncLock('c-1'), + stillHoldsSyncLock('c-1', 'run-a'), (node: MockCondition) => node.type === 'eq' && node.left === schemaMock.knowledgeConnector.status && @@ -1301,7 +1302,7 @@ describe('stillHoldsSyncLock', () => { it('still scopes to the connector and skips archived or deleted rows', async () => { const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') - const condition = stillHoldsSyncLock('c-1') + const condition = stillHoldsSyncLock('c-1', 'run-a') expect( hasMockCondition( @@ -1343,7 +1344,7 @@ describe('writeTerminalConnectorState', () => { * both terminal paths route through here and neither builds a WHERE clause, * so removing the guard is a single-site edit that this assertion catches. */ - await writeTerminalConnectorState('c-1', { status: 'active' }) + await writeTerminalConnectorState('c-1', 'run-a', { status: 'active' }) const where = dbChainMockFns.where.mock.calls[0][0] expect( @@ -1364,13 +1365,23 @@ describe('writeTerminalConnectorState', () => { node.right === 'c-1' ) ).toBe(true) + // The token must be the run's own, not some other value that merely fills the slot. + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.syncLockToken && + node.right === 'run-a' + ) + ).toBe(true) }) it('passes the caller values through untouched', async () => { const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine') const values = { status: 'error', consecutiveFailures: 4, nextSyncAt: null } - await writeTerminalConnectorState('c-1', values) + await writeTerminalConnectorState('c-1', 'run-a', values) expect(dbChainMockFns.set.mock.calls[0][0]).toEqual(values) }) @@ -1379,10 +1390,10 @@ describe('writeTerminalConnectorState', () => { const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine') dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) - expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(true) + expect(await writeTerminalConnectorState('c-1', 'run-a', { status: 'active' })).toBe(true) dbChainMockFns.returning.mockResolvedValueOnce([]) - expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(false) + expect(await writeTerminalConnectorState('c-1', 'run-a', { status: 'active' })).toBe(false) }) }) @@ -1420,3 +1431,99 @@ describe('applySupersededOutcome', () => { expect(applySupersededOutcome(result, false)).toMatchObject(result) }) }) + +/** + * Evaluates a mocked drizzle condition tree against a plain row. + * + * The row-queue mocks return whatever was queued regardless of the predicate, so + * "this WHERE admits run B and rejects run A" is only observable by interpreting + * the condition tree the guard emits. + */ +function conditionMatchesRow(condition: unknown, row: Record): boolean { + return flattenMockConditions(condition).every((node) => { + if (node.type === 'eq') return row[node.left as string] === node.right + if (node.type === 'isNull') return row[node.column as string] == null + throw new Error(`unhandled condition node: ${String(node.type)}`) + }) +} + +describe('sync lock ownership across a reclaim and reacquire', () => { + const RUN_A = 'run-a' + const RUN_B = 'run-b' + + /** The connector row once run B has taken the lock that run A used to hold. */ + const rowHeldByB = { + id: 'c-1', + status: 'syncing', + syncLockToken: RUN_B, + archivedAt: null, + deletedAt: null, + } + + it('rejects the reclaimed run A and admits the live run B', async () => { + const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * A outlived the TTL, the reaper reclaimed its lock, and replacement B took + * it — so the row reads `syncing` again. Guarding on status alone matched A + * here and let the dead run clobber the live one, then rejected B's own + * write as superseded. Exactly inverted. + */ + expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), rowHeldByB)).toBe(false) + expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_B), rowHeldByB)).toBe(true) + }) + + it('rejects a run whose lock was reclaimed with no replacement yet', async () => { + const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + const reclaimed = { + id: 'c-1', + status: 'error', + syncLockToken: null, + archivedAt: null, + deletedAt: null, + } + + expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), reclaimed)).toBe(false) + }) + + it('admits the run that still holds its own lock', async () => { + const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + const heldByA = { ...rowHeldByB, syncLockToken: RUN_A } + + expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), heldByA)).toBe(true) + }) + + it('rejects a run whose connector was paused mid-sync', async () => { + const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + const paused = { ...rowHeldByB, status: 'paused', syncLockToken: RUN_A } + + expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), paused)).toBe(false) + }) + + it('releases the token when a run writes its terminal success state', async () => { + const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + // A stale token left behind could match a later run reusing the same id. + expect(buildSyncSuccessUpdate(new Date(), 1, null, null).syncLockToken).toBeNull() + }) +}) + +describe('buildSyncLockAcquisition', () => { + it('claims the lock and stamps ownership in one payload', async () => { + const { buildSyncLockAcquisition } = await import('@/lib/knowledge/connectors/sync-engine') + + const now = new Date('2026-08-20T00:00:00.000Z') + const acquisition = buildSyncLockAcquisition('run-a', now) + + /** + * Without the token here every terminal write would fail to match its own + * run, so every sync would report superseded and leave the connector stuck + * `syncing` until the reaper cleared it. + */ + expect(acquisition.syncLockToken).toBe('run-a') + expect(acquisition.status).toBe('syncing') + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 59d946319e6..74f171b3035 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -450,27 +450,47 @@ export async function completeSyncLog( /** * Matches the connector row only while this run still holds its sync lock. * - * `executeSync` sets `status = 'syncing'` when it acquires the lock, so that - * value means "I am still the writer". Anything else means another actor took - * the row: the scheduler's stale sweep reclaimed it to `error`/`disabled` and - * may already have dispatched a replacement, or a user paused it. In every such - * case this run's terminal write must not land — otherwise it clears a backoff - * the breaker just set, un-disables a connector, or flips a paused connector - * back to `active`. + * `status = 'syncing'` alone is not enough: it asserts that *a* run holds the + * lock, not that *this* run does. Once the scheduler reclaims a stale lock and + * dispatches a replacement, the replacement sets `syncing` again — so the + * original run would match, overwrite the replacement's in-flight state and the + * reclaim's bookkeeping, and then reject the replacement's own write as + * superseded. The dead run wins and the live one loses, which is worse than the + * unguarded last-write-wins it replaced. + * + * `syncLockToken` is written in the same CAS that takes the lock, so matching it + * proves the lock is still this run's. `status` is kept alongside as defence in + * depth and to cover a user pausing the connector mid-run. * * Guards both terminal paths. The failure path needs it as much as the success * path: a reclaimed run's failure would double-increment a counter the sweep * already advanced and overwrite its backoff with a shorter one. */ -export function stillHoldsSyncLock(connectorId: string) { +export function stillHoldsSyncLock(connectorId: string, syncLockToken: string) { return and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.status, 'syncing'), + eq(knowledgeConnector.syncLockToken, syncLockToken), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) } +/** + * The connector row a run writes when it takes the sync lock. + * + * `syncLockToken` is set here, in the same statement as `status`, so ownership + * and the lock are established atomically — a token written afterwards would + * leave a window where a terminal write could not identify its own run. + */ +export function buildSyncLockAcquisition(syncLogId: string, now: Date) { + return { + status: 'syncing' as const, + syncLockToken: syncLogId, + updatedAt: now, + } +} + /** Columns a terminal write may set. Both paths write a subset of the same set. */ type ConnectorTerminalUpdate = Partial @@ -486,12 +506,13 @@ type ConnectorTerminalUpdate = Partial */ export async function writeTerminalConnectorState( connectorId: string, + syncLockToken: string, values: ConnectorTerminalUpdate ): Promise { const written = await db .update(knowledgeConnector) .set(values) - .where(stillHoldsSyncLock(connectorId)) + .where(stillHoldsSyncLock(connectorId, syncLockToken)) .returning({ id: knowledgeConnector.id }) return written.length > 0 @@ -722,6 +743,8 @@ export function buildSyncSuccessUpdate( lastSyncDocCount: actualDocCount, nextSyncAt, consecutiveFailures: 0, + // Releases the lock so a stale token can never match a later run. + syncLockToken: null, updatedAt: now, } } @@ -1151,9 +1174,17 @@ export async function executeSync( } const sourceConfig = connector.sourceConfig as Record + /** + * Identifies this run for the terminal writes. Generated before the CAS and + * written by it, so ownership is established atomically with the lock — and + * reused as the sync-log row id, which makes the connector row point at the + * run that holds it. + */ + const syncLogId = generateId() + const lockResult = await db .update(knowledgeConnector) - .set({ status: 'syncing', updatedAt: new Date() }) + .set(buildSyncLockAcquisition(syncLogId, new Date())) .where( and( eq(knowledgeConnector.id, connectorId), @@ -1171,7 +1202,6 @@ export async function executeSync( return { ...result, error: 'sync_in_progress' } } - const syncLogId = generateId() const syncStartedAt = new Date() await db.insert(knowledgeConnectorSyncLog).values({ id: syncLogId, @@ -1985,6 +2015,7 @@ export async function executeSync( const now = new Date() const successWriteLanded = await writeTerminalConnectorState( connectorId, + syncLogId, buildSyncSuccessUpdate( now, actualDocCount, @@ -2053,13 +2084,14 @@ export async function executeSync( }) } - const failureWriteLanded = await writeTerminalConnectorState(connectorId, { + const failureWriteLanded = await writeTerminalConnectorState(connectorId, syncLogId, { status: disabled ? 'disabled' : 'error', lastSyncError: disabled ? 'Connector disabled after repeated sync failures. Please reconnect.' : errorMessage, nextSyncAt: nextSync, consecutiveFailures: failures, + syncLockToken: null, updatedAt: now, }) diff --git a/packages/db/schema.ts b/packages/db/schema.ts index ae99cdcf3c8..a62e33f1c89 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4329,6 +4329,15 @@ export const knowledgeConnector = pgTable( lastSyncDocCount: integer('last_sync_doc_count'), nextSyncAt: timestamp('next_sync_at'), consecutiveFailures: integer('consecutive_failures').notNull().default(0), + /** + * Identifies the sync run that currently holds this connector's lock. + * + * `status = 'syncing'` only says *a* run holds it. After the scheduler + * reclaims a stale lock and dispatches a replacement, the original run would + * still see `syncing` and overwrite the replacement's state. Terminal writes + * match this token so a run can prove the lock is still *its own*. + */ + syncLockToken: text('sync_lock_token'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), archivedAt: timestamp('archived_at'), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 1cdbc71c2e5..6b13d139a1d 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1270,6 +1270,7 @@ export const schemaMock = { lastSyncDocCount: 'lastSyncDocCount', nextSyncAt: 'nextSyncAt', consecutiveFailures: 'consecutiveFailures', + syncLockToken: 'syncLockToken', createdAt: 'createdAt', updatedAt: 'updatedAt', archivedAt: 'archivedAt', From 81c7b239a6fe764d22ca0ba12c03a6a8a96ada01 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:30:45 -0700 Subject: [PATCH 05/14] fix(connectors): heartbeat a running sync and cap deletion generations apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4 on #6909. Two regressions the guards introduced, plus two tests that could not fail. Counting a stale-lock reclaim as a failure turned the reaper into a one-way ratchet for any sync that legitimately outran the TTL: the in-process path has no duration cap, so a long self-hosted sync was reclaimed, its successful terminal write then failed the ownership guard and was discarded, and its failure counter never reset. Ten of those and a working connector was disabled telling the user to reconnect. A running sync now refreshes updatedAt every five minutes, so the reaper's staleness predicate means "nobody is working on this" rather than "this started a long time ago". The beat is guarded on the run's own lock, so it doubles as an ownership probe: a run whose lock was reclaimed abandons immediately instead of working for hours and then discarding the result. The deletion cap summed soft and hard deletes against one ceiling sized for a single generation, so a connector with steady churn deadlocked from its second sync onward and got monotonically worse — the all-or-nothing hold blocked the very hard deletes that would have drained the tombstone backlog. Hard deletes are confirmations of removals already gated when they were soft-deleted, so each generation now caps independently. Both guard tests for the reaper asserted only the bookends of the rendered SQL, leaving the comparison itself unasserted: an inverted threshold that disabled a connector on its first hard kill passed. Both now assert the whole expression. --- .../knowledge/connectors/sync/route.test.ts | 44 +++++- .../knowledge/connectors/sync-engine.test.ts | 144 ++++++++++++++++-- .../lib/knowledge/connectors/sync-engine.ts | 128 ++++++++++++++-- .../lib/knowledge/connectors/sync-limits.ts | 39 +++-- 4 files changed, 312 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index a1a3e43d1dc..efc37c24018 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -106,11 +106,18 @@ describe('connector sync scheduler stale-lock reaper', () => { await runTickRecovering(['connector-1']) const status = setPayloadForUpdate(0).status - const rendered = renderedSql(status) - expect(rendered).toContain('CASE WHEN COALESCE(') - expect(rendered).toContain("THEN 'disabled' ELSE 'error' END") - expect(numericBinds(status)).toContain(MAX_CONSECUTIVE_FAILURES) + /** + * Asserted whole rather than by its bookends: the comparison is the entire + * point of this expression, and leaving it in an un-asserted middle let + * `+ 2 >=`, `+ 1 >`, and an inverted `+ 1 <=` all pass. The last of those + * disables a connector on its first hard kill. + */ + expect(renderedSql(status)).toBe( + "CASE WHEN COALESCE(?, 0) + 1 >= ? THEN 'disabled' ELSE 'error' END" + ) + expect(asFragment(status).values[0]).toBe(schemaMock.knowledgeConnector.consecutiveFailures) + expect(asFragment(status).values[1]).toBe(MAX_CONSECUTIVE_FAILURES) }) it('derives nextSyncAt from the shared failure backoff ladder', async () => { @@ -119,9 +126,10 @@ describe('connector sync scheduler stale-lock reaper', () => { const nextSyncAt = setPayloadForUpdate(0).nextSyncAt const rendered = renderedSql(nextSyncAt) - expect(rendered).toContain('THEN NULL') - expect(rendered).toContain('LEAST(') - expect(rendered).toContain("INTERVAL '1 minute'") + expect(rendered).toBe( + 'CASE WHEN COALESCE(?, 0) + 1 >= ? THEN NULL ' + + "ELSE now() + LEAST((COALESCE(?, 0) + 1) * ?, ?) * INTERVAL '1 minute' END" + ) const [threshold, step, cap] = numericBinds(nextSyncAt) expect(threshold).toBe(MAX_CONSECUTIVE_FAILURES) @@ -190,12 +198,32 @@ describe('connector sync scheduler stale-lock reaper', () => { await runTickRecovering(['connector-1']) const where = dbChainMockFns.where.mock.calls[1][0] + + /** + * Checks every position, not just `column`. `eq()` builds `{left, right}` + * and only `inArray()` builds `{column}`, so a `column`-only assertion + * silently permitted an `eq`-scoped sweep — the exact coupling this test + * exists to forbid. + */ + const connectorIdColumn = schemaMock.knowledgeConnectorSyncLog.connectorId expect( hasMockCondition( where, - (node: MockCondition) => node.column === schemaMock.knowledgeConnectorSyncLog.connectorId + (node: MockCondition) => + node.column === connectorIdColumn || + node.left === connectorIdColumn || + node.right === connectorIdColumn ) ).toBe(false) + + // And positively: the sweep is keyed on the row's own age. + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'lte' && node.left === schemaMock.knowledgeConnectorSyncLog.startedAt + ) + ).toBe(true) }) it('drives the connector write off a single clock', async () => { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 7f9891f3856..333c3de8630 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -937,7 +937,7 @@ describe('capReconciliationDeletions', () => { expect(result.held).toBe(false) expect(result.cap).toBe(250) - expect(result.requested).toBe(250) + expect(result.withheld).toBe(0) expect(result.softDeleteIds).toEqual(soft) }) @@ -947,7 +947,8 @@ describe('capReconciliationDeletions', () => { const result = capReconciliationDeletions(ids('soft', 251), [], 1000, false) expect(result.held).toBe(true) - expect(result.requested).toBe(251) + expect(result.softHeld).toBe(true) + expect(result.withheld).toBe(251) }) it('returns empty arrays — not the inputs — when held', async () => { @@ -960,25 +961,55 @@ describe('capReconciliationDeletions', () => { expect(result.hardDeleteIds).toEqual([]) }) - it('counts the union of soft and hard deletions against one cap', async () => { + it('caps each generation separately rather than summing them', async () => { const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') - const overlapping = ids('doc', 200) - // Same ids on both lists must count once, not twice. - expect(capReconciliationDeletions(overlapping, overlapping, 1000, false).held).toBe(false) - expect(capReconciliationDeletions(ids('a', 200), ids('b', 200), 1000, false).held).toBe(true) + /** + * Hard deletes are the previous generation's soft deletes, already gated by + * this cap once. Summing them double-counts the older generation, which is + * what deadlocked a churning connector. + */ + const result = capReconciliationDeletions(ids('a', 200), ids('b', 200), 1000, false) + + expect(result.held).toBe(false) + expect(result.softDeleteIds).toHaveLength(200) + expect(result.hardDeleteIds).toHaveLength(200) }) - it('is bypassed by a forced fullSync', async () => { + it('holds only the generation that breached the cap', async () => { const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') - const hard = ids('hard', 1000) - const result = capReconciliationDeletions([], hard, 1000, true) + const hard = ids('hard', 100) + const result = capReconciliationDeletions(ids('soft', 400), hard, 1000, false) - expect(result.held).toBe(false) + expect(result.softHeld).toBe(true) + expect(result.hardHeld).toBe(false) + expect(result.softDeleteIds).toEqual([]) + // The confirmed generation still drains, so the backlog cannot ratchet. expect(result.hardDeleteIds).toEqual(hard) }) + it('is bypassed by a forced fullSync, in both generations', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + const hard = ids('hard', 1000) + const hardOnly = capReconciliationDeletions([], hard, 1000, true) + + expect(hardOnly.held).toBe(false) + expect(hardOnly.hardDeleteIds).toEqual(hard) + + /** + * Exercised per generation: asserting only the hard list left the soft + * branch's bypass untested, so dropping it there was invisible. + */ + const soft = ids('soft', 1000) + const softOnly = capReconciliationDeletions(soft, [], 1000, true) + + expect(softOnly.held).toBe(false) + expect(softOnly.softHeld).toBe(false) + expect(softOnly.softDeleteIds).toEqual(soft) + }) + it('applies the small-corpus floor rather than the ratio', async () => { const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') @@ -1000,6 +1031,28 @@ describe('capReconciliationDeletions', () => { ).toBe(true) }) + describe('steady churn', () => { + it('reaches a stable state instead of ratcheting shut', async () => { + const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * 1,000 documents at 15% churn against a cap of 250. Under one summed cap: + * sync 1 applied 150 soft; sync 2 requested 150 soft + 150 hard = 300 and + * was held in full; the blocked hard deletes then accumulated forever. + */ + const sync1 = capReconciliationDeletions(ids('gen1', 150), [], 1000, false) + expect(sync1.held).toBe(false) + + const sync2 = capReconciliationDeletions(ids('gen2', 150), ids('gen1', 150), 1000, false) + expect(sync2.held).toBe(false) + expect(sync2.hardDeleteIds).toHaveLength(150) + + const sync3 = capReconciliationDeletions(ids('gen3', 150), ids('gen2', 150), 1000, false) + expect(sync3.held).toBe(false) + expect(sync3.hardDeleteIds).toHaveLength(150) + }) + }) + describe('confirmed data-loss shapes', () => { it('holds a partial outage that returns half a 1000-document corpus', async () => { const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine') @@ -1527,3 +1580,72 @@ describe('buildSyncLockAcquisition', () => { expect(acquisition.status).toBe('syncing') }) }) + +describe('shouldHeartbeatSyncLock', () => { + it('beats once the interval has elapsed', async () => { + const { shouldHeartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldHeartbeatSyncLock(1_000, 0, 1_000)).toBe(true) + expect(shouldHeartbeatSyncLock(1_001, 0, 1_000)).toBe(true) + }) + + it('does not beat before the interval has elapsed', async () => { + const { shouldHeartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldHeartbeatSyncLock(999, 0, 1_000)).toBe(false) + expect(shouldHeartbeatSyncLock(0, 0, 1_000)).toBe(false) + }) + + it('defaults to an interval far below the reclaim TTL', async () => { + const { shouldHeartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + const { CONNECTOR_SYNC_STALE_LOCK_TTL_MS, SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import( + '@/lib/knowledge/connectors/sync-limits' + ) + + /** + * A live run must beat many times over before the reclaim cutoff, or + * ordinary jitter reclaims a working sync — which is what made the reaper a + * one-way ratchet to `disabled` for slow in-process syncs. + */ + expect(SYNC_LOCK_HEARTBEAT_INTERVAL_MS * 4).toBeLessThan(CONNECTOR_SYNC_STALE_LOCK_TTL_MS) + expect(shouldHeartbeatSyncLock(SYNC_LOCK_HEARTBEAT_INTERVAL_MS, 0)).toBe(true) + expect(shouldHeartbeatSyncLock(SYNC_LOCK_HEARTBEAT_INTERVAL_MS - 1, 0)).toBe(false) + }) +}) + +describe('heartbeatSyncLock', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('refreshes updatedAt under the run own lock guard', async () => { + const { heartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + await heartbeatSyncLock('c-1', 'run-a') + + expect(dbChainMockFns.set.mock.calls[0][0]).toEqual({ updatedAt: expect.any(Date) }) + + // Guarded, so a beat doubles as an ownership probe rather than a blind touch. + const where = dbChainMockFns.where.mock.calls[0][0] + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.syncLockToken && + node.right === 'run-a' + ) + ).toBe(true) + }) + + it('reports a lost lock so the run can stop instead of racing its replacement', async () => { + const { heartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + dbChainMockFns.returning.mockResolvedValueOnce([]) + expect(await heartbeatSyncLock('c-1', 'run-a')).toBe(false) + + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + expect(await heartbeatSyncLock('c-1', 'run-a')).toBe(true) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 74f171b3035..dda098b169c 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -21,6 +21,7 @@ import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' import { connectorFailureBackoffMinutes, MAX_CONSECUTIVE_FAILURES, + SYNC_LOCK_HEARTBEAT_INTERVAL_MS, } from '@/lib/knowledge/connectors/sync-limits' import type { DocumentData } from '@/lib/knowledge/documents/service' import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' @@ -41,6 +42,20 @@ import { hasIndexablePayload } from '@/connectors/utils' const logger = createLogger('ConnectorSyncEngine') +/** + * Raised when a run discovers mid-flight that it no longer holds its sync lock. + * + * Stops it doing hours of further work whose terminal write would be rejected, + * and — more importantly — stops it writing documents concurrently with the + * replacement run that took the lock. + */ +class SyncLockLostException extends Error { + constructor(connectorId: string) { + super(`Sync lock for connector ${connectorId} was reclaimed during sync`) + this.name = 'SyncLockLostException' + } +} + class ConnectorDeletedException extends Error { constructor(connectorId: string) { super(`Connector ${connectorId} was deleted during sync`) @@ -491,6 +506,42 @@ export function buildSyncLockAcquisition(syncLogId: string, now: Date) { } } +/** + * Whether a running sync is due to refresh its lock. + * + * Time-based rather than batch-count-based: batches vary hugely in cost, so a + * every-N-batches beat would fire constantly on small documents and barely at + * all on large ones — exactly the runs that need it. + */ +export function shouldHeartbeatSyncLock( + nowMs: number, + lastBeatMs: number, + intervalMs: number = SYNC_LOCK_HEARTBEAT_INTERVAL_MS +): boolean { + return nowMs - lastBeatMs >= intervalMs +} + +/** + * Refreshes the connector's `updatedAt` to prove this run is still working, so + * the scheduler's stale-lock reclaim does not treat a slow-but-live sync as dead. + * + * Guarded on the run's own lock, so it doubles as an ownership probe: a false + * return means the lock was reclaimed and this run must stop rather than keep + * writing alongside its replacement. + */ +export async function heartbeatSyncLock( + connectorId: string, + syncLockToken: string +): Promise { + const beat = await db + .update(knowledgeConnector) + .set({ updatedAt: new Date() }) + .where(stillHoldsSyncLock(connectorId, syncLockToken)) + .returning({ id: knowledgeConnector.id }) + + return beat.length > 0 +} + /** Columns a terminal write may set. Both paths write a subset of the same set. */ type ConnectorTerminalUpdate = Partial @@ -710,12 +761,12 @@ export function countDeletionEligibleOwned( * which is the documented way to apply the removals once the source is verified. */ export function buildReconciliationHoldNotice( - requested: number, + withheld: number, cap: number, ownedDocCount: number ): string { return ( - `Withheld ${requested} document removal(s) — more than the ${cap} allowed in one sync ` + + `Withheld ${withheld} document removal(s) — more than the ${cap} allowed in one sync ` + `of ${ownedDocCount} documents. Documents deleted at the source are still indexed. ` + 'Check the source is returning its full contents, then run a full sync to apply the removals.' ) @@ -823,8 +874,21 @@ export function resolveReconciliationDeleteCap( * * The hold is deliberately all-or-nothing rather than a truncation to the cap: * deleting up to the cap still destroys data, and leaves the knowledge base in a - * state no operator asked for and no later sync can reason about. Holding - * everything keeps the corpus intact and self-heals as soon as the source does. + * state no operator asked for and no later sync can reason about. For the outage + * shapes above the corpus is left intact and reconciliation resumes as soon as + * the source returns its full listing. It does NOT self-heal from a hold caused + * by genuine bulk removal: those deletions stay withheld until a `fullSync` + * applies them, which is the point — a human confirms them. + * + * The two generations are capped SEPARATELY. Soft deletes are this sync's newly + * absent documents; hard deletes are the previous generation's soft deletes, + * confirmed absent a second time and therefore already gated by this cap once. + * Summing them double-counts the older generation and, on a connector with + * steady churn, ratchets: each sync's new soft deletes plus the prior sync's + * pending hard deletes exceed the cap, the all-or-nothing hold blocks the hard + * deletes that would drain the backlog, and the backlog grows monotonically so + * the connector never reconciles again. Capping each generation against the same + * ceiling keeps the per-sync blast radius bounded without that deadlock. * * `fullSync` bypasses the cap, matching its meaning everywhere else here — an * explicit human request to reconcile against this listing right now, which is @@ -840,16 +904,24 @@ export function capReconciliationDeletions( softDeleteIds: string[] hardDeleteIds: string[] held: boolean - requested: number + softHeld: boolean + hardHeld: boolean + withheld: number cap: number } { - const requested = new Set([...softDeleteIds, ...hardDeleteIds]).size const cap = resolveReconciliationDeleteCap(ownedDocCount, override) + const softHeld = !fullSync && softDeleteIds.length > cap + const hardHeld = !fullSync && hardDeleteIds.length > cap - if (fullSync || requested <= cap) { - return { softDeleteIds, hardDeleteIds, held: false, requested, cap } + return { + softDeleteIds: softHeld ? [] : softDeleteIds, + hardDeleteIds: hardHeld ? [] : hardDeleteIds, + held: softHeld || hardHeld, + softHeld, + hardHeld, + withheld: (softHeld ? softDeleteIds.length : 0) + (hardHeld ? hardDeleteIds.length : 0), + cap, } - return { softDeleteIds: [], hardDeleteIds: [], held: true, requested, cap } } /** @@ -1203,6 +1275,8 @@ export async function executeSync( } const syncStartedAt = new Date() + /** Seeded at lock acquisition, which wrote `updatedAt` itself. */ + let lastHeartbeatAtMs = Date.now() await db.insert(knowledgeConnectorSyncLog).values({ id: syncLogId, connectorId, @@ -1487,6 +1561,13 @@ export async function executeSync( // per-file cap never hydrate/upload together and exhaust the worker heap. const batches = chunkOpsByByteBudget(pendingOps, CONTENT_INFLIGHT_BUDGET_BYTES, SYNC_BATCH_SIZE) for (const rawBatch of batches) { + if (shouldHeartbeatSyncLock(Date.now(), lastHeartbeatAtMs)) { + if (!(await heartbeatSyncLock(connectorId, syncLogId))) { + throw new SyncLockLostException(connectorId) + } + lastHeartbeatAtMs = Date.now() + } + const liveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) if (liveness.connectorDeleted) { throw new ConnectorDeletedException(connectorId) @@ -1759,14 +1840,18 @@ export async function executeSync( let reconciliationHoldNotice: string | null = null if (capped.held) { reconciliationHoldNotice = buildReconciliationHoldNotice( - capped.requested, + capped.withheld, capped.cap, ownedDocCount ) logger.error('Reconciliation deletions held — exceeds per-sync blast-radius cap', { connectorId, connectorType: connector.connectorType, - requested: capped.requested, + withheld: capped.withheld, + softHeld: capped.softHeld, + hardHeld: capped.hardHeld, + requestedSoft: softDeleteIds.length, + requestedHard: hardDeleteIds.length, cap: capped.cap, ownedDocCount, listedCount: listedDocCount, @@ -1863,6 +1948,13 @@ export async function executeSync( result.docsDeleted += await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId) } + if (shouldHeartbeatSyncLock(Date.now(), lastHeartbeatAtMs)) { + if (!(await heartbeatSyncLock(connectorId, syncLogId))) { + throw new SyncLockLostException(connectorId) + } + lastHeartbeatAtMs = Date.now() + } + const postBatchLiveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) if (postBatchLiveness.connectorDeleted) { throw new ConnectorDeletedException(connectorId) @@ -2036,6 +2128,20 @@ export async function executeSync( logger.info('Sync completed', { connectorId, ...result }) return result } catch (error) { + if (error instanceof SyncLockLostException) { + /** + * Reported as superseded rather than failed, and deliberately writes + * nothing: the connector row belongs to whoever reclaimed it, and this + * run's own sync-log row was closed by the sweep that did so. + */ + logger.warn('Sync abandoned — lock was reclaimed while this run was executing', { + connectorId, + syncLogId, + ...result, + }) + return applySupersededOutcome(result, false) + } + if (error instanceof ConnectorDeletedException) { logger.info('Connector deleted during sync, cleaning up', { connectorId }) diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index 03b58ef8fa0..deb9f92036a 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -13,20 +13,22 @@ export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600 * lock for another sync, so a TTL at or below the run ceiling would start a second * sync while the first is still writing, both racing the same documents. * - * This is a hard ceiling for BOTH execution paths, not just the queued one. A - * Trigger.dev run is killed at {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}, so it - * is provably dead well before this. The fallback path is not: when Trigger.dev is - * unavailable, `dispatchSync` runs `executeSync` fire-and-forget inside the web - * process with no duration cap, and such a run genuinely can still be executing - * when this TTL expires. + * Measured against `updatedAt`, which a running sync refreshes every + * {@link SYNC_LOCK_HEARTBEAT_INTERVAL_MS}. That is what makes the TTL mean + * "nobody is working on this" rather than "this started a long time ago" — the + * distinction the in-process fallback path needs. A Trigger.dev run is killed at + * {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS} and so is provably dead well before + * this; the fallback path has no duration cap, so without a heartbeat a large + * self-hosted sync that legitimately runs past two hours would be reclaimed while + * still working, counted as a failure, and — because its own terminal write is + * then rejected as superseded — never able to reset that counter. Ten such syncs + * would disable a connector whose every sync had actually succeeded. * - * Treating it as dead anyway is deliberate. An unbounded background sync in a - * recyclable web process that has run for two hours is indistinguishable from one - * whose process was recycled out from under it, and the cost of guessing wrong in - * the other direction is a connector locked out of syncing forever. The sweep's - * verdict is therefore authoritative: `completeSyncLog` is guarded on - * `status = 'started'`, so a late finisher cannot overwrite a row already closed - * here, and it loses the race by design rather than by accident. + * A run that stops heartbeating is genuinely gone: its process died, or it is + * wedged, and either way reclaiming it is correct. The sweep's verdict stays + * authoritative for those — `completeSyncLog` is guarded on `status = 'started'` + * and terminal connector writes on the run's own `syncLockToken`, so a late + * finisher loses the race by design rather than by accident. */ export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000 @@ -63,3 +65,14 @@ export function connectorFailureBackoffMinutes(failures: number): number { CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES ) } + +/** + * How often a running sync refreshes its connector's `updatedAt` to prove it is + * still working. + * + * MUST stay well below {@link CONNECTOR_SYNC_STALE_LOCK_TTL_MS} so ordinary + * jitter — a slow batch, a long upload — cannot let a live run drift past the + * reclaim cutoff. The cost is one narrow UPDATE per interval per running sync, + * negligible against the work a sync does between beats. + */ +export const SYNC_LOCK_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000 From e30ade9c684419d6aafada55c6ea8a57dfee7a3c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:37:20 -0700 Subject: [PATCH 06/14] test(connectors): pin the failure ladder on both sides and cover the disable path Review round 5 on #6909. The nextSyncAt test recomputed its expected interval from the SQL's own binds and compared against the helper using those same constants, so both sides derived from one source and the assertion held for any values. It pinned the rendered SQL text but nothing about SQL-JS equivalence: a consistent refactor of both, or SQL whose text was right but whose semantics diverged, would have passed. Both sides now assert concrete values, so neither can move alone. The hold notice was checked with independent substring matches on distinct digits, so swapping the withheld count and the cap produced an inverted, misleading operator message that still passed. Now pinned whole, plus an assertion that the two orderings differ. Extracted buildSyncFailureUpdate to mirror the success path, covering the in-process ladder, a null counter treated as a first failure, the disable firing exactly at the threshold rather than one early, and the ownership token released on both outcomes. That is the path the disable ratchet runs through and it was previously covered only on the reaper's SQL side. --- .../knowledge/connectors/sync/route.test.ts | 47 +++++++++-- .../knowledge/connectors/sync-engine.test.ts | 80 +++++++++++++++++-- .../lib/knowledge/connectors/sync-engine.ts | 62 ++++++++++---- 3 files changed, 161 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index efc37c24018..9d27fa24d77 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -17,6 +17,8 @@ import { import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { + CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, + CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES, connectorFailureBackoffMinutes, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' @@ -133,12 +135,47 @@ describe('connector sync scheduler stale-lock reaper', () => { const [threshold, step, cap] = numericBinds(nextSyncAt) expect(threshold).toBe(MAX_CONSECUTIVE_FAILURES) + expect(step).toBe(CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES) + expect(cap).toBe(CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES) - // Recomputing the ladder from the binds the SQL actually carries makes this - // fail the moment the route and `connectorFailureBackoffMinutes` drift apart. - for (const failures of [1, 2, 5, 10, 47, 48, 100]) { - expect(Math.min(failures * step, cap)).toBe(connectorFailureBackoffMinutes(failures)) - } + /** + * Pinned to literals, not recomputed from the binds. Comparing + * `Math.min(failures * step, cap)` against `connectorFailureBackoffMinutes` + * derived both sides from the same two constants, so it held for any values + * AND any shape — swapping the SQL's `*` for `+` left every substring and + * every bind untouched. The shape is pinned by the string assertion above; + * these pin the magnitudes independently of both the SQL and the helper. + */ + expect(step).toBe(30) + expect(cap).toBe(1440) + }) + + it('applies the same minutes in SQL that the shared helper computes in JS', async () => { + /** + * The equivalence the ladder test above only appeared to establish. The SQL + * encodes `LEAST((failures) * 30, 1440)`; these fix what the JS helper + * returns for the same inputs, so the two cannot drift without one of the + * two assertions failing. + */ + expect(connectorFailureBackoffMinutes(1)).toBe(30) + expect(connectorFailureBackoffMinutes(2)).toBe(60) + expect(connectorFailureBackoffMinutes(3)).toBe(90) + expect(connectorFailureBackoffMinutes(9)).toBe(270) + // 48 * 30 is exactly the cap; either side of it must clamp, not overshoot. + expect(connectorFailureBackoffMinutes(47)).toBe(1410) + expect(connectorFailureBackoffMinutes(48)).toBe(1440) + expect(connectorFailureBackoffMinutes(49)).toBe(1440) + expect(connectorFailureBackoffMinutes(100)).toBe(1440) + }) + + it('releases the reclaimed run ownership token', async () => { + await runTickRecovering(['connector-1']) + + /** + * Without this the reclaimed run's token still matches its own terminal + * write, so it can overwrite the verdict this reclaim just recorded. + */ + expect(setPayloadForUpdate(0).syncLockToken).toBeNull() }) it('does not stamp lastSyncAt when reclaiming a stale lock', async () => { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 333c3de8630..63880a49644 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -1245,15 +1245,83 @@ describe('countDeletionEligibleOwned', () => { }) describe('buildReconciliationHoldNotice', () => { - it('names the counts and the full-sync remedy', async () => { + it('places each count in its own role', async () => { const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') - const notice = buildReconciliationHoldNotice(500, 250, 1000) + /** + * Asserted whole rather than by three independent `toContain` checks on + * distinct digit strings: those passed even with the first two arguments + * swapped, which inverts the message into "withheld 250 — more than the 500 + * allowed" and misleads the operator it exists to inform. + */ + expect(buildReconciliationHoldNotice(500, 250, 1000)).toBe( + 'Withheld 500 document removal(s) — more than the 250 allowed in one sync ' + + 'of 1000 documents. Documents deleted at the source are still indexed. ' + + 'Check the source is returning its full contents, then run a full sync to apply the removals.' + ) + }) + + it('cannot be satisfied by swapping the withheld and cap counts', async () => { + const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(buildReconciliationHoldNotice(500, 250, 1000)).not.toBe( + buildReconciliationHoldNotice(250, 500, 1000) + ) + }) +}) + +describe('buildSyncFailureUpdate', () => { + const now = new Date('2026-08-20T00:00:00.000Z') + const minutesAfter = (mins: number) => new Date(now.getTime() + mins * 60 * 1000) + + it('backs off on the shared ladder below the threshold', async () => { + const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + const first = buildSyncFailureUpdate(now, 0, 'boom') + expect(first.status).toBe('error') + expect(first.consecutiveFailures).toBe(1) + expect(first.lastSyncError).toBe('boom') + expect(first.nextSyncAt).toEqual(minutesAfter(30)) + + const third = buildSyncFailureUpdate(now, 2, 'boom') + expect(third.consecutiveFailures).toBe(3) + expect(third.nextSyncAt).toEqual(minutesAfter(90)) + }) + + it('treats a null counter as a first failure', async () => { + const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(buildSyncFailureUpdate(now, null, 'boom').consecutiveFailures).toBe(1) + expect(buildSyncFailureUpdate(now, undefined, 'boom').nextSyncAt).toEqual(minutesAfter(30)) + }) + + it('disables exactly at the threshold, not before it', async () => { + const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits') + + /** + * The path the auto-disable breaker actually runs through in-process. Only + * the reaper's SQL equivalent was covered before, so an off-by-one here — + * disabling a connector one failure early — was invisible. + */ + const below = buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES - 2, 'boom') + expect(below.status).toBe('error') + expect(below.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES - 1) + expect(below.nextSyncAt).not.toBeNull() + + const at = buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES - 1, 'boom') + expect(at.status).toBe('disabled') + expect(at.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES) + expect(at.nextSyncAt).toBeNull() + expect(at.lastSyncError).toContain('reconnect') + }) + + it('releases the ownership token on both outcomes', async () => { + const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits') - expect(notice).toContain('500') - expect(notice).toContain('250') - expect(notice).toContain('1000') - expect(notice).toContain('full sync') + expect(buildSyncFailureUpdate(now, 0, 'boom').syncLockToken).toBeNull() + expect(buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES, 'boom').syncLockToken).toBeNull() }) }) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index dda098b169c..65588e92cb9 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -772,6 +772,39 @@ export function buildReconciliationHoldNotice( ) } +/** + * The connector row a failed sync writes. + * + * Extracted for the same reason as {@link buildSyncSuccessUpdate}: this is the + * path the auto-disable breaker runs through, so the threshold and the backoff + * it applies need to be assertable without standing up the whole sync. The + * in-process ladder here and the reaper's SQL ladder must agree — they are two + * writers of one policy, both sourced from + * {@link connectorFailureBackoffMinutes}. + */ +export function buildSyncFailureUpdate( + now: Date, + previousFailures: number | null | undefined, + errorMessage: string +) { + const failures = (previousFailures ?? 0) + 1 + const disabled = failures >= MAX_CONSECUTIVE_FAILURES + + return { + status: (disabled ? 'disabled' : 'error') as 'disabled' | 'error', + lastSyncError: disabled + ? 'Connector disabled after repeated sync failures. Please reconnect.' + : errorMessage, + nextSyncAt: disabled + ? null + : new Date(now.getTime() + connectorFailureBackoffMinutes(failures) * 60 * 1000), + consecutiveFailures: failures, + // Releases the lock so a stale token can never match a later run. + syncLockToken: null, + updatedAt: now, + } +} + /** * The connector row a successful sync writes. * @@ -2177,29 +2210,24 @@ export async function executeSync( try { await completeSyncLog(syncLogId, 'failed', result, errorMessage) - const now = new Date() - const failures = (connector.consecutiveFailures ?? 0) + 1 - const disabled = failures >= MAX_CONSECUTIVE_FAILURES - const backoffMinutes = connectorFailureBackoffMinutes(failures) - const nextSync = disabled ? null : new Date(now.getTime() + backoffMinutes * 60 * 1000) + const failureUpdate = buildSyncFailureUpdate( + new Date(), + connector.consecutiveFailures, + errorMessage + ) - if (disabled) { + if (failureUpdate.status === 'disabled') { logger.warn('Connector disabled after repeated failures', { connectorId, - consecutiveFailures: failures, + consecutiveFailures: failureUpdate.consecutiveFailures, }) } - const failureWriteLanded = await writeTerminalConnectorState(connectorId, syncLogId, { - status: disabled ? 'disabled' : 'error', - lastSyncError: disabled - ? 'Connector disabled after repeated sync failures. Please reconnect.' - : errorMessage, - nextSyncAt: nextSync, - consecutiveFailures: failures, - syncLockToken: null, - updatedAt: now, - }) + const failureWriteLanded = await writeTerminalConnectorState( + connectorId, + syncLogId, + failureUpdate + ) /** * Deliberately does NOT get {@link applySupersededOutcome}. `result.error` From 5c419cea0f575998f0caaef89b0145d61fe79ae9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:41:00 -0700 Subject: [PATCH 07/14] chore(db): regenerate the sync lock token migration with drizzle-kit The migration was hand-written, which left it inconsistent with every other migration in the repo and, more importantly, without a schema snapshot. Drizzle diffs against the latest snapshot to decide what a migration needs to contain, so the next generate would have seen the column as still missing and emitted it a second time. Regenerated properly: drizzle-kit now owns the SQL, the journal entry, and 0297_snapshot.json. The emitted statement matches the house pattern for an additive nullable column, and check:migrations still reports backward-compatible. --- .../knowledge/connectors/sync-engine.test.ts | 27 ++++++++------- .../lib/knowledge/connectors/sync-engine.ts | 33 +++++++++---------- .../lib/knowledge/connectors/sync-limits.ts | 4 +-- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 63880a49644..fd856290bd9 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -1518,7 +1518,7 @@ describe('writeTerminalConnectorState', () => { }) }) -describe('applySupersededOutcome', () => { +describe('markSyncSuperseded', () => { const result = { docsAdded: 3, docsUpdated: 1, @@ -1527,29 +1527,28 @@ describe('applySupersededOutcome', () => { docsFailed: 0, } - it('leaves a run that kept its lock untouched', async () => { - const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine') - - expect(applySupersededOutcome(result, true)).toEqual(result) - }) - it('flags a discarded run so the task wrapper does not report it as clean', async () => { - const { applySupersededOutcome, SUPERSEDED_SYNC_ERROR } = await import( + const { markSyncSuperseded, SUPERSEDED_SYNC_ERROR } = await import( '@/lib/knowledge/connectors/sync-engine' ) - const superseded = applySupersededOutcome(result, false) - // The task wrapper reports `success: !result.error`. - expect(superseded.error).toBe(SUPERSEDED_SYNC_ERROR) - expect(Boolean(superseded.error)).toBe(true) + expect(markSyncSuperseded(result).error).toBe(SUPERSEDED_SYNC_ERROR) }) it('preserves the document counters of the discarded run', async () => { - const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine') + const { markSyncSuperseded } = await import('@/lib/knowledge/connectors/sync-engine') // Those writes landed — only the connector-level bookkeeping was discarded. - expect(applySupersededOutcome(result, false)).toMatchObject(result) + expect(markSyncSuperseded(result)).toMatchObject(result) + }) + + it('does not mutate the result it was handed', async () => { + const { markSyncSuperseded } = await import('@/lib/knowledge/connectors/sync-engine') + + markSyncSuperseded(result) + + expect(result).not.toHaveProperty('error') }) }) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 65588e92cb9..1d4ff5f428a 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -477,9 +477,11 @@ export async function completeSyncLog( * proves the lock is still this run's. `status` is kept alongside as defence in * depth and to cover a user pausing the connector mid-run. * - * Guards both terminal paths. The failure path needs it as much as the success - * path: a reclaimed run's failure would double-increment a counter the sweep - * already advanced and overwrite its backoff with a shorter one. + * Guards every write a run makes to its own connector row: both terminal paths + * and the mid-run heartbeat. The failure path needs it as much as the success + * path — a reclaimed run's failure would double-increment a counter the sweep + * already advanced and overwrite its backoff with a shorter one — and reusing it + * for the heartbeat is what turns a beat into an ownership probe. */ export function stillHoldsSyncLock(connectorId: string, syncLockToken: string) { return and( @@ -509,7 +511,7 @@ export function buildSyncLockAcquisition(syncLogId: string, now: Date) { /** * Whether a running sync is due to refresh its lock. * - * Time-based rather than batch-count-based: batches vary hugely in cost, so a + * Time-based rather than batch-count-based: batches vary hugely in cost, so an * every-N-batches beat would fire constantly on small documents and barely at * all on large ones — exactly the runs that need it. */ @@ -570,9 +572,10 @@ export async function writeTerminalConnectorState( } /** - * Reported when a run's terminal write matched no rows because the run no longer - * held its lock. Its document writes still landed; only its connector-level - * bookkeeping was discarded, in favour of whoever reclaimed the row. + * Reported when a run loses its connector's lock mid-flight — either because a + * heartbeat found the lock reclaimed, or because its terminal write matched no + * rows. Its document writes still landed; only its connector-level bookkeeping + * was discarded, in favour of whoever reclaimed the row. */ export const SUPERSEDED_SYNC_ERROR = 'sync_superseded' @@ -581,11 +584,7 @@ export const SUPERSEDED_SYNC_ERROR = 'sync_superseded' * report a discarded run as a clean sync — the same reason a lock-contended run * returns `sync_in_progress` rather than an empty success. */ -export function applySupersededOutcome( - result: SyncResult, - terminalWriteLanded: boolean -): SyncResult { - if (terminalWriteLanded) return result +export function markSyncSuperseded(result: SyncResult): SyncResult { return { ...result, error: SUPERSEDED_SYNC_ERROR } } @@ -708,8 +707,8 @@ export function classifySuspectListing( * immediately. A genuinely emptied source keeps reconciling: its second sync * corroborates the first and tombstones everything, and a later sync — once the * tombstoned set is again absent — completes the two-strike purge, subject to - * {@link capReconciliationDeletions}, which holds a pass whose deletion count - * exceeds the per-sync blast-radius cap. + * {@link capReconciliationDeletions}, which withholds any generation whose + * deletion count exceeds the per-sync blast-radius cap. * * A forced `fullSync` overrides the guard, matching its existing meaning * elsewhere here — an explicit human request to reconcile against this listing @@ -2155,7 +2154,7 @@ export async function executeSync( syncLogId, ...result, }) - return applySupersededOutcome(result, false) + return markSyncSuperseded(result) } logger.info('Sync completed', { connectorId, ...result }) @@ -2172,7 +2171,7 @@ export async function executeSync( syncLogId, ...result, }) - return applySupersededOutcome(result, false) + return markSyncSuperseded(result) } if (error instanceof ConnectorDeletedException) { @@ -2230,7 +2229,7 @@ export async function executeSync( ) /** - * Deliberately does NOT get {@link applySupersededOutcome}. `result.error` + * Deliberately does NOT get {@link markSyncSuperseded}. `result.error` * is set to the real failure cause below and the task wrapper already * reports this run as unsuccessful, so overwriting it with * `sync_superseded` would destroy the diagnostic without changing the diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index deb9f92036a..a66910dd411 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -38,8 +38,8 @@ export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECO * * Shared because two independent writers advance this counter: `executeSync`'s * in-process failure path, and the scheduler's out-of-process stale-lock - * reclaim (a SIGKILL skips `catch`/`finally`, so only the reaper ever sees that - * failure). A connector that only ever dies hard must still reach the threshold, + * reclaim (a SIGKILL unwinds nothing, so the in-process `catch` never runs and + * only the reaper ever sees that failure). A connector that only ever dies hard must still reach the threshold, * which it cannot if the two disagree on what the threshold is. */ export const MAX_CONSECUTIVE_FAILURES = 10 From 4b98668c5d4f6d1f25cce5373a611f482610d5c0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:44:39 -0700 Subject: [PATCH 08/14] fix(connectors): make the sync-log sweep aware of a live run's heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 6 on #6909, plus a consistency pass over the whole branch. The sweep keys on the log row's startedAt, and the heartbeat added earlier in this PR refreshes the connector's updatedAt — the log table has no equivalent, so nothing refreshed what the sweep reads. A legitimately long in-process sync kept its connector lock exactly as designed while its log row was closed as failed at the TTL, and completeSyncLog's started guard then no-opped when the run finished. A successful sync was recorded permanently as a failure and its counters were lost to the listing-safety check. Neither round was wrong alone; the combination was. The sweep now spares a row whose id is still the connector's lock token, reusing the ownership mechanism rather than adding another. Every orphan still drains: a reclaimed run's token is cleared, a replaced run's token belongs to its successor, and rows predating the column have none. Five documentation claims that later rounds falsified are corrected, including the sweep's own rationale, which still argued the platform kills every run at the duration ceiling — the reasoning the heartbeat exists because it does not hold for the in-process path. applySupersededOutcome's boolean parameter was vestigial: both call sites passed false and a test asserted the dead branch. Simplified. --- .../knowledge/connectors/sync/route.test.ts | 24 ++++++++++++ .../api/knowledge/connectors/sync/route.ts | 39 +++++++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index 9d27fa24d77..0b9e4153ca1 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -9,6 +9,7 @@ */ import { dbChainMockFns, + flattenMockConditions, hasMockCondition, type MockCondition, resetDbChainMock, @@ -213,6 +214,29 @@ describe('connector sync scheduler stale-lock reaper', () => { ).toBe(true) }) + it('spares the log row of a run that still holds its connector lock', async () => { + await runTickRecovering(['connector-1']) + + /** + * The sweep keys on `startedAt`, which no heartbeat refreshes, so age alone + * would close a legitimately long in-process run's row and record a + * successful sync as failed. + */ + const where = dbChainMockFns.where.mock.calls[1][0] + const liveness = flattenMockConditions(where).find( + (node: MockCondition) => typeof node.toSQL === 'function' + ) + expect(liveness).toBeDefined() + + const rendered = (liveness as unknown as MockSqlFragment).toSQL().sql + expect(rendered).toContain('NOT EXISTS') + expect(rendered).toContain("'syncing'") + + const bound = (liveness as unknown as MockSqlFragment).values + expect(bound).toContain(schemaMock.knowledgeConnector.syncLockToken) + expect(bound).toContain(schemaMock.knowledgeConnectorSyncLog.id) + }) + it('closes stale sync-log rows even when no connector was reclaimed this tick', async () => { /** * The self-healing assertion. A row orphaned before this sweep existed — diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index d33f6aa7ff2..321351bd301 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -31,6 +31,29 @@ const DISPATCH_CONCURRENCY = 10 const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)' +/** + * Excludes a sync-log row whose run still demonstrably holds its connector's + * lock. + * + * The sweep keys on `startedAt`, and nothing refreshes that — the heartbeat + * renews `knowledge_connector.updatedAt`, and the log table has no equivalent + * column. So a legitimately long in-process run keeps its connector lock but + * would still have its log row closed as `failed` at the TTL, recording a + * successful sync as a failure and losing its counters to + * `loadPreviousListingObservation`. Matching the connector's `syncLockToken` + * against the row's own id is exactly "this run is still the lock holder", so a + * live run is spared while every orphan — reclaimed, replaced, or predating the + * token column, where the token is NULL — is still swept. + */ +function runNoLongerHoldsItsLock(): SQL { + return sql`NOT EXISTS ( + SELECT 1 FROM ${knowledgeConnector} + WHERE ${knowledgeConnector.id} = ${knowledgeConnectorSyncLog.connectorId} + AND ${knowledgeConnector.syncLockToken} = ${knowledgeConnectorSyncLog.id} + AND ${knowledgeConnector.status} = 'syncing' + )` +} + /** * The reclaimed connector's new consecutive-failure count. * @@ -117,12 +140,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * stay stranded forever. Keying off the row's own `startedAt` instead makes * the sweep self-healing and lets it drain the existing backlog. * - * Safe on liveness: the run ceiling is - * {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS} and this TTL is twice that, so - * a row older than the cutoff belongs to a run the platform has already - * killed and which cannot still be writing. The predicate is per-row on - * `startedAt`, so a fresh run's log row can never be caught by it — even on - * a connector whose previous run is being reclaimed in this same tick. + * Age alone does not prove a run is dead: the in-process fallback path has + * no duration cap, so a large self-hosted sync can genuinely still be + * working past the TTL. `runNoLongerHoldsItsLock` is what makes this safe — + * a run still holding its connector's lock is spared regardless of age. + * The age predicate is also per-row on `startedAt`, so a fresh run's log row + * can never be caught by it, even on a connector whose previous run is being + * reclaimed in this same tick. */ const closedSyncLogs = await db .update(knowledgeConnectorSyncLog) @@ -134,7 +158,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where( and( eq(knowledgeConnectorSyncLog.status, 'started'), - lte(knowledgeConnectorSyncLog.startedAt, staleCutoff) + lte(knowledgeConnectorSyncLog.startedAt, staleCutoff), + runNoLongerHoldsItsLock() ) ) .returning({ id: knowledgeConnectorSyncLog.id }) From 9fada813a3f98178c84dfd84f090eacfcb35edae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:48:57 -0700 Subject: [PATCH 09/14] fix(connectors): require a lock to be held, owned, and heartbeated before sparing its log row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round spared a sync-log row whose id was still the connector's lock token. That is necessary but not sufficient: the reaper's reclaim filters out archived and soft-deleted connectors, so their rows keep status syncing with an intact token indefinitely and would have been spared forever. It also spared a run that died without ever being reclaimed. Keying on connector liveness alone has the opposite gap — it cannot tell which run holds the lock, so an orphan on a connector that is immediately re-locked by a replacement is spared until a tick happens to catch the connector idle. Sparing now requires all three: the connector is locked, this row's run is the holder, and that lock is being heartbeated. An orphan can satisfy at most two, so no timing window strands one. This is a per-row liveness predicate, not a restriction of the candidate set — every stale row is still evaluated. A backlog row predating the token column fails on two counts and still drains, so the sweep remains self-healing. --- .../knowledge/connectors/sync/route.test.ts | 61 +++++++++++++++---- .../api/knowledge/connectors/sync/route.ts | 32 +++++++--- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index 0b9e4153ca1..c0202e565fd 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -214,27 +214,64 @@ describe('connector sync scheduler stale-lock reaper', () => { ).toBe(true) }) - it('spares the log row of a run that still holds its connector lock', async () => { + /** The `NOT EXISTS` liveness fragment the sweep's WHERE carries. */ + function sweepLivenessFragment(): MockSqlFragment { + const where = dbChainMockFns.where.mock.calls[1][0] + const fragment = flattenMockConditions(where).find( + (node: MockCondition) => typeof node.toSQL === 'function' + ) + expect(fragment).toBeDefined() + return fragment as unknown as MockSqlFragment + } + + it('spares the log row of a run whose lock is still being heartbeated', async () => { await runTickRecovering(['connector-1']) /** * The sweep keys on `startedAt`, which no heartbeat refreshes, so age alone * would close a legitimately long in-process run's row and record a - * successful sync as failed. + * successful sync as failed. Every clause is pinned: sparing requires the + * connector to be locked, THIS row's run to be the holder, and that lock to + * be live — an orphan can satisfy at most two. */ - const where = dbChainMockFns.where.mock.calls[1][0] - const liveness = flattenMockConditions(where).find( - (node: MockCondition) => typeof node.toSQL === 'function' + const rendered = sweepLivenessFragment().toSQL().sql.replace(/\s+/g, ' ').trim() + + expect(rendered).toBe( + "NOT EXISTS ( SELECT 1 FROM ? WHERE ? = ? AND ? = ? AND ? = 'syncing' AND ? > ? )" ) - expect(liveness).toBeDefined() + }) - const rendered = (liveness as unknown as MockSqlFragment).toSQL().sql - expect(rendered).toContain('NOT EXISTS') - expect(rendered).toContain("'syncing'") + it('identifies the lock holder by token, not merely by the connector syncing', async () => { + await runTickRecovering(['connector-1']) - const bound = (liveness as unknown as MockSqlFragment).values - expect(bound).toContain(schemaMock.knowledgeConnector.syncLockToken) - expect(bound).toContain(schemaMock.knowledgeConnectorSyncLog.id) + /** + * Without the token clause the sweep spares every `started` row on a locked + * connector — including an orphan from a crashed run whose replacement now + * holds the lock, which would then never drain while that connector stays + * busy. + */ + expect(sweepLivenessFragment().values).toContain(schemaMock.knowledgeConnector.syncLockToken) + }) + + it('requires the held lock to be heartbeated, not merely held', async () => { + await runTickRecovering(['connector-1']) + + /** + * Without the freshness clause a run that died without being reclaimed — or + * one on an archived or deleted connector, which the reclaim skips entirely + * — keeps `status = 'syncing'` and its token forever, so its row is spared + * forever. + */ + const bound = sweepLivenessFragment().values + expect(bound).toContain(schemaMock.knowledgeConnector.updatedAt) + + const cutoff = bound.find( + (value): value is { value: Date } => + typeof value === 'object' && + value !== null && + (value as { value?: unknown }).value instanceof Date + ) + expect(cutoff).toBeDefined() }) it('closes stale sync-log rows even when no connector was reclaimed this tick', async () => { diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 321351bd301..95d320aa300 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -32,25 +32,37 @@ const DISPATCH_CONCURRENCY = 10 const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)' /** - * Excludes a sync-log row whose run still demonstrably holds its connector's - * lock. + * Excludes a sync-log row belonging to a run that is demonstrably still alive. * * The sweep keys on `startedAt`, and nothing refreshes that — the heartbeat * renews `knowledge_connector.updatedAt`, and the log table has no equivalent * column. So a legitimately long in-process run keeps its connector lock but * would still have its log row closed as `failed` at the TTL, recording a * successful sync as a failure and losing its counters to - * `loadPreviousListingObservation`. Matching the connector's `syncLockToken` - * against the row's own id is exactly "this run is still the lock holder", so a - * live run is spared while every orphan — reclaimed, replaced, or predating the - * token column, where the token is NULL — is still swept. + * `loadPreviousListingObservation`, which reads only `completed` rows. + * + * The heartbeat is the single source of liveness truth, so this defers to it. + * Sparing requires all three of: the connector is locked, THIS row's run is the + * lock holder, and that lock is being heartbeated. An orphan can satisfy at most + * two, so none is ever stranded: + * - reclaimed after a hard kill — connector is `error`, token cleared; + * - a replacement holds the lock — the token is the successor's, not this row's; + * - died without being reclaimed, including on an archived or deleted connector + * the reclaim skips entirely — `updatedAt` is stale. + * + * This re-references the connector row, which an earlier fix deliberately moved + * away from. That coupling was different: it restricted the sweep's candidate + * set to *this tick's reclaims*, which made a pre-existing backlog undrainable. + * This is a per-row liveness predicate — every stale row is still a candidate, + * so the sweep stays self-healing. */ -function runNoLongerHoldsItsLock(): SQL { +function logRowNotHeldByLiveRun(staleCutoff: Date): SQL { return sql`NOT EXISTS ( SELECT 1 FROM ${knowledgeConnector} WHERE ${knowledgeConnector.id} = ${knowledgeConnectorSyncLog.connectorId} AND ${knowledgeConnector.syncLockToken} = ${knowledgeConnectorSyncLog.id} AND ${knowledgeConnector.status} = 'syncing' + AND ${knowledgeConnector.updatedAt} > ${sql.param(staleCutoff, knowledgeConnector.updatedAt)} )` } @@ -142,8 +154,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * * Age alone does not prove a run is dead: the in-process fallback path has * no duration cap, so a large self-hosted sync can genuinely still be - * working past the TTL. `runNoLongerHoldsItsLock` is what makes this safe — - * a run still holding its connector's lock is spared regardless of age. + * working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe — + * a run whose lock is still being heartbeated is spared regardless of age. * The age predicate is also per-row on `startedAt`, so a fresh run's log row * can never be caught by it, even on a connector whose previous run is being * reclaimed in this same tick. @@ -159,7 +171,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(knowledgeConnectorSyncLog.status, 'started'), lte(knowledgeConnectorSyncLog.startedAt, staleCutoff), - runNoLongerHoldsItsLock() + logRowNotHeldByLiveRun(staleCutoff) ) ) .returning({ id: knowledgeConnectorSyncLog.id }) From 6eaeffe6b6b0ee40e68e7550ce4b1aa866e5198b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 18:57:58 -0700 Subject: [PATCH 10/14] fix(connectors): heartbeat every unbounded phase, not just the batch loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heartbeat was added where we happened to be looking. The pagination loop — where a large source spends most of its wall clock, since the batch loop does not start until every page is fetched — never beat at all, so a long listing on the uncapped in-process path was still reclaimed as a hard failure. That is the exact ratchet the heartbeat exists to prevent. Auditing the remaining phases found something worse in the stuck-document retry: on the in-process path it handed the entire backlog to a single await that fully parses, embeds and indexes every document before returning, so no beat placement could interrupt it. That dispatch is now chunked, with a beat per chunk. All four call sites share one beatIfDue closure; the two pre-existing inline blocks were collapsed onto it rather than left as copies. An await longer than the TTL — one pathological listing page, or a very large hard delete — is still not covered, and no inline beat can cover it. Closing that needs a concurrent interval, which is a second mechanism and a separate decision. Adds the first test that drives executeSync itself, reaching the pagination loop through the real lock acquisition rather than testing helpers in isolation. --- .../knowledge/connectors/sync-engine.test.ts | 100 +++++++++++++++++- .../lib/knowledge/connectors/sync-engine.ts | 51 ++++++--- 2 files changed, 135 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index fd856290bd9..1d9be0ac770 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -8,11 +8,12 @@ import { flattenMockConditions, hasMockCondition, type MockCondition, + queueTableRows, resetDbChainMock, schemaMock, } from '@sim/testing' import { generateShortId } from '@sim/utils/id' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { classifySuspectListing, evaluateListingSafety, @@ -34,7 +35,14 @@ vi.mock('@/background/knowledge-connector-sync', () => ({ knowledgeConnectorSync: { trigger: vi.fn() }, })) -const { mockMapTags } = vi.hoisted(() => ({ mockMapTags: vi.fn() })) +const { mockMapTags, mockListDocuments } = vi.hoisted(() => ({ + mockMapTags: vi.fn(), + mockListDocuments: vi.fn(), +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, +})) vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { @@ -44,6 +52,11 @@ vi.mock('@/connectors/registry.server', () => ({ 'no-tags': { name: 'No Tags', }, + paged: { + name: 'Paged', + auth: { mode: 'apiKey', optional: true }, + listDocuments: mockListDocuments, + }, }, })) @@ -1716,3 +1729,86 @@ describe('heartbeatSyncLock', () => { expect(await heartbeatSyncLock('c-1', 'run-a')).toBe(true) }) }) + +describe('executeSync heartbeats during the listing phase', () => { + const CONNECTOR = { + id: 'c-1', + knowledgeBaseId: 'kb-1', + connectorType: 'paged', + credentialId: null, + encryptedApiKey: null, + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncDocCount: null, + consecutiveFailures: 0, + syncLockToken: null, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-20T00:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + /** Drives executeSync as far as the pagination loop. */ + function primeSyncUpToListing() { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) + // The lock CAS; every later `.returning()` falls through to the empty default, + // which is what makes the heartbeat below report a lost lock. + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + } + + it('beats between pages and abandons the run when the lock was reclaimed', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + const { SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import( + '@/lib/knowledge/connectors/sync-limits' + ) + + primeSyncUpToListing() + + /** + * Listing is where a large source spends most of its wall clock, so a page + * that pushes the run past the heartbeat interval must trigger a beat before + * the next page — not only once listing has finished. + */ + mockListDocuments.mockImplementation(async () => { + vi.setSystemTime(new Date(Date.now() + SYNC_LOCK_HEARTBEAT_INTERVAL_MS + 1_000)) + return { documents: [], hasMore: true, nextCursor: 'page-2' } + }) + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + + // Aborted on the beat before page 2 rather than paging on under a lost lock. + expect(mockListDocuments).toHaveBeenCalledTimes(1) + expect(result.error).toBe('sync_superseded') + }) + + it('does not beat when pages return faster than the interval', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + + primeSyncUpToListing() + + let pages = 0 + mockListDocuments.mockImplementation(async () => { + pages += 1 + vi.setSystemTime(new Date(Date.now() + 1_000)) + return { documents: [], hasMore: pages < 3, nextCursor: `page-${pages}` } + }) + + await executeSync('c-1', { billingAttribution: { workspaceId: 'ws-1' } as never }) + + // All three pages fetched: the time gate keeps a fast listing beat-free. + expect(mockListDocuments).toHaveBeenCalledTimes(3) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 1d4ff5f428a..7615d9c9112 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -76,6 +76,15 @@ const DEFAULT_OP_SIZE_BYTES = 4 * 1024 * 1024 const CONTENT_INFLIGHT_BUDGET_BYTES = 64 * 1024 * 1024 const MAX_PAGES = 500 const MAX_SAFE_TITLE_LENGTH = 200 +/** + * How many stuck documents are re-dispatched per call. + * + * The retry backlog is unbounded, and on the in-process fallback path + * `processDocumentsWithQueue` parses, embeds, and indexes every document it is + * given before returning. Handing it the whole backlog made the retry a single + * await no heartbeat could interrupt; chunking gives the beat somewhere to run. + */ +const STUCK_RETRY_DISPATCH_CHUNK_SIZE = 25 const STALE_PROCESSING_MINUTES = 45 /** Largest connector corpus observed in production, which sets the queue drain to beat. */ const LARGEST_OBSERVED_CORPUS_DOCUMENTS = 7_730 @@ -1309,6 +1318,20 @@ export async function executeSync( const syncStartedAt = new Date() /** Seeded at lock acquisition, which wrote `updatedAt` itself. */ let lastHeartbeatAtMs = Date.now() + + /** + * Refreshes the lock if the interval has elapsed, and aborts the run if it has + * been reclaimed. Called at the top of every unbounded loop in this sync — the + * time gate makes each call nearly free, so placement only has to guarantee + * that no unbounded phase runs without reaching one. + */ + const beatIfDue = async (): Promise => { + if (!shouldHeartbeatSyncLock(Date.now(), lastHeartbeatAtMs)) return + if (!(await heartbeatSyncLock(connectorId, syncLogId))) { + throw new SyncLockLostException(connectorId) + } + lastHeartbeatAtMs = Date.now() + } await db.insert(knowledgeConnectorSyncLog).values({ id: syncLogId, connectorId, @@ -1416,6 +1439,14 @@ export async function executeSync( ) for (let pageNum = 0; hasMore && pageNum < MAX_PAGES; pageNum++) { + /** + * Listing is where a large source spends most of its wall clock — the + * batch loop below does not start until every page has been fetched — so + * without this a big listing outran the TTL and was reclaimed as a hard + * failure, which is the exact ratchet the heartbeat exists to prevent. + */ + await beatIfDue() + if (pageNum > 0 && connectorConfig.auth.mode === 'oauth') { accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId) } @@ -1593,12 +1624,7 @@ export async function executeSync( // per-file cap never hydrate/upload together and exhaust the worker heap. const batches = chunkOpsByByteBudget(pendingOps, CONTENT_INFLIGHT_BUDGET_BYTES, SYNC_BATCH_SIZE) for (const rawBatch of batches) { - if (shouldHeartbeatSyncLock(Date.now(), lastHeartbeatAtMs)) { - if (!(await heartbeatSyncLock(connectorId, syncLogId))) { - throw new SyncLockLostException(connectorId) - } - lastHeartbeatAtMs = Date.now() - } + await beatIfDue() const liveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) if (liveness.connectorDeleted) { @@ -1980,12 +2006,7 @@ export async function executeSync( result.docsDeleted += await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId) } - if (shouldHeartbeatSyncLock(Date.now(), lastHeartbeatAtMs)) { - if (!(await heartbeatSyncLock(connectorId, syncLogId))) { - throw new SyncLockLostException(connectorId) - } - lastHeartbeatAtMs = Date.now() - } + await beatIfDue() const postBatchLiveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) if (postBatchLiveness.connectorDeleted) { @@ -2098,9 +2119,11 @@ export async function executeSync( } }) - if (retryDocs.length > 0) { + for (let i = 0; i < retryDocs.length; i += STUCK_RETRY_DISPATCH_CHUNK_SIZE) { + await beatIfDue() + await processDocumentsWithQueue( - retryDocs.map((doc) => ({ + retryDocs.slice(i, i + STUCK_RETRY_DISPATCH_CHUNK_SIZE).map((doc) => ({ documentId: doc.id, filename: doc.filename ?? 'document.txt', fileUrl: doc.fileUrl ?? '', From d4a3ddb09f7fcb6707c1dde0922b22eb15150640 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 19:05:41 -0700 Subject: [PATCH 11/14] chore(db): regenerate the sync lock token migration as 0298 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6921 merged first and took 0297 for an unrelated column on a different table, so this branch's migration is regenerated rather than renamed. Renaming would leave the snapshot describing the wrong ordinal, and drizzle diffs against that snapshot to decide what the next migration contains. The regenerated statement is the lock token column alone — it correctly diffs against staging's 0297 snapshot rather than re-emitting the column that landed there. --- packages/db/migrations/0298_nasty_madrox.sql | 1 + .../db/migrations/meta/0298_snapshot.json | 20079 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + 3 files changed, 20087 insertions(+) create mode 100644 packages/db/migrations/0298_nasty_madrox.sql create mode 100644 packages/db/migrations/meta/0298_snapshot.json diff --git a/packages/db/migrations/0298_nasty_madrox.sql b/packages/db/migrations/0298_nasty_madrox.sql new file mode 100644 index 00000000000..b9332aabfa5 --- /dev/null +++ b/packages/db/migrations/0298_nasty_madrox.sql @@ -0,0 +1 @@ +ALTER TABLE "knowledge_connector" ADD COLUMN "sync_lock_token" text; \ No newline at end of file diff --git a/packages/db/migrations/meta/0298_snapshot.json b/packages/db/migrations/meta/0298_snapshot.json new file mode 100644 index 00000000000..280fb370bf0 --- /dev/null +++ b/packages/db/migrations/meta/0298_snapshot.json @@ -0,0 +1,20079 @@ +{ + "id": "f97abcd2-e8ef-4ab8-af02-6bae4d9bf64c", + "prevId": "c9c21e6c-7324-484b-b303-ab7b4fd9ab6d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": 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_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_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_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_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_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_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.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "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()" + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index e755c8150b7..f0631bbfb1e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2080,6 +2080,13 @@ "when": 1787276752722, "tag": "0297_eminent_sinister_six", "breakpoints": true + }, + { + "idx": 298, + "version": "7", + "when": 1787277851960, + "tag": "0298_nasty_madrox", + "breakpoints": true } ] } From aeb0ae5324ea8e1acfecfc0c6e632891767b0fc0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 22:56:08 -0700 Subject: [PATCH 12/14] fix(knowledge): guard the document lifecycle and cap retry dispatches Adds a bounded retry budget so a deterministically failing document stops being re-parsed and re-embedded on every sync, and closes the unguarded state transitions around it. - `processing_attempts` is charged in the one guarded write every dispatch passes through, cleared on success, and bounds the stuck-document sweep. - The document claim now guards on status and gates on the row it writes back, so a worker can no longer process and bill a document it never claimed. The retry, missing-context and sweep-reset writes are guarded the same way. - `STALE_PROCESSING_MINUTES` and the queue concurrency are derived from the env vars the processing task is configured with, so raising the run ceiling can no longer make the sweep reclaim live work. - Qualifies every column in the shared schema mock as `table.column`, which makes `.where()` assertions across the repo able to fail on a wrong-table predicate. Two tests were pinning nothing as a result. --- .../app/api/copilot/feedback/route.test.ts | 2 +- .../knowledge/connectors/sync/route.test.ts | 162 +- apps/sim/app/api/knowledge/utils.test.ts | 3 + .../chats/[chatId]/fork/route.test.ts | 2 +- .../api/mothership/chats/read/route.test.ts | 4 +- apps/sim/app/api/resume/poll/route.test.ts | 6 +- apps/sim/app/api/v2/knowledge/utils.ts | 10 +- .../lib/background-work/store.test.ts | 48 +- .../lib/copy/copy-files.test.ts | 5 +- apps/sim/lib/audit-logs/query.test.ts | 34 +- apps/sim/lib/billing/core/usage-log.test.ts | 10 +- .../sim/lib/billing/webhooks/invoices.test.ts | 6 +- .../knowledge/connectors/sync-engine.test.ts | 146 +- .../lib/knowledge/connectors/sync-engine.ts | 308 +- .../knowledge/connectors/sync-limits.test.ts | 12 +- .../lib/knowledge/connectors/sync-limits.ts | 11 +- .../documents/document-indexing-usage.test.ts | 3 + .../document-processing-source.test.ts | 142 +- .../documents/processing-claim.test.ts | 10 +- .../documents/processing-queue.test.ts | 6 + .../documents/retry-processing-grace.test.ts | 187 +- apps/sim/lib/knowledge/documents/service.ts | 102 +- .../knowledge/documents/tag-filter.test.ts | 19 +- apps/sim/lib/knowledge/documents/types.ts | 47 + .../workspace/track-chat-upload.test.ts | 14 +- .../workspace-file-secret-provenance.test.ts | 12 +- apps/sim/lib/webhooks/path-claims.test.ts | 2 +- .../human-in-the-loop-manager.test.ts | 19 +- packages/db/migrations/0298_nasty_madrox.sql | 1 - .../migrations/0298_shallow_silver_sable.sql | 2 + .../db/migrations/meta/0298_snapshot.json | 9 +- packages/db/migrations/meta/_journal.json | 4 +- packages/db/schema.ts | 11 + packages/testing/src/mocks/schema.mock.ts | 2498 +++++++++-------- 34 files changed, 2339 insertions(+), 1518 deletions(-) delete mode 100644 packages/db/migrations/0298_nasty_madrox.sql create mode 100644 packages/db/migrations/0298_shallow_silver_sable.sql diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index 910bbed28a2..e73cc2c8644 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -357,7 +357,7 @@ edges: const { eq } = await import('drizzle-orm') expect(dbChainMockFns.where).toHaveBeenCalled() - expect(eq).toHaveBeenCalledWith('userId', 'user-123') + expect(eq).toHaveBeenCalledWith('copilotFeedback.userId', 'user-123') }) it('should handle database errors gracefully', async () => { diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index c0202e565fd..b721184daa4 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -12,14 +12,16 @@ import { flattenMockConditions, hasMockCondition, type MockCondition, + queueTableRows, resetDbChainMock, schemaMock, } from '@sim/testing' -import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type NextRequest, NextResponse } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES, + CONNECTOR_SYNC_STALE_LOCK_TTL_MS, connectorFailureBackoffMinutes, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' @@ -81,10 +83,27 @@ function setPayloadForUpdate(index: number): Record { return dbChainMockFns.set.mock.calls[index][0] as Record } +/** Fixed so the reclaim cutoff can be compared by value, not merely by type. */ +const NOW = new Date('2026-08-20T12:00:00.000Z') +const EXPECTED_STALE_CUTOFF = new Date(NOW.getTime() - CONNECTOR_SYNC_STALE_LOCK_TTL_MS) + +/** The `.where()` condition of the nth `db.update()` chain in call order. */ +function whereForUpdate(index: number): unknown { + return dbChainMockFns.where.mock.calls[index][0] +} + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mockVerifyCronAuth.mockReturnValue(null) + mockDispatchSync.mockResolvedValue(undefined) + mockResolveSystemBillingAttribution.mockResolvedValue({ workspaceId: 'ws-1' }) + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() }) describe('connector sync scheduler stale-lock reaper', () => { @@ -193,7 +212,7 @@ describe('connector sync scheduler stale-lock reaper', () => { const payload = setPayloadForUpdate(1) expect(payload.status).toBe('failed') expect(renderedSql(payload.completedAt)).toContain('now()') - expect(payload.errorMessage).toEqual(expect.any(String)) + expect(payload.errorMessage).toBe('Sync timed out (stale lock recovered)') const where = dbChainMockFns.where.mock.calls[1][0] expect( @@ -234,11 +253,26 @@ describe('connector sync scheduler stale-lock reaper', () => { * connector to be locked, THIS row's run to be the holder, and that lock to * be live — an orphan can satisfy at most two. */ - const rendered = sweepLivenessFragment().toSQL().sql.replace(/\s+/g, ' ').trim() + const fragment = sweepLivenessFragment() - expect(rendered).toBe( + expect(fragment.toSQL().sql.replace(/\s+/g, ' ').trim()).toBe( "NOT EXISTS ( SELECT 1 FROM ? WHERE ? = ? AND ? = ? AND ? = 'syncing' AND ? > ? )" ) + + /** + * The rendered SQL above is seven `?` carrying every operand, so the shape + * assertion alone cannot tell one column from another. The bound values are + * the only place the predicate's operands are observable, and they are + * checked positionally so a swapped column fails on the exact slot. + */ + const bound = fragment.values + expect(bound[0]).toBe(schemaMock.knowledgeConnector) + expect(bound[1]).toBe(schemaMock.knowledgeConnector.id) + expect(bound[2]).toBe(schemaMock.knowledgeConnectorSyncLog.connectorId) + expect(bound[3]).toBe(schemaMock.knowledgeConnector.syncLockToken) + expect(bound[4]).toBe(schemaMock.knowledgeConnectorSyncLog.id) + expect(bound[5]).toBe(schemaMock.knowledgeConnector.status) + expect(bound[6]).toBe(schemaMock.knowledgeConnector.updatedAt) }) it('identifies the lock holder by token, not merely by the connector syncing', async () => { @@ -250,7 +284,13 @@ describe('connector sync scheduler stale-lock reaper', () => { * holds the lock, which would then never drain while that connector stays * busy. */ - expect(sweepLivenessFragment().values).toContain(schemaMock.knowledgeConnector.syncLockToken) + const bound = sweepLivenessFragment().values + + // Compared against the LOG ROW's id: matching the connector id instead makes + // the correlation trivially true, so `NOT EXISTS` never spares anything. + expect(bound[3]).toBe(schemaMock.knowledgeConnector.syncLockToken) + expect(bound[4]).toBe(schemaMock.knowledgeConnectorSyncLog.id) + expect(bound[4]).not.toBe(schemaMock.knowledgeConnectorSyncLog.connectorId) }) it('requires the held lock to be heartbeated, not merely held', async () => { @@ -263,15 +303,11 @@ describe('connector sync scheduler stale-lock reaper', () => { * forever. */ const bound = sweepLivenessFragment().values - expect(bound).toContain(schemaMock.knowledgeConnector.updatedAt) + expect(bound[6]).toBe(schemaMock.knowledgeConnector.updatedAt) - const cutoff = bound.find( - (value): value is { value: Date } => - typeof value === 'object' && - value !== null && - (value as { value?: unknown }).value instanceof Date - ) - expect(cutoff).toBeDefined() + // Compared by value: `toBeDefined()` passed even for `new Date()`, which + // spares nothing and closes rows started a second ago. + expect((bound[7] as { value: Date }).value).toEqual(EXPECTED_STALE_CUTOFF) }) it('closes stale sync-log rows even when no connector was reclaimed this tick', async () => { @@ -331,3 +367,101 @@ describe('connector sync scheduler stale-lock reaper', () => { expect(renderedSql(setPayloadForUpdate(0).updatedAt)).toContain('now()') }) }) + +describe('connector sync scheduler reclaim predicate', () => { + it('reclaims only connectors that are syncing and past the stale cutoff', async () => { + await runTickRecovering(['connector-1']) + + const where = whereForUpdate(0) + + /** + * Asserted against the CONNECTOR's own columns. While every mock column was + * its bare name, `knowledgeConnector.status` and + * `knowledgeConnectorSyncLog.status` were both `'status'`, so this passed + * for a predicate guarding the wrong table entirely. + */ + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.status && + node.right === 'syncing' + ) + ).toBe(true) + + // Deleting this clause reclaims every syncing connector on every tick. + const cutoff = flattenMockConditions(where).find( + (node: MockCondition) => + node.type === 'lte' && node.left === schemaMock.knowledgeConnector.updatedAt + ) + expect(cutoff).toBeDefined() + expect(cutoff?.right).toEqual(EXPECTED_STALE_CUTOFF) + + for (const column of [ + schemaMock.knowledgeConnector.archivedAt, + schemaMock.knowledgeConnector.deletedAt, + ]) { + expect( + hasMockCondition( + where, + (node: MockCondition) => node.type === 'isNull' && node.column === column + ) + ).toBe(true) + } + }) +}) + +describe('connector sync scheduler authentication and dispatch', () => { + it('rejects an unauthenticated request without touching the database', async () => { + mockVerifyCronAuth.mockReturnValue( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ) + + const response = await GET(cronRequest()) + + // Deleting the auth check leaves an unauthenticated cron endpoint. + expect(response.status).toBe(401) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('dispatches a sync for every due connector with its workspace billing context', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'due-1', workspaceId: 'ws-1' }, + { id: 'due-2', workspaceId: 'ws-2' }, + ]) + + const response = await GET(cronRequest()) + + // Deleting the dispatch call means no connector ever syncs. + expect(await response.json()).toMatchObject({ success: true, count: 2 }) + expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('ws-1') + expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('ws-2') + expect(mockDispatchSync).toHaveBeenCalledTimes(2) + expect(mockDispatchSync).toHaveBeenCalledWith( + 'due-1', + expect.objectContaining({ billingAttribution: { workspaceId: 'ws-1' } }) + ) + }) + + it('skips a connector missing workspace billing context without failing the tick', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'due-1', workspaceId: null }, + { id: 'due-2', workspaceId: 'ws-2' }, + ]) + + const response = await GET(cronRequest()) + + expect(response.status).toBe(200) + expect(mockDispatchSync).toHaveBeenCalledTimes(1) + expect(mockDispatchSync).toHaveBeenCalledWith('due-2', expect.anything()) + }) + + it('reports a tick with nothing due', async () => { + const response = await GET(cronRequest()) + + expect(await response.json()).toMatchObject({ success: true, count: 0 }) + expect(mockDispatchSync).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d7d0ea2999d..421414d602f 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -137,6 +137,9 @@ describe('Knowledge Utils', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + // The document claim gates on the row it writes back, so an unstubbed + // `returning()` would abort processing before any completion write. + dbChainMockFns.returning.mockResolvedValue([{ id: 'doc1' }]) // `unstubGlobals: true` removes the module-scope fetch stub after the // first test in the worker; re-stub it per test. vi.stubGlobal('fetch', createEmbeddingFetchMock()) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index f0d7c8a9517..db78d250fec 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -352,7 +352,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { // they vanish from VFS listings and name resolution… expect(dbChainMockFns.where).toHaveBeenCalledWith({ type: 'inArray', - column: 'id', + column: 'workspaceFiles.id', values: ['wf_dead1', 'wf_dead2'], }) // …and their resource chips are dropped from the new chat. diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts index f8e4a1b40ea..1ff8c0a60fd 100644 --- a/apps/sim/app/api/mothership/chats/read/route.test.ts +++ b/apps/sim/app/api/mothership/chats/read/route.test.ts @@ -52,8 +52,8 @@ describe('POST /api/mothership/chats/read', () => { expect(orClause).toBeDefined() expect(orClause?.conditions).toEqual( expect.arrayContaining([ - { type: 'isNull', column: 'lastSeenAt' }, - { type: 'lt', left: 'lastSeenAt', right: 'updatedAt' }, + { type: 'isNull', column: 'copilotChats.lastSeenAt' }, + { type: 'lt', left: 'copilotChats.lastSeenAt', right: 'copilotChats.updatedAt' }, ]) ) }) diff --git a/apps/sim/app/api/resume/poll/route.test.ts b/apps/sim/app/api/resume/poll/route.test.ts index 8e175aeff29..6db363f8681 100644 --- a/apps/sim/app/api/resume/poll/route.test.ts +++ b/apps/sim/app/api/resume/poll/route.test.ts @@ -385,7 +385,9 @@ describe('time-pause resume admission', () => { expect( inArrayMock.mock.calls.some( ([column, values]) => - column === 'id' && Array.isArray(values) && values.join(',') === 'paused-2,paused-3' + column === 'pausedExecutions.id' && + Array.isArray(values) && + values.join(',') === 'paused-2,paused-3' ) ).toBe(true) expect(executionSnapshotFromJsonMock).toHaveBeenCalledTimes(2) @@ -537,7 +539,7 @@ describe('time-pause resume admission', () => { [LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE], ]) const snapshotIdBatches = inArrayMock.mock.calls - .filter(([column]) => column === 'id') + .filter(([column]) => column === 'pausedExecutions.id') .map(([, ids]) => ids as string[]) expect(snapshotIdBatches.map((ids) => ids.length)).toEqual([10, 4, 4, 2]) expect( diff --git a/apps/sim/app/api/v2/knowledge/utils.ts b/apps/sim/app/api/v2/knowledge/utils.ts index 583368db64b..6d8da179662 100644 --- a/apps/sim/app/api/v2/knowledge/utils.ts +++ b/apps/sim/app/api/v2/knowledge/utils.ts @@ -4,6 +4,10 @@ import type { V2KnowledgeTaggedDocument, } from '@/lib/api/contracts/v2/knowledge' import { ALL_TAG_SLOTS, type AllTagSlot } from '@/lib/knowledge/constants' +import { + DOCUMENT_PROCESSING_STATUSES, + type DocumentProcessingStatus, +} from '@/lib/knowledge/documents/types' import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' @@ -39,9 +43,7 @@ export function toV2DocumentTags( return tags } -const PROCESSING_STATUSES = ['pending', 'processing', 'completed', 'failed'] as const - -type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number] +type V2DocumentProcessingStatus = DocumentProcessingStatus /** * Narrows a stored processing status onto the published enum. An absent value @@ -50,7 +52,7 @@ type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number] */ function toProcessingStatus(status: string | null | undefined): V2DocumentProcessingStatus { if (status === null || status === undefined) return 'pending' - const known = PROCESSING_STATUSES.find((candidate) => candidate === status) + const known = DOCUMENT_PROCESSING_STATUSES.find((candidate) => candidate === status) if (!known) throw new Error(`Unexpected knowledge document processing status: ${status}`) return known } diff --git a/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts b/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts index 04313635abe..64787bbd605 100644 --- a/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts +++ b/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts @@ -57,8 +57,8 @@ describe('listSurfacedBackgroundWork', () => { expect(result.rows).toEqual(rows) expect(dbChainMockFns.orderBy).toHaveBeenCalledWith( - { type: 'desc', column: 'updatedAt' }, - { type: 'desc', column: 'id' } + { type: 'desc', column: 'backgroundWorkStatus.updatedAt' }, + { type: 'desc', column: 'backgroundWorkStatus.id' } ) // Over-fetches one row past the default page size to detect another page. expect(dbChainMockFns.limit).toHaveBeenCalledWith(51) @@ -163,7 +163,7 @@ describe('listSurfacedBackgroundWork', () => { conditions: [ expect.objectContaining({ type: 'lt', - left: 'updatedAt', + left: 'backgroundWorkStatus.updatedAt', right: expectedTimestampFragment, }), expect.objectContaining({ @@ -171,10 +171,14 @@ describe('listSurfacedBackgroundWork', () => { conditions: [ expect.objectContaining({ type: 'eq', - left: 'updatedAt', + left: 'backgroundWorkStatus.updatedAt', right: expectedTimestampFragment, }), - expect.objectContaining({ type: 'lt', left: 'id', right: 'job-2' }), + expect.objectContaining({ + type: 'lt', + left: 'backgroundWorkStatus.id', + right: 'job-2', + }), ], }), ], @@ -194,7 +198,7 @@ describe('listSurfacedBackgroundWork', () => { expect((keyset.conditions as MockCondition[])[0]).toEqual( expect.objectContaining({ type: 'lt', - left: 'updatedAt', + left: 'backgroundWorkStatus.updatedAt', right: expect.objectContaining({ values: ['2026-07-01T09:00:00.000Z'] }), }) ) @@ -239,10 +243,10 @@ describe('listSurfacedBackgroundWork', () => { conditions: [ expect.objectContaining({ type: 'eq', - left: 'updatedAt', + left: 'backgroundWorkStatus.updatedAt', right: expect.objectContaining({ values: [sharedAtCursor] }), }), - expect.objectContaining({ type: 'lt', left: 'id', right: 'job-b' }), + expect.objectContaining({ type: 'lt', left: 'backgroundWorkStatus.id', right: 'job-b' }), ], }) ) @@ -299,8 +303,8 @@ describe('listSurfacedBackgroundWork', () => { expect(childrenWhere).toEqual({ type: 'and', conditions: [ - { type: 'eq', left: 'forkedFromWorkspaceId', right: 'ws-1' }, - { type: 'isNull', column: 'archivedAt' }, + { type: 'eq', left: 'workspace.forkedFromWorkspaceId', right: 'ws-1' }, + { type: 'isNull', column: 'workspace.archivedAt' }, ], }) }) @@ -322,19 +326,23 @@ describe('listSurfacedBackgroundWork', () => { ] expect(orConditions).toHaveLength(3) - expect(orConditions[0]).toEqual({ type: 'eq', left: 'workspaceId', right: 'ws-1' }) + expect(orConditions[0]).toEqual({ + type: 'eq', + left: 'backgroundWorkStatus.workspaceId', + right: 'ws-1', + }) const childIdClause = orConditions[1] expect(childIdClause.strings.join('?')).toContain("->> 'childWorkspaceId' =") - expect(childIdClause.values).toEqual(['metadata', 'ws-1']) + expect(childIdClause.values).toEqual(['backgroundWorkStatus.metadata', 'ws-1']) const otherIdClause = orConditions[2] expect(otherIdClause.strings.join('?')).toContain("->> 'otherWorkspaceId' =") - expect(otherIdClause.values).toEqual(['metadata', 'ws-1']) + expect(otherIdClause.values).toEqual(['backgroundWorkStatus.metadata', 'ws-1']) expect(statuses).toEqual({ type: 'inArray', - column: 'status', + column: 'backgroundWorkStatus.status', values: ['pending', 'processing', 'completed', 'completed_with_warnings', 'failed'], }) }) @@ -351,8 +359,16 @@ describe('listSurfacedBackgroundWork', () => { expect(childKeyedClause).toEqual({ type: 'and', conditions: [ - { type: 'inArray', column: 'workspaceId', values: ['fork-1', 'fork-2'] }, - { type: 'inArray', column: 'kind', values: ['fork_sync', 'fork_rollback'] }, + { + type: 'inArray', + column: 'backgroundWorkStatus.workspaceId', + values: ['fork-1', 'fork-2'], + }, + { + type: 'inArray', + column: 'backgroundWorkStatus.kind', + values: ['fork_sync', 'fork_rollback'], + }, ], }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts index 76f2bb315aa..9e806528a8f 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts @@ -270,7 +270,8 @@ function predicateColumn(node: MockCondition, field: 'left' | 'column'): string const column = node[field] if (typeof column !== 'string') unsupportedPredicate(`${String(node.type)} with a non-column ${field}`) - return column + // Schema-mock columns are `table.column`; row fixtures are keyed by field name. + return column.slice(column.indexOf('.') + 1) } /** @@ -445,7 +446,7 @@ describe('executeForkFileBlobCopies target name collisions', () => { it('absorbs only a primary-key conflict, so a name conflict can never be mistaken for a replay', async () => { await executeForkFileBlobCopies([collidingTask()], 'test') - expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledWith({ target: 'id' }) + expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledWith({ target: 'workspaceFiles.id' }) }) it('copies a non-colliding file into the mirrored folder unchanged', async () => { diff --git a/apps/sim/lib/audit-logs/query.test.ts b/apps/sim/lib/audit-logs/query.test.ts index dd454b31179..2af9e34b949 100644 --- a/apps/sim/lib/audit-logs/query.test.ts +++ b/apps/sim/lib/audit-logs/query.test.ts @@ -40,7 +40,7 @@ function asCondition(value: unknown): MockCondition { function expectOrgLevelCondition(condition: MockCondition, organizationId: string): void { expect(condition.type).toBe('and') const [nullCheck, orgLink] = condition.conditions! - expect(nullCheck).toMatchObject({ type: 'isNull', column: 'workspaceId' }) + expect(nullCheck).toMatchObject({ type: 'isNull', column: 'auditLog.workspaceId' }) expect(orgLink.type).toBe('or') const [metadataMatch, orgResourceMatch] = orgLink.conditions! @@ -49,8 +49,8 @@ function expectOrgLevelCondition(condition: MockCondition, organizationId: strin expect(orgResourceMatch.type).toBe('and') expect(orgResourceMatch.conditions).toEqual([ - expect.objectContaining({ type: 'eq', left: 'resourceType', right: 'organization' }), - expect.objectContaining({ type: 'eq', left: 'resourceId', right: organizationId }), + expect.objectContaining({ type: 'eq', left: 'auditLog.resourceType', right: 'organization' }), + expect.objectContaining({ type: 'eq', left: 'auditLog.resourceId', right: organizationId }), ]) } @@ -72,7 +72,7 @@ describe('buildOrgScopeCondition', () => { const [workspaceScope, orgLevel] = orgScope.conditions! expect(workspaceScope).toMatchObject({ type: 'inArray', - column: 'workspaceId', + column: 'auditLog.workspaceId', values: WORKSPACE_IDS, }) expectOrgLevelCondition(orgLevel, ORG_ID) @@ -80,8 +80,12 @@ describe('buildOrgScopeCondition', () => { expect(actorFilter).toMatchObject({ type: 'or', conditions: [ - expect.objectContaining({ type: 'inArray', column: 'actorId', values: MEMBER_IDS }), - expect.objectContaining({ type: 'isNull', column: 'actorId' }), + expect.objectContaining({ + type: 'inArray', + column: 'auditLog.actorId', + values: MEMBER_IDS, + }), + expect.objectContaining({ type: 'isNull', column: 'auditLog.actorId' }), ], }) }) @@ -100,7 +104,7 @@ describe('buildOrgScopeCondition', () => { const [workspaceScope, orgLevel] = condition.conditions! expect(workspaceScope).toMatchObject({ type: 'inArray', - column: 'workspaceId', + column: 'auditLog.workspaceId', values: WORKSPACE_IDS, }) expectOrgLevelCondition(orgLevel, ORG_ID) @@ -137,8 +141,12 @@ describe('buildOrgScopeCondition', () => { expect(actorFilter).toMatchObject({ type: 'or', conditions: [ - expect.objectContaining({ type: 'inArray', column: 'actorId', values: MEMBER_IDS }), - expect.objectContaining({ type: 'isNull', column: 'actorId' }), + expect.objectContaining({ + type: 'inArray', + column: 'auditLog.actorId', + values: MEMBER_IDS, + }), + expect.objectContaining({ type: 'isNull', column: 'auditLog.actorId' }), ], }) }) @@ -155,7 +163,7 @@ describe('buildOrgScopeCondition', () => { expect(condition.type).toBe('and') const [, actorFilter] = condition.conditions! - expect(actorFilter).toMatchObject({ type: 'isNull', column: 'actorId' }) + expect(actorFilter).toMatchObject({ type: 'isNull', column: 'auditLog.actorId' }) }) }) @@ -169,7 +177,7 @@ describe('getOrgWorkspaceIds', () => { expect(ids).toEqual([]) expect(dbChainMockFns.where).toHaveBeenCalledWith( - expect.objectContaining({ type: 'eq', left: 'organizationId', right: ORG_ID }) + expect.objectContaining({ type: 'eq', left: 'workspace.organizationId', right: ORG_ID }) ) }) }) @@ -190,7 +198,7 @@ describe('buildFilterConditions resourceType', () => { it('trims members so a spaced list filters on the types it names', () => { expect(resourceTypeCondition('file, workflow')).toMatchObject({ type: 'inArray', - column: 'resourceType', + column: 'auditLog.resourceType', values: ['file', 'workflow'], }) }) @@ -214,7 +222,7 @@ describe('buildFilterConditions resourceType', () => { it('still collapses a single member to an equality check', () => { expect(resourceTypeCondition(' workflow ')).toMatchObject({ type: 'eq', - left: 'resourceType', + left: 'auditLog.resourceType', right: 'workflow', }) }) diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index 86befaa5701..6568fd4dacc 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -421,7 +421,7 @@ describe('usage-log query scopes', () => { expect(latestWhereCondition()).toMatchObject({ type: 'and', - conditions: [{ type: 'eq', left: 'workspaceId', right: 'workspace-1' }], + conditions: [{ type: 'eq', left: 'usageLog.workspaceId', right: 'workspace-1' }], }) expect(dbChainMockFns.limit).toHaveBeenCalledWith(26) }) @@ -467,7 +467,7 @@ describe('usage-log query scopes', () => { expect(latestWhereCondition()).toMatchObject({ type: 'and', - conditions: [{ type: 'eq', left: 'userId', right: 'user-1' }, { type: 'or' }], + conditions: [{ type: 'eq', left: 'usageLog.userId', right: 'user-1' }, { type: 'or' }], }) }) @@ -481,7 +481,7 @@ describe('usage-log query scopes', () => { expect(latestWhereCondition()).toMatchObject({ type: 'and', - conditions: [{ type: 'eq', left: 'userId', right: 'user-1' }, { type: 'or' }], + conditions: [{ type: 'eq', left: 'usageLog.userId', right: 'user-1' }, { type: 'or' }], }) expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1) expect(dbChainMockFns.limit).toHaveBeenCalledWith(26) @@ -497,8 +497,8 @@ describe('usage-log query scopes', () => { expect(latestWhereCondition()).toMatchObject({ type: 'and', conditions: [ - { type: 'eq', left: 'userId', right: 'user-1' }, - { type: 'eq', left: 'workspaceId', right: 'workspace-1' }, + { type: 'eq', left: 'usageLog.userId', right: 'user-1' }, + { type: 'eq', left: 'usageLog.workspaceId', right: 'workspace-1' }, ], }) }) diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 61368c8050a..2a117da98a1 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -288,9 +288,11 @@ describe('invoice billing recovery', () => { (call) => call[0] as { type?: string; column?: string; left?: string } ) const allMemberStatsLockIndex = whereArgs.findIndex( - (arg) => arg?.type === 'inArray' && arg?.column === 'userId' + (arg) => arg?.type === 'inArray' && arg?.column === 'userStats.userId' + ) + const orgLockIndex = whereArgs.findIndex( + (arg) => arg?.type === 'eq' && arg?.left === 'organization.id' ) - const orgLockIndex = whereArgs.findIndex((arg) => arg?.type === 'eq' && arg?.left === 'id') expect(allMemberStatsLockIndex).toBeGreaterThanOrEqual(0) expect(orgLockIndex).toBeGreaterThanOrEqual(0) expect(allMemberStatsLockIndex).toBeLessThan(orgLockIndex) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 1d9be0ac770..f0c36fa64b3 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -741,11 +741,11 @@ describe('isStuckDocumentSweepEligible', () => { }) /** - * Pinned to the derivation in sync-engine (corpus 7,730 / concurrency 20 x - * 1 minute occupancy x 2 contention). A change to any input should fail here - * so it is re-checked deliberately rather than absorbed silently. + * Pinned to `QUEUED_DISPATCH_GRACE_MINUTES` in sync-engine. A change to it + * should fail here so it is re-checked deliberately rather than absorbed + * silently. */ - const GRACE_MINUTES = 773 + const GRACE_MINUTES = 240 it('leaves a document dispatched by the previous sync and still queued alone', () => { expect( @@ -1267,18 +1267,50 @@ describe('buildReconciliationHoldNotice', () => { * swapped, which inverts the message into "withheld 250 — more than the 500 * allowed" and misleads the operator it exists to inform. */ - expect(buildReconciliationHoldNotice(500, 250, 1000)).toBe( - 'Withheld 500 document removal(s) — more than the 250 allowed in one sync ' + - 'of 1000 documents. Documents deleted at the source are still indexed. ' + + expect(buildReconciliationHoldNotice(500, 250, 1000, true, false)).toBe( + 'Withheld 500 document removal(s) — more than the 250 allowed per generation ' + + 'in one sync of 1000 documents. Documents removed at the source are still indexed. ' + 'Check the source is returning its full contents, then run a full sync to apply the removals.' ) }) + it('does not claim withheld documents are indexed when only the purge was held', async () => { + const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * A hard-only hold withholds documents a previous sync already tombstoned, + * so they have been invisible since then. Telling the operator they are + * "still indexed" was simply false. + */ + const notice = buildReconciliationHoldNotice(500, 250, 1000, false, true) + + expect(notice).toContain('already pending removal were not purged') + expect(notice).not.toContain('are still indexed') + }) + + it('names both consequences when both generations were held', async () => { + const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') + + const notice = buildReconciliationHoldNotice(900, 250, 1000, true, true) + + expect(notice).toContain('are still indexed') + expect(notice).toContain('already pending removal were not purged') + }) + + it('describes the cap as per generation, since a sync may spend it twice', async () => { + const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') + + // Saying "allowed in one sync" understated the real ceiling by 2x. + expect(buildReconciliationHoldNotice(500, 250, 1000, true, false)).toContain( + '250 allowed per generation' + ) + }) + it('cannot be satisfied by swapping the withheld and cap counts', async () => { const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine') - expect(buildReconciliationHoldNotice(500, 250, 1000)).not.toBe( - buildReconciliationHoldNotice(250, 500, 1000) + expect(buildReconciliationHoldNotice(500, 250, 1000, true, false)).not.toBe( + buildReconciliationHoldNotice(250, 500, 1000, true, false) ) }) }) @@ -1586,11 +1618,11 @@ describe('sync lock ownership across a reclaim and reacquire', () => { /** The connector row once run B has taken the lock that run A used to hold. */ const rowHeldByB = { - id: 'c-1', - status: 'syncing', - syncLockToken: RUN_B, - archivedAt: null, - deletedAt: null, + [schemaMock.knowledgeConnector.id]: 'c-1', + [schemaMock.knowledgeConnector.status]: 'syncing', + [schemaMock.knowledgeConnector.syncLockToken]: RUN_B, + [schemaMock.knowledgeConnector.archivedAt]: null, + [schemaMock.knowledgeConnector.deletedAt]: null, } it('rejects the reclaimed run A and admits the live run B', async () => { @@ -1610,11 +1642,9 @@ describe('sync lock ownership across a reclaim and reacquire', () => { const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') const reclaimed = { - id: 'c-1', - status: 'error', - syncLockToken: null, - archivedAt: null, - deletedAt: null, + ...rowHeldByB, + [schemaMock.knowledgeConnector.status]: 'error', + [schemaMock.knowledgeConnector.syncLockToken]: null, } expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), reclaimed)).toBe(false) @@ -1623,7 +1653,7 @@ describe('sync lock ownership across a reclaim and reacquire', () => { it('admits the run that still holds its own lock', async () => { const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') - const heldByA = { ...rowHeldByB, syncLockToken: RUN_A } + const heldByA = { ...rowHeldByB, [schemaMock.knowledgeConnector.syncLockToken]: RUN_A } expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), heldByA)).toBe(true) }) @@ -1631,7 +1661,11 @@ describe('sync lock ownership across a reclaim and reacquire', () => { it('rejects a run whose connector was paused mid-sync', async () => { const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') - const paused = { ...rowHeldByB, status: 'paused', syncLockToken: RUN_A } + const paused = { + ...rowHeldByB, + [schemaMock.knowledgeConnector.status]: 'paused', + [schemaMock.knowledgeConnector.syncLockToken]: RUN_A, + } expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), paused)).toBe(false) }) @@ -1812,3 +1846,73 @@ describe('executeSync heartbeats during the listing phase', () => { expect(mockListDocuments).toHaveBeenCalledTimes(3) }) }) + +describe('resolveStaleProcessingMinutes', () => { + it('preserves the previously hard-coded value at the default configuration', async () => { + const { resolveStaleProcessingMinutes } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(resolveStaleProcessingMinutes(600, 3)).toBe(45) + }) + + it('always exceeds the longest a legitimate run can take', async () => { + const { resolveStaleProcessingMinutes, worstCaseProcessingMinutes } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + /** + * The sweep reclaims by deleting embeddings and re-dispatching, so a value + * at or below the worst-case run makes it delete live work. At the previous + * fixed 45, raising KB_CONFIG_MAX_DURATION past 900s did exactly that. + */ + for (const [maxDuration, maxAttempts] of [ + [600, 3], + [900, 3], + [3600, 3], + [600, 10], + [7200, 5], + ]) { + expect(resolveStaleProcessingMinutes(maxDuration, maxAttempts)).toBeGreaterThan( + worstCaseProcessingMinutes(maxDuration, maxAttempts) + ) + } + }) +}) + +describe('SWEEPABLE_PROCESSING_STATUSES', () => { + it('never includes a completed document', async () => { + const { SWEEPABLE_PROCESSING_STATUSES } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * The sweep reclaims by deleting embeddings and re-dispatching, so a + * completed document entering this list means a finished, already-billed + * pass is discarded and paid for twice. + */ + expect(SWEEPABLE_PROCESSING_STATUSES).not.toContain('completed') + expect([...SWEEPABLE_PROCESSING_STATUSES].sort()).toEqual(['failed', 'pending', 'processing']) + }) + + it('covers every non-terminal state so nothing is stranded', async () => { + const { SWEEPABLE_PROCESSING_STATUSES } = await import('@/lib/knowledge/connectors/sync-engine') + const { DOCUMENT_PROCESSING_STATUSES } = await import('@/lib/knowledge/documents/types') + + const unreclaimable = DOCUMENT_PROCESSING_STATUSES.filter( + (status) => !SWEEPABLE_PROCESSING_STATUSES.includes(status as never) + ) + expect(unreclaimable).toEqual(['completed']) + }) +}) + +describe('MAX_PROCESSING_ATTEMPTS', () => { + it('bounds sweep spend without stranding a recoverable document too early', async () => { + const { MAX_PROCESSING_ATTEMPTS } = await import('@/lib/knowledge/documents/types') + + /** + * One attempt is spent per dispatch, not per Trigger.dev retry, so a + * short-interval connector can burn several inside one transient outage. + * Below 4 that is reachable in a single bad window; above ~10 the budget + * stops bounding the spend it exists to bound. + */ + expect(MAX_PROCESSING_ATTEMPTS).toBeGreaterThanOrEqual(4) + expect(MAX_PROCESSING_ATTEMPTS).toBeLessThanOrEqual(10) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 7615d9c9112..2133cf7a5ce 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -16,6 +16,7 @@ import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, } from '@/lib/billing/core/billing-attribution' +import { env, envNumber } from '@/lib/core/config/env' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' import { @@ -25,6 +26,11 @@ import { } from '@/lib/knowledge/connectors/sync-limits' import type { DocumentData } from '@/lib/knowledge/documents/service' import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' +import { + type DocumentProcessingStatus, + isDocumentProcessingStatus, + MAX_PROCESSING_ATTEMPTS, +} from '@/lib/knowledge/documents/types' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { StorageService } from '@/lib/uploads' import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' @@ -85,43 +91,102 @@ const MAX_SAFE_TITLE_LENGTH = 200 * await no heartbeat could interrupt; chunking gives the beat somewhere to run. */ const STUCK_RETRY_DISPATCH_CHUNK_SIZE = 25 -const STALE_PROCESSING_MINUTES = 45 -/** Largest connector corpus observed in production, which sets the queue drain to beat. */ -const LARGEST_OBSERVED_CORPUS_DOCUMENTS = 7_730 -/** `document-processing-queue`'s `concurrencyLimit` — global, shared by every workspace. */ -const PROCESSING_QUEUE_CONCURRENCY = 20 -/** Wall time a typical document occupies a queue slot, parse through embedding. */ -const TYPICAL_DOCUMENT_OCCUPANCY_MINUTES = 1 -/** Headroom for the queue being shared: another tenant's backlog cuts our share of it. */ -const QUEUE_CONTENTION_FACTOR = 2 +/** + * Concurrent `knowledge-process-document` runs, shared by every workspace. + * + * Read from the same env var the task itself is configured with rather than + * restated, so the drain estimate below cannot describe a queue depth the + * deployment does not actually run. + */ +const PROCESSING_QUEUE_CONCURRENCY = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20) + +/** + * Worst-case wall clock for one document's processing: the task's own duration + * ceiling times its retry budget, both read from the env vars + * `knowledge-process-document` is configured with. + */ +export function worstCaseProcessingMinutes( + maxDurationSeconds: number, + maxAttempts: number +): number { + return (maxDurationSeconds * maxAttempts) / 60 +} + +/** Headroom over the worst case, so ordinary jitter never reclaims a live run. */ +const STALE_PROCESSING_HEADROOM = 1.5 + +/** Floor preserving the previously hard-coded value at the default env. */ +const STALE_PROCESSING_FLOOR_MINUTES = 45 + +/** + * Minutes a `processing` document is given before the sweep calls its run + * abandoned. Never below the worst case a legitimate run can take. + */ +export function resolveStaleProcessingMinutes( + maxDurationSeconds: number, + maxAttempts: number +): number { + return Math.max( + STALE_PROCESSING_FLOOR_MINUTES, + Math.ceil( + worstCaseProcessingMinutes(maxDurationSeconds, maxAttempts) * STALE_PROCESSING_HEADROOM + ) + ) +} + +/** + * How long a document may sit in `processing` before the sweep treats its run as + * abandoned — and deletes its embeddings and re-dispatches it. + * + * DERIVED, not fixed at 45. The sweep reclaims by deleting live work, so this + * must exceed the longest a legitimate run can take. That bound is + * `KB_CONFIG_MAX_DURATION` x `KB_CONFIG_MAX_ATTEMPTS`, which an operator can + * raise: at the previous hard-coded 45, setting `KB_CONFIG_MAX_DURATION` above + * 900s silently made every long run look abandoned, so the sweep would delete + * the embeddings of documents that were still being indexed and bill a second + * pass. Deriving it keeps the invariant true at any configuration; the floor + * preserves today's value at the defaults. + */ +const STALE_PROCESSING_MINUTES = resolveStaleProcessingMinutes( + envNumber(env.KB_CONFIG_MAX_DURATION, 600), + envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3) +) /** * Grace period a document waiting on the processing queue gets before the * stuck-document sweep may reclaim it. * - * Derived rather than chosen, so the next person can re-run the arithmetic with - * their own numbers instead of trusting this one: the sweep must not reclaim a - * document that a full queue drain has simply not reached yet, so the grace is - * that drain — corpus / concurrency x per-document occupancy — times a - * contention factor for the queue being shared across workspaces. At the values - * above that is 7,730 / 20 x 1 x 2 = 773 minutes, just under thirteen hours. + * {@link STALE_PROCESSING_MINUTES} bounds a run that has already begun, derived + * from the task's own duration and retry budget. Queue *wait* is a different + * quantity: it is backlog / concurrency, not run duration. + * `document-processing-queue` has a global concurrency of + * {@link PROCESSING_QUEUE_CONCURRENCY} shared by every workspace, so a corpus large + * enough to approach `CONNECTOR_SYNC_MAX_DURATION_SECONDS` enqueues thousands of + * documents that drain in waves of that width — at roughly a minute of occupancy each, + * a few hours, and longer while other workspaces hold slots. * - * Each input is measurable and should be re-measured when it moves: corpus size - * from the largest connector in production, concurrency from - * `knowledge-process-document`'s queue config, occupancy from run durations. - * Note the failure mode is asymmetric — too small silently re-bills live work, - * too large only delays recovery of documents nothing is processing — so round - * up, never down. + * Four hours is chosen against three bounds that are all constants in this + * repository rather than any one deployment's corpus: it is well above that + * drain estimate, an order of magnitude above the one-hour sync ceiling, and + * still well under the 1,440-minute default sync interval — so a + * default-configured connector waits no longer for recovery than it already did. */ -const QUEUED_DISPATCH_GRACE_MINUTES = Math.ceil( - (LARGEST_OBSERVED_CORPUS_DOCUMENTS / PROCESSING_QUEUE_CONCURRENCY) * - TYPICAL_DOCUMENT_OCCUPANCY_MINUTES * - QUEUE_CONTENTION_FACTOR -) +const QUEUED_DISPATCH_GRACE_MINUTES = 240 const RETRY_WINDOW_DAYS = 7 +/** + * Processing states the stuck-document sweep may reclaim from. + * + * One constant used by BOTH the candidate SELECT and the reset UPDATE. The + * UPDATE has to re-assert what the SELECT filtered on — the ownership re-check + * between them covers `connectorId` only, so a document that completed in that + * window would otherwise be reset and have its embeddings deleted. Sharing the + * list means the two cannot drift into disagreeing about what is reclaimable. + */ +export const SWEEPABLE_PROCESSING_STATUSES = ['pending', 'failed', 'processing'] as const + /** The processing state the stuck-document sweep decides on, one row at a time. */ export interface StuckDocumentSweepCandidate { - processingStatus: string + processingStatus: DocumentProcessingStatus processingQueuedAt: Date | null processingStartedAt: Date | null processingCompletedAt: Date | null @@ -140,10 +205,13 @@ export interface StuckDocumentSweepCandidate { * pass, so queued documents get {@link QUEUED_DISPATCH_GRACE_MINUTES} before * they are considered lost. * - * Queue wait is measured from `processingQueuedAt`, stamped by - * `processDocumentsWithQueue` — the funnel every dispatch passes through — so - * no caller can dispatch without recording when. It falls back to `uploadedAt` - * when NULL, which covers rows written before the column existed. + * Queue wait is measured from `processingQueuedAt`, stamped in one place — + * `markDocumentsQueued`, which every dispatch funnels through, so the column + * always describes the attempt that is live right now. + * It falls back to `uploadedAt` when NULL, which covers a document dispatched + * by the sync that created it (`uploadedAt` then sits within that sync's own + * runtime, an over-estimate bounded by the one-hour sync ceiling) and rows + * written before the column existed. * * `failed` is not a terminal state and gets the same grace. `processDocumentAsync` * records the failure and then rethrows, so `knowledge-process-document` retries @@ -196,7 +264,9 @@ export function isStuckDocumentSweepEligible(doc: StuckDocumentSweepCandidate, n now.getTime() - doc.processingStartedAt.getTime() > STALE_PROCESSING_MINUTES * 60 * 1000 ) } - default: + // No `default`: a status added to DocumentProcessingStatus must fail + // type-check here rather than silently reading as "not eligible". + case 'completed': return false } } @@ -384,8 +454,15 @@ export function chunkOpsByByteBudget( return chunks } -/** Single-roundtrip liveness check used between batches. */ -async function checkSyncLiveness( +/** + * Single-roundtrip check that this sync's targets still exist. + * + * Named for presence rather than liveness deliberately: this file uses + * "liveness" in its distributed-systems sense — a run proving it is still + * working, via {@link heartbeatSyncLock} — and reusing the word for a row + * existence check conflated two unrelated questions three lines apart. + */ +async function checkSyncTargetPresence( connectorId: string, knowledgeBaseId: string ): Promise<{ connectorDeleted: boolean; knowledgeBaseDeleted: boolean }> { @@ -493,12 +570,30 @@ export async function completeSyncLog( * for the heartbeat is what turns a beat into an ownership probe. */ export function stillHoldsSyncLock(connectorId: string, syncLockToken: string) { + return and(holdsSyncLockToken(connectorId, syncLockToken), connectorIsLive()) +} + +/** The archived/deleted half of {@link stillHoldsSyncLock}. */ +function connectorIsLive() { + return and(isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt)) +} + +/** + * Ownership only: this run still holds the lock, regardless of whether the + * connector has since been archived or deleted. + * + * The heartbeat guards on this rather than on {@link stillHoldsSyncLock} so a + * connector deleted mid-sync does not read as lock loss. It would otherwise + * raise `SyncLockLostException` before `checkSyncTargetPresence` ever ran, skipping + * the leftover-document cleanup that `ConnectorDeletedException` performs and + * leaving the sync-log row `started` until the sweep mislabelled it. Deletion is + * the liveness check's verdict to reach, not the heartbeat's. + */ +export function holdsSyncLockToken(connectorId: string, syncLockToken: string) { return and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.status, 'syncing'), - eq(knowledgeConnector.syncLockToken, syncLockToken), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) + eq(knowledgeConnector.syncLockToken, syncLockToken) ) } @@ -547,7 +642,7 @@ export async function heartbeatSyncLock( const beat = await db .update(knowledgeConnector) .set({ updatedAt: new Date() }) - .where(stillHoldsSyncLock(connectorId, syncLockToken)) + .where(holdsSyncLockToken(connectorId, syncLockToken)) .returning({ id: knowledgeConnector.id }) return beat.length > 0 @@ -771,11 +866,25 @@ export function countDeletionEligibleOwned( export function buildReconciliationHoldNotice( withheld: number, cap: number, - ownedDocCount: number + ownedDocCount: number, + softHeld: boolean, + hardHeld: boolean ): string { + /** + * Stated per held generation. A hard-only hold withholds documents that a + * previous sync already tombstoned, so they have been invisible since then — + * telling the operator they are "still indexed" would be false. + */ + const consequence = + softHeld && hardHeld + ? 'Documents removed at the source are still indexed, and documents already pending removal were not purged.' + : softHeld + ? 'Documents removed at the source are still indexed.' + : 'Documents already pending removal were not purged; they stay hidden from search either way.' + return ( - `Withheld ${withheld} document removal(s) — more than the ${cap} allowed in one sync ` + - `of ${ownedDocCount} documents. Documents deleted at the source are still indexed. ` + + `Withheld ${withheld} document removal(s) — more than the ${cap} allowed per generation ` + + `in one sync of ${ownedDocCount} documents. ${consequence} ` + 'Check the source is returning its full contents, then run a full sync to apply the removals.' ) } @@ -931,6 +1040,15 @@ export function resolveReconciliationDeleteCap( * the connector never reconciles again. Capping each generation against the same * ceiling keeps the per-sync blast radius bounded without that deadlock. * + * Note the ceiling this yields: each generation may spend the cap independently, + * so a single sync can remove up to 2x the cap — with the default ratio, about + * half the corpus, not a quarter. That is deliberate. The two generations are + * different populations: the hard deletes were already gated by this cap on the + * sync that tombstoned them, and have been invisible ever since, so confirming + * them costs no additional visible documents. The quarter-of-a-corpus figure + * describes what one sync may newly hide, which is the number that matters for a + * source that has started lying about its contents. + * * `fullSync` bypasses the cap, matching its meaning everywhere else here — an * explicit human request to reconcile against this listing right now, which is * the documented escape hatch for a genuine mass deletion. @@ -1042,11 +1160,13 @@ export function shouldRunIncrementalSync( /** * A stored document's identity, as read back for reconciliation. * - * `userExcluded` is optional because only the tombstoned read projects it — the - * live read filters excluded rows out in SQL, so an absent flag there means - * "not excluded" and the deletion guards below read the same either way. + * `userExcluded` is required, not optional. Both reads project it, so the + * deletion guards in {@link partitionSyncReconciliation} enforce something on + * their own rather than restating a filter the SQL already applied — if that + * filter were ever dropped, the guard would still hold. An optional flag made + * the guard a silent no-op on any read that forgot to select it. */ -type ReconciliationDoc = { id: string; externalId: string | null; userExcluded?: boolean } +type ReconciliationDoc = { id: string; externalId: string | null; userExcluded: boolean } /** * Partitions a connector's stored documents against the current listing into @@ -1499,6 +1619,10 @@ export async function executeSync( id: document.id, externalId: document.externalId, contentHash: document.contentHash, + // Projected as well as filtered: the SQL predicate and the in-memory + // guard in partitionSyncReconciliation must both hold, so dropping + // either one alone cannot make an excluded document deletable. + userExcluded: document.userExcluded, }) .from(document) .where( @@ -1624,16 +1748,18 @@ export async function executeSync( // per-file cap never hydrate/upload together and exhaust the worker heap. const batches = chunkOpsByByteBudget(pendingOps, CONTENT_INFLIGHT_BUDGET_BYTES, SYNC_BATCH_SIZE) for (const rawBatch of batches) { - await beatIfDue() - - const liveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) - if (liveness.connectorDeleted) { + const presence = await checkSyncTargetPresence(connectorId, connector.knowledgeBaseId) + if (presence.connectorDeleted) { throw new ConnectorDeletedException(connectorId) } - if (liveness.knowledgeBaseDeleted) { + if (presence.knowledgeBaseDeleted) { throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`) } + // After liveness: a deleted connector must raise ConnectorDeletedException + // and run its cleanup, not be reported as a lost lock. + await beatIfDue() + // Oversized/skipped docs become visible `failed` rows (never silent). They are // flagged either at listing time (skip ops here) or discovered only at fetch // time during hydration below; both are collected and persisted after hydration. @@ -1900,7 +2026,9 @@ export async function executeSync( reconciliationHoldNotice = buildReconciliationHoldNotice( capped.withheld, capped.cap, - ownedDocCount + ownedDocCount, + capped.softHeld, + capped.hardHeld ) logger.error('Reconciliation deletions held — exceeds per-sync blast-radius cap', { connectorId, @@ -1928,6 +2056,14 @@ export async function executeSync( let safeSoftDeleteIds: string[] = [] let safeHardDeleteIds: string[] = [] + /** + * Probes ownership before the reconciliation writes rather than after them: + * the soft-delete transaction and `hardDeleteDocuments` below are the most + * destructive block in this file, and a run that has lost its lock must not + * execute them alongside its replacement. + */ + await beatIfDue() + if (candidateIds.length > 0) { /** * A concurrent "delete connector, keep documents" request detaches these @@ -2006,13 +2142,11 @@ export async function executeSync( result.docsDeleted += await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId) } - await beatIfDue() - - const postBatchLiveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) - if (postBatchLiveness.connectorDeleted) { + const postBatchPresence = await checkSyncTargetPresence(connectorId, connector.knowledgeBaseId) + if (postBatchPresence.connectorDeleted) { throw new ConnectorDeletedException(connectorId) } - if (postBatchLiveness.knowledgeBaseDeleted) { + if (postBatchPresence.knowledgeBaseDeleted) { throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`) } @@ -2046,7 +2180,10 @@ export async function executeSync( .where( and( eq(document.connectorId, connectorId), - inArray(document.processingStatus, ['pending', 'failed', 'processing']), + inArray(document.processingStatus, SWEEPABLE_PROCESSING_STATUSES), + // Dead letters are left alone: past the budget, re-dispatching only + // re-bills a document that has failed the same way every time. + lt(document.processingAttempts, MAX_PROCESSING_ATTEMPTS), lt(document.uploadedAt, syncStartedAt), gt(document.uploadedAt, retryCutoff), eq(document.userExcluded, false), @@ -2055,9 +2192,11 @@ export async function executeSync( isNull(document.deletedAt) ) ) - const stuckDocs = sweepCandidates.filter((doc) => - isStuckDocumentSweepEligible(doc, sweepEvaluatedAt) - ) + const stuckDocs = sweepCandidates + .filter((row): row is typeof row & { processingStatus: DocumentProcessingStatus } => + isDocumentProcessingStatus(row.processingStatus) + ) + .filter((doc) => isStuckDocumentSweepEligible(doc, sweepEvaluatedAt)) if (stuckDocs.length > 0) { logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId }) @@ -2096,18 +2235,14 @@ export async function executeSync( if (retryDocs.length > 0) { const retryDocIds = retryDocs.map((doc) => doc.id) - await tx.delete(embedding).where(inArray(embedding.documentId, retryDocIds)) - - await tx + const reset = await tx .update(document) .set({ processingStatus: 'pending', - /** - * Records when this re-dispatch was queued, so a later sweep can - * tell a document still waiting for a worker from one whose - * dispatch was lost. See {@link isStuckDocumentSweepEligible}. - */ - processingQueuedAt: sweepEvaluatedAt, + // `processingQueuedAt` is not stamped here: the dispatch below + // funnels through `markDocumentsQueued`, which stamps it for + // every caller. Setting it here wrote a value that was + // immediately overwritten. processingStartedAt: null, processingCompletedAt: null, processingError: null, @@ -2115,7 +2250,32 @@ export async function executeSync( tokenCount: 0, characterCount: 0, }) - .where(inArray(document.id, retryDocIds)) + /** + * Re-asserts the status the candidate SELECT filtered on. + * + * The ownership re-check above covers `connectorId` only, so + * between the SELECT and this write a worker could have claimed + * or finished the document. Resetting it then would delete the + * embeddings of a pass that had already completed and bill a + * second one — the same TOCTOU the connector-side writes in this + * file were guarded against. + */ + .where( + and( + inArray(document.id, retryDocIds), + inArray(document.processingStatus, SWEEPABLE_PROCESSING_STATUSES) + ) + ) + .returning({ id: document.id }) + + // Embeddings are dropped only for documents this sweep actually + // reset. Deleting first would strip a pass that completed between + // the candidate SELECT and this write. + const resetIds = reset.map((row) => row.id) + if (resetIds.length > 0) { + await tx.delete(embedding).where(inArray(embedding.documentId, resetIds)) + } + retryDocs = retryDocs.filter((doc) => resetIds.includes(doc.id)) } }) @@ -2137,6 +2297,14 @@ export async function executeSync( ) } } catch (error) { + /** + * Kept out of the best-effort swallow below. A run that has provably + * lost its lock would otherwise be mislabelled an enqueue failure, fall + * through, and publish `completeSyncLog(..., 'completed')` — which the + * replacement run then reads as corroboration of its own listing. + */ + if (error instanceof SyncLockLostException) throw error + logger.warn('Failed to enqueue stuck documents for reprocessing', { connectorId, count: stuckDocs.length, diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.test.ts b/apps/sim/lib/knowledge/connectors/sync-limits.test.ts index 41f5a0da19e..263c801a641 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { CONNECTOR_SYNC_MAX_DURATION_SECONDS, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, + SYNC_LOCK_HEARTBEAT_INTERVAL_MS, } from '@/lib/knowledge/connectors/sync-limits' describe('connector sync limits', () => { @@ -19,8 +20,17 @@ describe('connector sync limits', () => { ) }) - /** A 2,600-document library exhausted the previous 1800s budget mid-listing. */ + /** A large library exhausted the previous 1800s budget partway through listing. */ it('allows a run longer than the half hour that timed out in production', () => { expect(CONNECTOR_SYNC_MAX_DURATION_SECONDS).toBeGreaterThan(1800) }) + + /** + * A live run must beat several times over before the reclaim cutoff, or + * ordinary jitter — a slow batch, a long upload — reclaims a working sync and + * counts it as a failure it can never clear. + */ + it('leaves room for several heartbeats inside the reclaim window', () => { + expect(SYNC_LOCK_HEARTBEAT_INTERVAL_MS * 4).toBeLessThan(CONNECTOR_SYNC_STALE_LOCK_TTL_MS) + }) }) diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index a66910dd411..edf72229f90 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -1,8 +1,11 @@ /** - * Wall-clock ceiling for a single connector sync run. A large document library - * needs more than the half hour this used to allow: a 2,600-document site - * exhausted the old budget and was killed mid-listing, leaving its `syncing` - * lock set until the scheduler reclaimed it. + * Wall-clock ceiling for a single connector sync run. + * + * Raised from the half hour this used to allow, which a large document library + * exhausted mid-listing: the run was killed partway through pagination, leaving + * its `syncing` lock set until the scheduler reclaimed it. Listing dominates a + * large sync's wall clock, so the ceiling has to cover a full enumeration rather + * than a typical one. */ export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600 diff --git a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts index e788ef5bacc..d0dcf244ec5 100644 --- a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts +++ b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts @@ -179,6 +179,9 @@ describe('knowledge document indexing usage', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + // The processing claim is guarded and returns the row it claimed; without a + // stub every worker would read as 'already completed' and return early. + dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) mockGetFileMetadataByKeys.mockImplementation(async (_keys: string[], context: string) => context === 'workspace' ? [SOURCE_BINDING] : [] diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 1a2f7402632..8b68aaf7883 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -1,7 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + hasMockCondition, + type MockCondition, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -134,6 +140,9 @@ describe('knowledge document processing source', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + // The processing claim is guarded and returns the row it claimed; without a + // stub every worker would read as 'already completed' and return early. + dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) dbChainMockFns.limit .mockResolvedValueOnce([PERSISTED_CONTEXT]) .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) @@ -235,7 +244,7 @@ describe('knowledge document processing source', () => { .mockResolvedValueOnce([{ ...PERSISTED_CONTEXT, processingStatus: 'processing' }]) .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) .mockResolvedValueOnce([{ id: 'document-1' }]) - dbChainMockFns.returning.mockReset().mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReset().mockResolvedValue([{ id: 'document-1' }]) await processDocumentAsync('knowledge-base-1', 'document-1', { filename: 'stale.pdf', @@ -254,3 +263,132 @@ describe('knowledge document processing source', () => { ) }) }) + +describe('processDocumentAsync write guards', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) + mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) + mockProcessDocument.mockResolvedValue({ + chunks: [], + metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0 }, + }) + }) + + /** Asserts the write that set `status` exists, and returns its guard clause. */ + function guardForStatusWrite(status: string): unknown { + expect( + dbChainMockFns.set.mock.calls.some( + (call) => (call[0] as Record | undefined)?.processingStatus === status + ) + ).toBe(true) + + // `set` and `where` are separate shared spies, so they cannot be correlated + // by index; the guard is identified by its own shape instead. + const guard = dbChainMockFns.where.mock.calls.find((call) => + hasMockCondition( + call[0], + (node: MockCondition) => + node.type === 'ne' && + node.left === schemaMock.document.processingStatus && + node.right === 'completed' + ) + ) + expect(guard).toBeDefined() + return guard?.[0] + } + + it('never claims a document whose pass already completed', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([PERSISTED_CONTEXT]) + .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) + .mockResolvedValueOnce([{ id: 'document-1' }]) + mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING]) + mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue( + new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]]) + ) + + await processDocumentAsync('knowledge-base-1', 'document-1', { + filename: 'a.pdf', + fileUrl: 'https://example.com/a.pdf', + fileSize: 1, + mimeType: 'text/plain', + }) + + /** + * Unguarded, a late or duplicate dispatch flipped `completed` back to + * `processing`, discarding a pass that had already indexed and billed. + */ + expect(guardForStatusWrite('processing')).toBeDefined() + }) + + it('does not process or bill a document it failed to claim', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([PERSISTED_CONTEXT]) + .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) + .mockResolvedValueOnce([{ id: 'document-1' }]) + // The guarded claim matched no rows: another pass owns this document. + dbChainMockFns.returning.mockReset().mockResolvedValue([]) + + await processDocumentAsync('knowledge-base-1', 'document-1', { + filename: 'a.pdf', + fileUrl: 'https://example.com/a.pdf', + fileSize: 1, + mimeType: 'text/plain', + }) + + expect(mockProcessDocument).not.toHaveBeenCalled() + }) + + it('guards the missing-context failure write against a finished pass', async () => { + // No context row: the document or its knowledge base is gone. + dbChainMockFns.limit.mockResolvedValue([]) + + await processDocumentAsync('knowledge-base-1', 'document-1', { + filename: 'a.pdf', + fileUrl: 'https://example.com/a.pdf', + fileSize: 1, + mimeType: 'text/plain', + }) + + const where = guardForStatusWrite('failed') + for (const column of [schemaMock.document.archivedAt, schemaMock.document.deletedAt]) { + expect( + hasMockCondition( + where, + (node: MockCondition) => node.type === 'isNull' && node.column === column + ) + ).toBe(true) + } + }) + + it('clears the retry budget when a pass completes', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([PERSISTED_CONTEXT]) + .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) + .mockResolvedValueOnce([{ id: 'document-1' }]) + mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING]) + mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue( + new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]]) + ) + + await processDocumentAsync('knowledge-base-1', 'document-1', { + filename: 'a.pdf', + fileUrl: 'https://example.com/a.pdf', + fileSize: 1, + mimeType: 'text/plain', + }) + + /** + * Without the reset a document that failed four times and then succeeded + * would carry those attempts forever, so its next single failure would + * exhaust the budget and dead-letter a document that is actually healthy. + */ + const completion = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'completed' + ) + expect(completion).toBeDefined() + expect((completion?.[0] as Record).processingAttempts).toBe(0) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/processing-claim.test.ts b/apps/sim/lib/knowledge/documents/processing-claim.test.ts index 34d4df22149..d09d85505ec 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.test.ts @@ -174,11 +174,17 @@ describe('failUndispatchedDocumentProcessing', () => { expect( hasMockCondition( where, - (node) => node.type === 'eq' && node.left === 'processingStatus' && node.right === 'pending' + (node) => + node.type === 'eq' && + node.left === 'document.processingStatus' && + node.right === 'pending' ) ).toBe(true) expect( - hasMockCondition(where, (node) => node.type === 'isNull' && node.column === 'deletedAt') + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === 'document.deletedAt' + ) ).toBe(true) }) }) diff --git a/apps/sim/lib/knowledge/documents/processing-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-queue.test.ts index 1490a48f50a..6b0639c947a 100644 --- a/apps/sim/lib/knowledge/documents/processing-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-queue.test.ts @@ -76,6 +76,9 @@ describe('processDocumentsWithQueue billing attribution', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + // The processing claim is guarded and returns the row it claimed; without a + // stub every worker would read as 'already completed' and return early. + dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) mockBatchTrigger.mockResolvedValue({ batchId: 'batch-1' }) for (const key of Object.keys(env)) { delete (env as Record)[key] @@ -168,6 +171,9 @@ describe('processDocumentsWithQueue dispatch backend', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + // The processing claim is guarded and returns the row it claimed; without a + // stub every worker would read as 'already completed' and return early. + dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) resetInsideTriggerRunForTests() mockBatchTrigger.mockResolvedValue({ batchId: 'batch-1' }) for (const key of Object.keys(env)) { diff --git a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts index 9d5f64a774f..b5779246534 100644 --- a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts +++ b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, flattenMockConditions, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + hasMockCondition, + type MockCondition, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => dbChainMock) @@ -45,16 +52,13 @@ describe('retryDocumentProcessing requeue stamp', () => { resetDbChainMock() }) - it('stamps the requeue time on the dispatch column', async () => { - const before = Date.now() + it('clears the previous attempt terminal state', async () => { const values = await captureRequeueValues() - const after = Date.now() - expect(values.processingQueuedAt).toBeInstanceOf(Date) - const stamp = values.processingQueuedAt as Date - expect(stamp.getTime()).toBeGreaterThanOrEqual(before) - expect(stamp.getTime()).toBeLessThanOrEqual(after) + // The queue stamp itself is written by `markDocumentsQueued` on dispatch, + // covered below — the reset's job is only to undo the prior attempt. expect(values.processingCompletedAt).toBeNull() + expect(values.processingError).toBeNull() }) it('leaves processingStartedAt null so the API reports no start time', async () => { @@ -62,38 +66,6 @@ describe('retryDocumentProcessing requeue stamp', () => { expect(values.processingStartedAt).toBeNull() }) - - it('leaves the requeued document outside the reach of the next connector sync', async () => { - const values = await captureRequeueValues() - const uploadedAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) - const sweptAt = new Date(Date.now() + 60 * 1000) - - expect( - isStuckDocumentSweepEligible( - { - processingStatus: values.processingStatus as string, - processingQueuedAt: values.processingQueuedAt as Date | null, - processingStartedAt: values.processingStartedAt as Date | null, - processingCompletedAt: values.processingCompletedAt as Date | null, - uploadedAt, - }, - sweptAt - ) - ).toBe(false) - - expect( - isStuckDocumentSweepEligible( - { - processingStatus: 'pending', - processingQueuedAt: null, - processingStartedAt: null, - processingCompletedAt: null, - uploadedAt, - }, - sweptAt - ) - ).toBe(true) - }) }) describe('processDocumentsWithQueue dispatch stamp', () => { @@ -135,31 +107,130 @@ describe('processDocumentsWithQueue dispatch stamp', () => { expect(values.processingStartedAt).toBeNull() }) - it('withdraws the stamp when every dispatch fails', async () => { + it('puts the dispatched document outside the reach of the next connector sync', async () => { await dispatch() - const withdrawal = dbChainMockFns.set.mock.calls.find( - (call) => (call[0] as Record | undefined)?.processingQueuedAt === null + const stampCall = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingQueuedAt !== undefined ) - expect(withdrawal).toBeDefined() + const values = stampCall?.[0] as Record + const uploadedAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const sweptAt = new Date(Date.now() + 60 * 1000) + + /** + * The invariant the retry used to protect with its own inline stamp: a + * document dispatched moments ago must not be reclaimed by a sweep that + * would otherwise age it from a month-old `uploadedAt`. + */ + expect( + isStuckDocumentSweepEligible( + { + processingStatus: 'pending', + processingQueuedAt: values.processingQueuedAt as Date | null, + processingStartedAt: values.processingStartedAt as Date | null, + uploadedAt, + }, + sweptAt + ) + ).toBe(false) + + // Without the stamp the same document ages from `uploadedAt` and is taken. + expect( + isStuckDocumentSweepEligible( + { + processingStatus: 'pending', + processingQueuedAt: null, + processingStartedAt: null, + uploadedAt, + }, + sweptAt + ) + ).toBe(true) }) +}) - it('scopes the withdrawal to this batch, to pending rows, and to its own stamp', async () => { - await dispatch() +describe('retryDocumentProcessing double-click guard', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) - const stampCall = dbChainMockFns.set.mock.calls.find( - (call) => (call[0] as Record | undefined)?.processingQueuedAt instanceof Date + it('only requeues a document in a terminal state', async () => { + dbChainMockFns.returning.mockResolvedValue([{ id: 'doc-1' }]) + + await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined).catch(() => {}) + + /** + * Unguarded, a second click reset a document the first had already queued, + * so both dispatches ran, both indexed, and both billed. + */ + const guard = dbChainMockFns.where.mock.calls.find((call) => + hasMockCondition( + call[0], + (node: MockCondition) => + node.type === 'inArray' && node.column === schemaMock.document.processingStatus + ) ) - const stamp = (stampCall?.[0] as Record).processingQueuedAt as Date - - const scoped = dbChainMockFns.where.mock.calls.some((call) => { - const nodes = flattenMockConditions(call[0]) - return ( - nodes.some((node) => node.type === 'inArray' && Array.isArray(node.values)) && - nodes.some((node) => node.type === 'eq' && node.right === 'pending') && - nodes.some((node) => node.type === 'eq' && node.right === stamp) + expect(guard).toBeDefined() + expect( + hasMockCondition( + guard?.[0], + (node: MockCondition) => + node.type === 'inArray' && + Array.isArray(node.values) && + node.values.join(',') === 'completed,failed' + ) + ).toBe(true) + }) + + it('does not dispatch or drop embeddings when it claimed nothing', async () => { + // The guarded reset matched no rows: another click already queued this doc. + dbChainMockFns.returning.mockResolvedValue([]) + + const result = await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined) + + expect(result).toMatchObject({ success: true, status: 'pending' }) + expect(result.message).toContain('already queued') + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + // No dispatch means no queue stamp was written either. + expect( + dbChainMockFns.set.mock.calls.some( + (call) => (call[0] as Record | undefined)?.processingQueuedAt !== undefined ) - }) - expect(scoped).toBe(true) + ).toBe(false) + }) +}) + +describe('processing attempt budget', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([{ userId: 'user-1', workspaceId: null }]) + }) + + it('spends one attempt per dispatch, in the same guarded write', async () => { + await processDocumentsWithQueue( + [{ documentId: 'doc-1', ...DOC_DATA }], + 'kb-1', + {}, + 'req-1', + undefined + ).catch(() => {}) + + const stampCall = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingQueuedAt !== undefined + ) + const values = stampCall?.[0] as Record + + /** + * Charged as a SQL increment rather than a read-then-write, and in the same + * statement as the queue stamp, so two concurrent dispatches cannot both + * read the same count and spend one attempt between them. + */ + expect(values.processingAttempts).toBeDefined() + expect(typeof values.processingAttempts).not.toBe('number') + expect((values.processingAttempts as { toSQL: () => { sql: string } }).toSQL().sql).toContain( + '+ 1' + ) }) }) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index b735a524ff0..5e452ba048e 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -23,6 +23,7 @@ import { inArray, isNotNull, isNull, + ne, type SQL, sql, } from 'drizzle-orm' @@ -702,7 +703,14 @@ async function resolveDocumentProcessingBillingContext( async function markDocumentsQueued(documentIds: string[], queuedAt: Date): Promise { await db .update(document) - .set({ processingQueuedAt: queuedAt, processingStartedAt: null }) + .set({ + processingQueuedAt: queuedAt, + processingStartedAt: null, + // Spent here because this is the one write every dispatch passes through, + // and it is already guarded — so the budget cannot be charged twice for a + // single dispatch, nor skipped by a caller that dispatches another way. + processingAttempts: sql`${document.processingAttempts} + 1`, + }) .where(and(inArray(document.id, documentIds), eq(document.processingStatus, 'pending'))) } @@ -971,7 +979,16 @@ export async function processDocumentAsync( processingError: 'Document or knowledge base no longer exists', processingCompletedAt: new Date(), }) - .where(eq(document.id, documentId)) + // Never overwrite a finished pass, and never resurrect state on a row + // that has since been archived or deleted. + .where( + and( + eq(document.id, documentId), + ne(document.processingStatus, 'completed'), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) return } @@ -983,7 +1000,17 @@ export async function processDocumentAsync( mimeType: ctx.mimeType, } - await db + /** + * Claiming is guarded on the document not already being `completed`. + * + * Without a status predicate this write was reachable for a finished + * document — a late or duplicate dispatch would flip `completed` back to + * `processing`, discard the pass that had already indexed and billed, and + * index it a second time. `pending`, `failed` and `processing` stay + * claimable so a Trigger.dev retry of the same run still proceeds; the + * commit CAS downstream is what keeps two live workers from both finishing. + */ + const claimed = await db .update(document) .set({ processingStatus: 'processing', @@ -992,8 +1019,21 @@ export async function processDocumentAsync( processingError: null, }) .where( - and(eq(document.id, documentId), isNull(document.archivedAt), isNull(document.deletedAt)) + and( + eq(document.id, documentId), + ne(document.processingStatus, 'completed'), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) ) + .returning({ id: document.id }) + + if (claimed.length === 0) { + logger.info( + `[${documentId}] Skipping document processing: already completed, archived, or deleted` + ) + return + } logger.info(`[${documentId}] Status updated to 'processing', starting document processor`) @@ -1267,6 +1307,9 @@ export async function processDocumentAsync( processingStatus: 'completed', processingCompletedAt: now, processingError: null, + // A completed pass clears the budget: the next failure starts + // from a full allowance rather than inheriting a stale count. + processingAttempts: 0, }) .where( and( @@ -2572,26 +2615,21 @@ export async function retryDocumentProcessing( billingAttribution: BillingAttributionSnapshot | undefined ): Promise<{ success: boolean; status: string; message: string }> { /** - * When this requeue was dispatched. + * Only a document in a terminal state may be retried. * - * The document sits at `pending` until a worker claims it, and for a - * connector-owned document the connector sweep - * (`isStuckDocumentSweepEligible`) measures queue wait from - * `processingQueuedAt`, falling back to `uploadedAt`. Leaving it unset would - * fall the sweep back on `uploadedAt`, which for a document synced days ago - * is arbitrarily old — so the next sync would reclaim the document out from - * under this very retry, duplicating its work and billing a second indexing - * pass. + * Unguarded, a double-click issued two full passes: the second reset a + * document that the first had already queued, so both dispatches ran, both + * indexed, and both billed. Restricting the transition to `completed` or + * `failed` makes the second click match no rows, and the empty `returning` + * below stops it dispatching. */ - const requeuedAt = new Date() - await db.transaction(async (tx) => { - await tx.delete(embedding).where(eq(embedding.documentId, documentId)) - - await tx + const requeued = await db.transaction(async (tx) => { + const reset = await tx .update(document) .set({ processingStatus: 'pending', - processingQueuedAt: requeuedAt, + // `processingQueuedAt` is stamped by `markDocumentsQueued` on the + // dispatch below, for this and every other caller. processingStartedAt: null, processingCompletedAt: null, processingError: null, @@ -2599,9 +2637,33 @@ export async function retryDocumentProcessing( tokenCount: 0, characterCount: 0, }) - .where(eq(document.id, documentId)) + .where( + and( + eq(document.id, documentId), + inArray(document.processingStatus, ['completed', 'failed']), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + + // Embeddings are dropped only for a document this call actually claimed, + // so a losing double-click cannot wipe the winner's in-flight work. + if (reset.length > 0) { + await tx.delete(embedding).where(eq(embedding.documentId, documentId)) + } + return reset.length > 0 }) + if (!requeued) { + logger.info(`[${requestId}] Document retry skipped, already queued: ${documentId}`) + return { + success: true, + status: 'pending', + message: 'Document is already queued for processing', + } + } + await processDocumentsWithQueue( [ { diff --git a/apps/sim/lib/knowledge/documents/tag-filter.test.ts b/apps/sim/lib/knowledge/documents/tag-filter.test.ts index 83ff405d271..94afd17c221 100644 --- a/apps/sim/lib/knowledge/documents/tag-filter.test.ts +++ b/apps/sim/lib/knowledge/documents/tag-filter.test.ts @@ -38,7 +38,7 @@ describe('buildTagFilterCondition', () => { }) ) expect(sql).toBe('LOWER(?) = LOWER(?)') - expect(params).toEqual(['tag1', 'Ada Lovelace']) + expect(params).toEqual(['document.tag1', 'Ada Lovelace']) }) it('matches neq case-insensitively', () => { @@ -51,7 +51,7 @@ describe('buildTagFilterCondition', () => { }) ) expect(sql).toBe('LOWER(?) != LOWER(?)') - expect(params).toEqual(['tag2', 'Spreadsheet']) + expect(params).toEqual(['document.tag2', 'Spreadsheet']) }) it('escapes LIKE wildcards in contains', () => { @@ -89,7 +89,7 @@ describe('buildTagFilterCondition', () => { }) ) expect(sql).toBe('?::date = ?::date') - expect(params).toEqual(['date1', '2026-04-21']) + expect(params).toEqual(['document.date1', '2026-04-21']) }) it('compares range bounds on the calendar day', () => { @@ -138,7 +138,7 @@ describe('buildTagFilterCondition', () => { operator: 'eq', value: '42', }) - ).toEqual({ type: 'eq', left: 'number1', right: 42 }) + ).toEqual({ type: 'eq', left: 'document.number1', right: 42 }) }) it('ignores non-numeric values', () => { @@ -162,7 +162,7 @@ describe('buildTagFilterCondition', () => { operator: 'eq', value: 'true', }) - ).toEqual({ type: 'eq', left: 'boolean1', right: true }) + ).toEqual({ type: 'eq', left: 'document.boolean1', right: true }) }) it('ignores values that are not boolean-like', () => { @@ -189,7 +189,7 @@ describe('buildTagFilterCondition', () => { }) ) expect(sql).toBe('?::date = ?::date') - expect(params).toEqual(['date1', '2026-04-21']) + expect(params).toEqual(['document.date1', '2026-04-21']) }) it('compiles a trimmed between bound too', () => { @@ -201,7 +201,10 @@ describe('buildTagFilterCondition', () => { valueTo: ' 2026-04-30 ', }) as unknown as { type: string; conditions: unknown[] } expect(condition.type).toBe('and') - expect(rendered(condition.conditions[1] as never).params).toEqual(['date1', '2026-04-30']) + expect(rendered(condition.conditions[1] as never).params).toEqual([ + 'document.date1', + '2026-04-30', + ]) }) it('reads a boolean case-insensitively', () => { @@ -213,7 +216,7 @@ describe('buildTagFilterCondition', () => { operator: 'eq', value: 'TRUE', }) - ).toEqual({ type: 'eq', left: 'boolean1', right: true }) + ).toEqual({ type: 'eq', left: 'document.boolean1', right: true }) }) }) }) diff --git a/apps/sim/lib/knowledge/documents/types.ts b/apps/sim/lib/knowledge/documents/types.ts index 65e165011b0..1d23f5631e3 100644 --- a/apps/sim/lib/knowledge/documents/types.ts +++ b/apps/sim/lib/knowledge/documents/types.ts @@ -1,3 +1,50 @@ +/** + * Dispatches the stuck-document sweep will spend on one document before giving + * up on it. + * + * The sweep re-dispatches a non-terminal document every sync for the whole + * retry window, and every dispatch re-parses and re-embeds it — so a document + * that fails deterministically (a corrupt file, an unsupported encoding) was + * billed once per sync indefinitely. Five is chosen against the unit that is + * actually consumed: one attempt per *dispatch*, not per Trigger.dev retry, so + * a short-interval connector can burn several inside one transient outage. + * Three left too little room for that; five still bounds the spend well inside + * `RETRY_WINDOW_DAYS`. + * + * Reaching it is a dead letter, not a deletion: the document keeps its `failed` + * status and stays user-retryable, it simply stops being swept automatically. + */ +export const MAX_PROCESSING_ATTEMPTS = 5 + +/** + * Every value `document.processing_status` may hold. + * + * Shared rather than redeclared per consumer so a switch over it can be + * exhaustive: a `default` arm silently absorbs a status added later, which for + * the stuck-document sweep meant a new state would read as "not eligible" and + * quietly stop being reclaimed. With the union imported and no `default`, adding + * a member fails type-check at every decision site instead. + */ +export const DOCUMENT_PROCESSING_STATUSES = [ + 'pending', + 'processing', + 'completed', + 'failed', +] as const + +export type DocumentProcessingStatus = (typeof DOCUMENT_PROCESSING_STATUSES)[number] + +/** + * Narrows a stored `processing_status` onto the union. + * + * The column is `text`, so every read arrives as `string` no matter how the + * query filters it. Narrowing at the read boundary keeps the decision sites + * exhaustive without a cast asserting something the type system cannot see. + */ +export function isDocumentProcessingStatus(value: string): value is DocumentProcessingStatus { + return (DOCUMENT_PROCESSING_STATUSES as readonly string[]).includes(value) +} + export type DocumentSortField = | 'filename' | 'fileSize' diff --git a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts index 24750a15f7c..7b9e7368328 100644 --- a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts @@ -433,16 +433,16 @@ describe('trackChatUpload', () => { expect(dbChainMockFns.where.mock.calls.at(-1)?.[0]).toEqual({ type: 'and', conditions: [ - { type: 'eq', left: 'id', right: 'wf_mine' }, - { type: 'eq', left: 'userId', right: USER_ID }, - { type: 'eq', left: 'workspaceId', right: WORKSPACE_ID }, - { type: 'eq', left: 'context', right: 'mothership' }, - { type: 'isNull', column: 'deletedAt' }, + { type: 'eq', left: 'workspaceFiles.id', right: 'wf_mine' }, + { type: 'eq', left: 'workspaceFiles.userId', right: USER_ID }, + { type: 'eq', left: 'workspaceFiles.workspaceId', right: WORKSPACE_ID }, + { type: 'eq', left: 'workspaceFiles.context', right: 'mothership' }, + { type: 'isNull', column: 'workspaceFiles.deletedAt' }, { type: 'or', conditions: [ - { type: 'isNull', column: 'chatId' }, - { type: 'eq', left: 'chatId', right: CHAT_ID }, + { type: 'isNull', column: 'workspaceFiles.chatId' }, + { type: 'eq', left: 'workspaceFiles.chatId', right: CHAT_ID }, ], }, ], diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts index 124c66e0337..62bb88fc54f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts @@ -163,19 +163,19 @@ describe('workspace file secret provenance', () => { expect(dbChainMockFns.where.mock.calls.at(-1)?.[0]).toEqual({ type: 'and', conditions: [ - { type: 'eq', left: 'id', right: 'file-1' }, - { type: 'gte', left: 'contentUpdatedAt', right: CONTENT_UPDATED_AT }, + { type: 'eq', left: 'workspaceFiles.id', right: 'file-1' }, + { type: 'gte', left: 'workspaceFiles.contentUpdatedAt', right: CONTENT_UPDATED_AT }, { type: 'lt', - left: 'contentUpdatedAt', + left: 'workspaceFiles.contentUpdatedAt', right: new Date(CONTENT_UPDATED_AT.getTime() + 1), }, - { type: 'inArray', column: 'context', values: ['workspace', 'mothership'] }, + { type: 'inArray', column: 'workspaceFiles.context', values: ['workspace', 'mothership'] }, { type: 'or', conditions: [ - { type: 'isNull', column: 'secretProvenanceVersion' }, - { type: 'eq', left: 'secretProvenanceVersion', right: 1 }, + { type: 'isNull', column: 'workspaceFiles.secretProvenanceVersion' }, + { type: 'eq', left: 'workspaceFiles.secretProvenanceVersion', right: 1 }, ], }, ], diff --git a/apps/sim/lib/webhooks/path-claims.test.ts b/apps/sim/lib/webhooks/path-claims.test.ts index 3ab2a0b1691..32f0242ebcf 100644 --- a/apps/sim/lib/webhooks/path-claims.test.ts +++ b/apps/sim/lib/webhooks/path-claims.test.ts @@ -61,7 +61,7 @@ function createClaimTx(claims: Map): DbOrTx { from: () => ({ where: (condition: Condition) => ({ limit: async () => { - const path = conditionValue(condition, 'path') as string + const path = conditionValue(condition, 'webhookPathClaim.path') as string const current = claims.get(path) return current ? [current] : [] }, diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index aaf109c3cc0..149c4efced5 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -1026,17 +1026,17 @@ describe('PauseResumeManager paused cancellation after pause release', () => { const casConditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) expect(casConditions).toContainEqual({ type: 'eq', - left: 'executionId', + left: 'workflowExecutionLogs.executionId', right: 'execution-1', }) expect(casConditions).toContainEqual({ type: 'eq', - left: 'workflowId', + left: 'workflowExecutionLogs.workflowId', right: 'workflow-1', }) expect(casConditions).toContainEqual({ type: 'inArray', - column: 'status', + column: 'workflowExecutionLogs.status', values: ['running', 'pending', 'cancelled'], }) }) @@ -1131,17 +1131,17 @@ describe('PauseResumeManager paused cancellation after pause release', () => { const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls[0]?.[0]) expect(conditions).toContainEqual({ type: 'eq', - left: 'parentExecutionId', + left: 'resumeQueue.parentExecutionId', right: 'execution-1', }) expect(conditions).toContainEqual({ type: 'eq', - left: 'workflowId', + left: 'pausedExecutions.workflowId', right: 'workflow-1', }) expect(conditions).toContainEqual({ type: 'eq', - left: 'status', + left: 'resumeQueue.status', right: 'claimed', }) }) @@ -1305,7 +1305,7 @@ describe('PauseResumeManager paused cancellation after pause release', () => { ) expect(queueRestoreConditions).toContainEqual({ type: 'eq', - left: 'failureReason', + left: 'resumeQueue.failureReason', right: 'Paused execution cancellation requested', }) expect(processQueuedResumesSpy).toHaveBeenCalledWith('execution-1', 'workflow-1') @@ -1798,11 +1798,12 @@ describe('PauseResumeManager resume log claims', () => { executionDeadlineAt, }) const statusGuard = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).find( - (condition) => condition.type === 'inArray' && condition.column === 'status' + (condition) => + condition.type === 'inArray' && condition.column === 'workflowExecutionLogs.status' ) expect(statusGuard).toEqual({ type: 'inArray', - column: 'status', + column: 'workflowExecutionLogs.status', values: ['pending', 'paused'], }) }) diff --git a/packages/db/migrations/0298_nasty_madrox.sql b/packages/db/migrations/0298_nasty_madrox.sql deleted file mode 100644 index b9332aabfa5..00000000000 --- a/packages/db/migrations/0298_nasty_madrox.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "knowledge_connector" ADD COLUMN "sync_lock_token" text; \ No newline at end of file diff --git a/packages/db/migrations/0298_shallow_silver_sable.sql b/packages/db/migrations/0298_shallow_silver_sable.sql new file mode 100644 index 00000000000..2cd7e9778d0 --- /dev/null +++ b/packages/db/migrations/0298_shallow_silver_sable.sql @@ -0,0 +1,2 @@ +ALTER TABLE "document" ADD COLUMN "processing_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "knowledge_connector" ADD COLUMN "sync_lock_token" text; \ No newline at end of file diff --git a/packages/db/migrations/meta/0298_snapshot.json b/packages/db/migrations/meta/0298_snapshot.json index 280fb370bf0..6ffd7f840d6 100644 --- a/packages/db/migrations/meta/0298_snapshot.json +++ b/packages/db/migrations/meta/0298_snapshot.json @@ -1,5 +1,5 @@ { - "id": "f97abcd2-e8ef-4ab8-af02-6bae4d9bf64c", + "id": "7d6f83e8-3845-40a9-b29d-81764850f548", "prevId": "c9c21e6c-7324-484b-b303-ab7b4fd9ab6d", "version": "7", "dialect": "postgresql", @@ -4775,6 +4775,13 @@ "notNull": true, "default": "'pending'" }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "processing_queued_at": { "name": "processing_queued_at", "type": "timestamp", diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f0631bbfb1e..be480c8543b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2084,8 +2084,8 @@ { "idx": 298, "version": "7", - "when": 1787277851960, - "tag": "0298_nasty_madrox", + "when": 1787282609732, + "tag": "0298_shallow_silver_sable", "breakpoints": true } ] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a62e33f1c89..2785081a9a4 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2547,6 +2547,17 @@ export const document = pgTable( // Processing status processingStatus: text('processing_status').notNull().default('pending'), // 'pending', 'processing', 'completed', 'failed' + /** + * Dispatches spent on this document since its last successful pass. + * + * A bounded retry budget, not a dispatch generation. The stuck-document + * sweep re-dispatches a failing document every sync for the whole retry + * window, and each dispatch re-parses and re-embeds it — so a document that + * fails deterministically was billed once per sync indefinitely. Past the + * budget it becomes a dead letter: still visible and still user-retryable, + * but no longer swept. Reset to 0 whenever a pass completes. + */ + processingAttempts: integer('processing_attempts').notNull().default(0), /** * When indexing was last dispatched to a worker, which is not when a worker * picked it up — a document sits at `pending` in between. Recovery sweeps diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 6b13d139a1d..c46d957e212 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1,559 +1,570 @@ /** * Comprehensive mock for `@sim/db/schema`. - * Every exported table maps each column to its own name as a string, - * which satisfies drizzle column references used in query builders. + * + * Every exported table maps each column to a `table.column` string, which + * satisfies the drizzle column references used in query builders while keeping + * columns distinguishable ACROSS tables. + * + * The qualification is load-bearing, not cosmetic. When every column was its own + * bare name, `knowledgeConnector.id`, `document.id` and `knowledgeConnectorSyncLog.id` + * were all the string `'id'`, so any assertion of the form + * `node.left === schemaMock..` passed for the wrong table — and a + * predicate guarding the wrong table's column was indistinguishable from the + * right one. Rendered SQL cannot cover the gap either: `createMockSql` renders + * every interpolation as `?`, so the bound `values` are the only place a + * predicate's operands are observable at all. */ export const schemaMock = { user: { - id: 'id', - name: 'name', - email: 'email', - normalizedEmail: 'normalizedEmail', - emailVerified: 'emailVerified', - image: 'image', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - stripeCustomerId: 'stripeCustomerId', - role: 'role', - banned: 'banned', - banReason: 'banReason', - banExpires: 'banExpires', + id: 'user.id', + name: 'user.name', + email: 'user.email', + normalizedEmail: 'user.normalizedEmail', + emailVerified: 'user.emailVerified', + image: 'user.image', + createdAt: 'user.createdAt', + updatedAt: 'user.updatedAt', + stripeCustomerId: 'user.stripeCustomerId', + role: 'user.role', + banned: 'user.banned', + banReason: 'user.banReason', + banExpires: 'user.banExpires', }, session: { - id: 'id', - expiresAt: 'expiresAt', - token: 'token', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - ipAddress: 'ipAddress', - userAgent: 'userAgent', - userId: 'userId', - activeOrganizationId: 'activeOrganizationId', - impersonatedBy: 'impersonatedBy', + id: 'session.id', + expiresAt: 'session.expiresAt', + token: 'session.token', + createdAt: 'session.createdAt', + updatedAt: 'session.updatedAt', + ipAddress: 'session.ipAddress', + userAgent: 'session.userAgent', + userId: 'session.userId', + activeOrganizationId: 'session.activeOrganizationId', + impersonatedBy: 'session.impersonatedBy', }, account: { - id: 'id', - accountId: 'accountId', - providerId: 'providerId', - userId: 'userId', - accessToken: 'accessToken', - refreshToken: 'refreshToken', - idToken: 'idToken', - accessTokenExpiresAt: 'accessTokenExpiresAt', - refreshTokenExpiresAt: 'refreshTokenExpiresAt', - scope: 'scope', - password: 'password', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'account.id', + accountId: 'account.accountId', + providerId: 'account.providerId', + userId: 'account.userId', + accessToken: 'account.accessToken', + refreshToken: 'account.refreshToken', + idToken: 'account.idToken', + accessTokenExpiresAt: 'account.accessTokenExpiresAt', + refreshTokenExpiresAt: 'account.refreshTokenExpiresAt', + scope: 'account.scope', + password: 'account.password', + createdAt: 'account.createdAt', + updatedAt: 'account.updatedAt', }, outboxEvent: { - id: 'id', - eventType: 'eventType', - payload: 'payload', - status: 'status', - attempts: 'attempts', - maxAttempts: 'maxAttempts', - availableAt: 'availableAt', - lockedAt: 'lockedAt', - lastError: 'lastError', - createdAt: 'createdAt', - processedAt: 'processedAt', + id: 'outboxEvent.id', + eventType: 'outboxEvent.eventType', + payload: 'outboxEvent.payload', + status: 'outboxEvent.status', + attempts: 'outboxEvent.attempts', + maxAttempts: 'outboxEvent.maxAttempts', + availableAt: 'outboxEvent.availableAt', + lockedAt: 'outboxEvent.lockedAt', + lastError: 'outboxEvent.lastError', + createdAt: 'outboxEvent.createdAt', + processedAt: 'outboxEvent.processedAt', }, verification: { - id: 'id', - identifier: 'identifier', - value: 'value', - expiresAt: 'expiresAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'verification.id', + identifier: 'verification.identifier', + value: 'verification.value', + expiresAt: 'verification.expiresAt', + createdAt: 'verification.createdAt', + updatedAt: 'verification.updatedAt', }, folder: { - id: 'id', - resourceType: 'resourceType', - name: 'name', - userId: 'userId', - workspaceId: 'workspaceId', - parentId: 'parentId', - locked: 'locked', - sortOrder: 'sortOrder', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - deletedAt: 'deletedAt', + id: 'folder.id', + resourceType: 'folder.resourceType', + name: 'folder.name', + userId: 'folder.userId', + workspaceId: 'folder.workspaceId', + parentId: 'folder.parentId', + locked: 'folder.locked', + sortOrder: 'folder.sortOrder', + createdAt: 'folder.createdAt', + updatedAt: 'folder.updatedAt', + deletedAt: 'folder.deletedAt', }, pinnedItem: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - resourceType: 'resourceType', - resourceId: 'resourceId', - pinnedAt: 'pinnedAt', + id: 'pinnedItem.id', + userId: 'pinnedItem.userId', + workspaceId: 'pinnedItem.workspaceId', + resourceType: 'pinnedItem.resourceType', + resourceId: 'pinnedItem.resourceId', + pinnedAt: 'pinnedItem.pinnedAt', }, workflow: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - folderId: 'folderId', - sortOrder: 'sortOrder', - name: 'name', - description: 'description', - lastSynced: 'lastSynced', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - isDeployed: 'isDeployed', - deployedAt: 'deployedAt', - isPublicApi: 'isPublicApi', - runCount: 'runCount', - lastRunAt: 'lastRunAt', - variables: 'variables', - archivedAt: 'archivedAt', + id: 'workflow.id', + userId: 'workflow.userId', + workspaceId: 'workflow.workspaceId', + folderId: 'workflow.folderId', + sortOrder: 'workflow.sortOrder', + name: 'workflow.name', + description: 'workflow.description', + lastSynced: 'workflow.lastSynced', + createdAt: 'workflow.createdAt', + updatedAt: 'workflow.updatedAt', + isDeployed: 'workflow.isDeployed', + deployedAt: 'workflow.deployedAt', + isPublicApi: 'workflow.isPublicApi', + runCount: 'workflow.runCount', + lastRunAt: 'workflow.lastRunAt', + variables: 'workflow.variables', + archivedAt: 'workflow.archivedAt', }, workflowBlocks: { - id: 'id', - workflowId: 'workflowId', - type: 'type', - name: 'name', - positionX: 'positionX', - positionY: 'positionY', - enabled: 'enabled', - horizontalHandles: 'horizontalHandles', - isWide: 'isWide', - advancedMode: 'advancedMode', - triggerMode: 'triggerMode', - locked: 'locked', - height: 'height', - subBlocks: 'subBlocks', - outputs: 'outputs', - data: 'data', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowBlocks.id', + workflowId: 'workflowBlocks.workflowId', + type: 'workflowBlocks.type', + name: 'workflowBlocks.name', + positionX: 'workflowBlocks.positionX', + positionY: 'workflowBlocks.positionY', + enabled: 'workflowBlocks.enabled', + horizontalHandles: 'workflowBlocks.horizontalHandles', + isWide: 'workflowBlocks.isWide', + advancedMode: 'workflowBlocks.advancedMode', + triggerMode: 'workflowBlocks.triggerMode', + locked: 'workflowBlocks.locked', + height: 'workflowBlocks.height', + subBlocks: 'workflowBlocks.subBlocks', + outputs: 'workflowBlocks.outputs', + data: 'workflowBlocks.data', + createdAt: 'workflowBlocks.createdAt', + updatedAt: 'workflowBlocks.updatedAt', }, workflowEdges: { - id: 'id', - workflowId: 'workflowId', - sourceBlockId: 'sourceBlockId', - targetBlockId: 'targetBlockId', - sourceHandle: 'sourceHandle', - targetHandle: 'targetHandle', - createdAt: 'createdAt', + id: 'workflowEdges.id', + workflowId: 'workflowEdges.workflowId', + sourceBlockId: 'workflowEdges.sourceBlockId', + targetBlockId: 'workflowEdges.targetBlockId', + sourceHandle: 'workflowEdges.sourceHandle', + targetHandle: 'workflowEdges.targetHandle', + createdAt: 'workflowEdges.createdAt', }, workflowSubflows: { - id: 'id', - workflowId: 'workflowId', - type: 'type', - config: 'config', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowSubflows.id', + workflowId: 'workflowSubflows.workflowId', + type: 'workflowSubflows.type', + config: 'workflowSubflows.config', + createdAt: 'workflowSubflows.createdAt', + updatedAt: 'workflowSubflows.updatedAt', }, waitlist: { - id: 'id', - email: 'email', - status: 'status', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'waitlist.id', + email: 'waitlist.email', + status: 'waitlist.status', + createdAt: 'waitlist.createdAt', + updatedAt: 'waitlist.updatedAt', }, workflowExecutionSnapshots: { - id: 'id', - workflowId: 'workflowId', - stateHash: 'stateHash', - stateData: 'stateData', - createdAt: 'createdAt', + id: 'workflowExecutionSnapshots.id', + workflowId: 'workflowExecutionSnapshots.workflowId', + stateHash: 'workflowExecutionSnapshots.stateHash', + stateData: 'workflowExecutionSnapshots.stateData', + createdAt: 'workflowExecutionSnapshots.createdAt', }, secretUsage: { - id: 'id', - workspaceId: 'workspaceId', - secretName: 'secretName', - secretScope: 'secretScope', - source: 'source', - workflowId: 'workflowId', - actorUserId: 'actorUserId', - usageDate: 'usageDate', - useCount: 'useCount', - firstUsedAt: 'firstUsedAt', - lastUsedAt: 'lastUsedAt', - lastExecutionId: 'lastExecutionId', - lastTrigger: 'lastTrigger', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'secretUsage.id', + workspaceId: 'secretUsage.workspaceId', + secretName: 'secretUsage.secretName', + secretScope: 'secretUsage.secretScope', + source: 'secretUsage.source', + workflowId: 'secretUsage.workflowId', + actorUserId: 'secretUsage.actorUserId', + usageDate: 'secretUsage.usageDate', + useCount: 'secretUsage.useCount', + firstUsedAt: 'secretUsage.firstUsedAt', + lastUsedAt: 'secretUsage.lastUsedAt', + lastExecutionId: 'secretUsage.lastExecutionId', + lastTrigger: 'secretUsage.lastTrigger', + createdAt: 'secretUsage.createdAt', + updatedAt: 'secretUsage.updatedAt', }, workflowExecutionLogs: { - id: 'id', - workflowId: 'workflowId', - workspaceId: 'workspaceId', - executionId: 'executionId', - stateSnapshotId: 'stateSnapshotId', - deploymentVersionId: 'deploymentVersionId', - level: 'level', - status: 'status', - trigger: 'trigger', - startedAt: 'startedAt', - executionDeadlineAt: 'executionDeadlineAt', - endedAt: 'endedAt', - totalDurationMs: 'totalDurationMs', - executionData: 'executionData', - cost: 'cost', - files: 'files', - createdAt: 'createdAt', + id: 'workflowExecutionLogs.id', + workflowId: 'workflowExecutionLogs.workflowId', + workspaceId: 'workflowExecutionLogs.workspaceId', + executionId: 'workflowExecutionLogs.executionId', + stateSnapshotId: 'workflowExecutionLogs.stateSnapshotId', + deploymentVersionId: 'workflowExecutionLogs.deploymentVersionId', + level: 'workflowExecutionLogs.level', + status: 'workflowExecutionLogs.status', + trigger: 'workflowExecutionLogs.trigger', + startedAt: 'workflowExecutionLogs.startedAt', + executionDeadlineAt: 'workflowExecutionLogs.executionDeadlineAt', + endedAt: 'workflowExecutionLogs.endedAt', + totalDurationMs: 'workflowExecutionLogs.totalDurationMs', + executionData: 'workflowExecutionLogs.executionData', + cost: 'workflowExecutionLogs.cost', + files: 'workflowExecutionLogs.files', + createdAt: 'workflowExecutionLogs.createdAt', }, executionLargeValues: { - key: 'key', - workspaceId: 'workspaceId', - workflowId: 'workflowId', - ownerExecutionId: 'ownerExecutionId', - size: 'size', - createdAt: 'createdAt', - deletedAt: 'deletedAt', + key: 'executionLargeValues.key', + workspaceId: 'executionLargeValues.workspaceId', + workflowId: 'executionLargeValues.workflowId', + ownerExecutionId: 'executionLargeValues.ownerExecutionId', + size: 'executionLargeValues.size', + createdAt: 'executionLargeValues.createdAt', + deletedAt: 'executionLargeValues.deletedAt', }, executionLargeValueReferences: { - key: 'key', - executionId: 'executionId', - source: 'source', - workspaceId: 'workspaceId', - workflowId: 'workflowId', - createdAt: 'createdAt', + key: 'executionLargeValueReferences.key', + executionId: 'executionLargeValueReferences.executionId', + source: 'executionLargeValueReferences.source', + workspaceId: 'executionLargeValueReferences.workspaceId', + workflowId: 'executionLargeValueReferences.workflowId', + createdAt: 'executionLargeValueReferences.createdAt', }, executionLargeValueDependencies: { - parentKey: 'parentKey', - childKey: 'childKey', - workspaceId: 'workspaceId', - createdAt: 'createdAt', + parentKey: 'executionLargeValueDependencies.parentKey', + childKey: 'executionLargeValueDependencies.childKey', + workspaceId: 'executionLargeValueDependencies.workspaceId', + createdAt: 'executionLargeValueDependencies.createdAt', }, pausedExecutions: { - id: 'id', - workflowId: 'workflowId', - executionId: 'executionId', - executionSnapshot: 'executionSnapshot', - pausePoints: 'pausePoints', - totalPauseCount: 'totalPauseCount', - resumedCount: 'resumedCount', - automaticResumeRetryCount: 'automaticResumeRetryCount', - status: 'status', - metadata: 'metadata', - pausedAt: 'pausedAt', - updatedAt: 'updatedAt', - expiresAt: 'expiresAt', - nextResumeAt: 'nextResumeAt', + id: 'pausedExecutions.id', + workflowId: 'pausedExecutions.workflowId', + executionId: 'pausedExecutions.executionId', + executionSnapshot: 'pausedExecutions.executionSnapshot', + pausePoints: 'pausedExecutions.pausePoints', + totalPauseCount: 'pausedExecutions.totalPauseCount', + resumedCount: 'pausedExecutions.resumedCount', + automaticResumeRetryCount: 'pausedExecutions.automaticResumeRetryCount', + status: 'pausedExecutions.status', + metadata: 'pausedExecutions.metadata', + pausedAt: 'pausedExecutions.pausedAt', + updatedAt: 'pausedExecutions.updatedAt', + expiresAt: 'pausedExecutions.expiresAt', + nextResumeAt: 'pausedExecutions.nextResumeAt', }, resumeQueue: { - id: 'id', - pausedExecutionId: 'pausedExecutionId', - parentExecutionId: 'parentExecutionId', - newExecutionId: 'newExecutionId', - contextId: 'contextId', - resumeInput: 'resumeInput', - status: 'status', - queuedAt: 'queuedAt', - claimedAt: 'claimedAt', - completedAt: 'completedAt', - failureReason: 'failureReason', + id: 'resumeQueue.id', + pausedExecutionId: 'resumeQueue.pausedExecutionId', + parentExecutionId: 'resumeQueue.parentExecutionId', + newExecutionId: 'resumeQueue.newExecutionId', + contextId: 'resumeQueue.contextId', + resumeInput: 'resumeQueue.resumeInput', + status: 'resumeQueue.status', + queuedAt: 'resumeQueue.queuedAt', + claimedAt: 'resumeQueue.claimedAt', + completedAt: 'resumeQueue.completedAt', + failureReason: 'resumeQueue.failureReason', }, environment: { - id: 'id', - userId: 'userId', - variables: 'variables', - updatedAt: 'updatedAt', + id: 'environment.id', + userId: 'environment.userId', + variables: 'environment.variables', + updatedAt: 'environment.updatedAt', }, workspaceEnvironment: { - id: 'id', - workspaceId: 'workspaceId', - variables: 'variables', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceEnvironment.id', + workspaceId: 'workspaceEnvironment.workspaceId', + variables: 'workspaceEnvironment.variables', + createdAt: 'workspaceEnvironment.createdAt', + updatedAt: 'workspaceEnvironment.updatedAt', }, workspaceSandbox: { - id: 'id', - workspaceId: 'workspaceId', - name: 'name', - language: 'language', - dependencies: 'dependencies', - specHash: 'specHash', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceSandbox.id', + workspaceId: 'workspaceSandbox.workspaceId', + name: 'workspaceSandbox.name', + language: 'workspaceSandbox.language', + dependencies: 'workspaceSandbox.dependencies', + specHash: 'workspaceSandbox.specHash', + createdBy: 'workspaceSandbox.createdBy', + createdAt: 'workspaceSandbox.createdAt', + updatedAt: 'workspaceSandbox.updatedAt', }, sandboxImage: { - id: 'id', - provider: 'provider', - specHash: 'specHash', - spec: 'spec', - status: 'status', - imageRef: 'imageRef', - providerImageId: 'providerImageId', - buildId: 'buildId', - errorCode: 'errorCode', - errorMessage: 'errorMessage', - errorDetail: 'errorDetail', - lastUsedAt: 'lastUsedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'sandboxImage.id', + provider: 'sandboxImage.provider', + specHash: 'sandboxImage.specHash', + spec: 'sandboxImage.spec', + status: 'sandboxImage.status', + imageRef: 'sandboxImage.imageRef', + providerImageId: 'sandboxImage.providerImageId', + buildId: 'sandboxImage.buildId', + errorCode: 'sandboxImage.errorCode', + errorMessage: 'sandboxImage.errorMessage', + errorDetail: 'sandboxImage.errorDetail', + lastUsedAt: 'sandboxImage.lastUsedAt', + createdAt: 'sandboxImage.createdAt', + updatedAt: 'sandboxImage.updatedAt', }, workspaceBYOKKeys: { - id: 'id', - workspaceId: 'workspaceId', - providerId: 'providerId', - encryptedApiKey: 'encryptedApiKey', - name: 'name', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceBYOKKeys.id', + workspaceId: 'workspaceBYOKKeys.workspaceId', + providerId: 'workspaceBYOKKeys.providerId', + encryptedApiKey: 'workspaceBYOKKeys.encryptedApiKey', + name: 'workspaceBYOKKeys.name', + createdBy: 'workspaceBYOKKeys.createdBy', + createdAt: 'workspaceBYOKKeys.createdAt', + updatedAt: 'workspaceBYOKKeys.updatedAt', }, organizationBYOKKeys: { - id: 'id', - organizationId: 'organizationId', - providerId: 'providerId', - encryptedApiKey: 'encryptedApiKey', - name: 'name', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'organizationBYOKKeys.id', + organizationId: 'organizationBYOKKeys.organizationId', + providerId: 'organizationBYOKKeys.providerId', + encryptedApiKey: 'organizationBYOKKeys.encryptedApiKey', + name: 'organizationBYOKKeys.name', + createdBy: 'organizationBYOKKeys.createdBy', + createdAt: 'organizationBYOKKeys.createdAt', + updatedAt: 'organizationBYOKKeys.updatedAt', }, settings: { - id: 'id', - userId: 'userId', - theme: 'theme', - autoConnect: 'autoConnect', - telemetryEnabled: 'telemetryEnabled', - emailPreferences: 'emailPreferences', - billingUsageNotificationsEnabled: 'billingUsageNotificationsEnabled', - showTrainingControls: 'showTrainingControls', - superUserModeEnabled: 'superUserModeEnabled', - errorNotificationsEnabled: 'errorNotificationsEnabled', - snapToGridSize: 'snapToGridSize', - showActionBar: 'showActionBar', - autoFocusOnClick: 'autoFocusOnClick', - copilotEnabledModels: 'copilotEnabledModels', - copilotAutoAllowedTools: 'copilotAutoAllowedTools', - lastActiveWorkspaceId: 'lastActiveWorkspaceId', - updatedAt: 'updatedAt', + id: 'settings.id', + userId: 'settings.userId', + theme: 'settings.theme', + autoConnect: 'settings.autoConnect', + telemetryEnabled: 'settings.telemetryEnabled', + emailPreferences: 'settings.emailPreferences', + billingUsageNotificationsEnabled: 'settings.billingUsageNotificationsEnabled', + showTrainingControls: 'settings.showTrainingControls', + superUserModeEnabled: 'settings.superUserModeEnabled', + errorNotificationsEnabled: 'settings.errorNotificationsEnabled', + snapToGridSize: 'settings.snapToGridSize', + showActionBar: 'settings.showActionBar', + autoFocusOnClick: 'settings.autoFocusOnClick', + copilotEnabledModels: 'settings.copilotEnabledModels', + copilotAutoAllowedTools: 'settings.copilotAutoAllowedTools', + lastActiveWorkspaceId: 'settings.lastActiveWorkspaceId', + updatedAt: 'settings.updatedAt', }, workflowSchedule: { - id: 'id', - workflowId: 'workflowId', - deploymentVersionId: 'deploymentVersionId', - deploymentOperationId: 'deploymentOperationId', - blockId: 'blockId', - cronExpression: 'cronExpression', - nextRunAt: 'nextRunAt', - lastRanAt: 'lastRanAt', - lastQueuedAt: 'lastQueuedAt', - triggerType: 'triggerType', - timezone: 'timezone', - failedCount: 'failedCount', - status: 'status', - lastFailedAt: 'lastFailedAt', - sourceType: 'sourceType', - jobTitle: 'jobTitle', - prompt: 'prompt', - lifecycle: 'lifecycle', - successCondition: 'successCondition', - maxRuns: 'maxRuns', - runCount: 'runCount', - sourceChatId: 'sourceChatId', - sourceTaskName: 'sourceTaskName', - sourceUserId: 'sourceUserId', - sourceWorkspaceId: 'sourceWorkspaceId', - jobHistory: 'jobHistory', - archivedAt: 'archivedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowSchedule.id', + workflowId: 'workflowSchedule.workflowId', + deploymentVersionId: 'workflowSchedule.deploymentVersionId', + deploymentOperationId: 'workflowSchedule.deploymentOperationId', + blockId: 'workflowSchedule.blockId', + cronExpression: 'workflowSchedule.cronExpression', + nextRunAt: 'workflowSchedule.nextRunAt', + lastRanAt: 'workflowSchedule.lastRanAt', + lastQueuedAt: 'workflowSchedule.lastQueuedAt', + triggerType: 'workflowSchedule.triggerType', + timezone: 'workflowSchedule.timezone', + failedCount: 'workflowSchedule.failedCount', + status: 'workflowSchedule.status', + lastFailedAt: 'workflowSchedule.lastFailedAt', + sourceType: 'workflowSchedule.sourceType', + jobTitle: 'workflowSchedule.jobTitle', + prompt: 'workflowSchedule.prompt', + lifecycle: 'workflowSchedule.lifecycle', + successCondition: 'workflowSchedule.successCondition', + maxRuns: 'workflowSchedule.maxRuns', + runCount: 'workflowSchedule.runCount', + sourceChatId: 'workflowSchedule.sourceChatId', + sourceTaskName: 'workflowSchedule.sourceTaskName', + sourceUserId: 'workflowSchedule.sourceUserId', + sourceWorkspaceId: 'workflowSchedule.sourceWorkspaceId', + jobHistory: 'workflowSchedule.jobHistory', + archivedAt: 'workflowSchedule.archivedAt', + createdAt: 'workflowSchedule.createdAt', + updatedAt: 'workflowSchedule.updatedAt', }, jobExecutionLogs: { - id: 'id', - scheduleId: 'scheduleId', - workspaceId: 'workspaceId', - executionId: 'executionId', - level: 'level', - status: 'status', - trigger: 'trigger', - startedAt: 'startedAt', - endedAt: 'endedAt', - totalDurationMs: 'totalDurationMs', - executionData: 'executionData', - cost: 'cost', - createdAt: 'createdAt', + id: 'jobExecutionLogs.id', + scheduleId: 'jobExecutionLogs.scheduleId', + workspaceId: 'jobExecutionLogs.workspaceId', + executionId: 'jobExecutionLogs.executionId', + level: 'jobExecutionLogs.level', + status: 'jobExecutionLogs.status', + trigger: 'jobExecutionLogs.trigger', + startedAt: 'jobExecutionLogs.startedAt', + endedAt: 'jobExecutionLogs.endedAt', + totalDurationMs: 'jobExecutionLogs.totalDurationMs', + executionData: 'jobExecutionLogs.executionData', + cost: 'jobExecutionLogs.cost', + createdAt: 'jobExecutionLogs.createdAt', }, webhook: { - id: 'id', - workflowId: 'workflowId', - deploymentVersionId: 'deploymentVersionId', - registrationStatus: 'registrationStatus', - registrationGeneration: 'registrationGeneration', - configFingerprint: 'configFingerprint', - preparedAt: 'preparedAt', - blockId: 'blockId', - path: 'path', - provider: 'provider', - providerConfig: 'providerConfig', - isActive: 'isActive', - failedCount: 'failedCount', - lastFailedAt: 'lastFailedAt', - archivedAt: 'archivedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'webhook.id', + workflowId: 'webhook.workflowId', + deploymentVersionId: 'webhook.deploymentVersionId', + registrationStatus: 'webhook.registrationStatus', + registrationGeneration: 'webhook.registrationGeneration', + configFingerprint: 'webhook.configFingerprint', + preparedAt: 'webhook.preparedAt', + blockId: 'webhook.blockId', + path: 'webhook.path', + provider: 'webhook.provider', + providerConfig: 'webhook.providerConfig', + isActive: 'webhook.isActive', + failedCount: 'webhook.failedCount', + lastFailedAt: 'webhook.lastFailedAt', + archivedAt: 'webhook.archivedAt', + createdAt: 'webhook.createdAt', + updatedAt: 'webhook.updatedAt', }, webhookPathClaim: { - path: 'path', - workflowId: 'workflowId', - generation: 'generation', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + path: 'webhookPathClaim.path', + workflowId: 'webhookPathClaim.workflowId', + generation: 'webhookPathClaim.generation', + createdAt: 'webhookPathClaim.createdAt', + updatedAt: 'webhookPathClaim.updatedAt', }, simTriggerState: { - workflowId: 'workflowId', - blockId: 'blockId', - scopeKey: 'scopeKey', - lastFiredAt: 'lastFiredAt', - updatedAt: 'updatedAt', + workflowId: 'simTriggerState.workflowId', + blockId: 'simTriggerState.blockId', + scopeKey: 'simTriggerState.scopeKey', + lastFiredAt: 'simTriggerState.lastFiredAt', + updatedAt: 'simTriggerState.updatedAt', }, apiKey: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - createdBy: 'createdBy', - name: 'name', - key: 'key', - keyHash: 'keyHash', - type: 'type', - lastUsed: 'lastUsed', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - expiresAt: 'expiresAt', + id: 'apiKey.id', + userId: 'apiKey.userId', + workspaceId: 'apiKey.workspaceId', + createdBy: 'apiKey.createdBy', + name: 'apiKey.name', + key: 'apiKey.key', + keyHash: 'apiKey.keyHash', + type: 'apiKey.type', + lastUsed: 'apiKey.lastUsed', + createdAt: 'apiKey.createdAt', + updatedAt: 'apiKey.updatedAt', + expiresAt: 'apiKey.expiresAt', }, billingBlockedReasonEnum: 'billingBlockedReasonEnum', userStats: { - id: 'id', - userId: 'userId', - totalManualExecutions: 'totalManualExecutions', - totalApiCalls: 'totalApiCalls', - totalWebhookTriggers: 'totalWebhookTriggers', - totalScheduledExecutions: 'totalScheduledExecutions', - totalChatExecutions: 'totalChatExecutions', - totalMcpExecutions: 'totalMcpExecutions', - totalTokensUsed: 'totalTokensUsed', - totalCost: 'totalCost', - currentUsageLimit: 'currentUsageLimit', - usageLimitUpdatedAt: 'usageLimitUpdatedAt', - currentPeriodCost: 'currentPeriodCost', - lastPeriodCost: 'lastPeriodCost', - billedOverageThisPeriod: 'billedOverageThisPeriod', - proPeriodCostSnapshot: 'proPeriodCostSnapshot', - creditBalance: 'creditBalance', - totalCopilotCost: 'totalCopilotCost', - currentPeriodCopilotCost: 'currentPeriodCopilotCost', - lastPeriodCopilotCost: 'lastPeriodCopilotCost', - totalCopilotTokens: 'totalCopilotTokens', - totalCopilotCalls: 'totalCopilotCalls', - totalMcpCopilotCalls: 'totalMcpCopilotCalls', - totalMcpCopilotCost: 'totalMcpCopilotCost', - currentPeriodMcpCopilotCost: 'currentPeriodMcpCopilotCost', - storageUsedBytes: 'storageUsedBytes', - lastActive: 'lastActive', - billingBlocked: 'billingBlocked', - billingBlockedReason: 'billingBlockedReason', + id: 'userStats.id', + userId: 'userStats.userId', + totalManualExecutions: 'userStats.totalManualExecutions', + totalApiCalls: 'userStats.totalApiCalls', + totalWebhookTriggers: 'userStats.totalWebhookTriggers', + totalScheduledExecutions: 'userStats.totalScheduledExecutions', + totalChatExecutions: 'userStats.totalChatExecutions', + totalMcpExecutions: 'userStats.totalMcpExecutions', + totalTokensUsed: 'userStats.totalTokensUsed', + totalCost: 'userStats.totalCost', + currentUsageLimit: 'userStats.currentUsageLimit', + usageLimitUpdatedAt: 'userStats.usageLimitUpdatedAt', + currentPeriodCost: 'userStats.currentPeriodCost', + lastPeriodCost: 'userStats.lastPeriodCost', + billedOverageThisPeriod: 'userStats.billedOverageThisPeriod', + proPeriodCostSnapshot: 'userStats.proPeriodCostSnapshot', + creditBalance: 'userStats.creditBalance', + totalCopilotCost: 'userStats.totalCopilotCost', + currentPeriodCopilotCost: 'userStats.currentPeriodCopilotCost', + lastPeriodCopilotCost: 'userStats.lastPeriodCopilotCost', + totalCopilotTokens: 'userStats.totalCopilotTokens', + totalCopilotCalls: 'userStats.totalCopilotCalls', + totalMcpCopilotCalls: 'userStats.totalMcpCopilotCalls', + totalMcpCopilotCost: 'userStats.totalMcpCopilotCost', + currentPeriodMcpCopilotCost: 'userStats.currentPeriodMcpCopilotCost', + storageUsedBytes: 'userStats.storageUsedBytes', + lastActive: 'userStats.lastActive', + billingBlocked: 'userStats.billingBlocked', + billingBlockedReason: 'userStats.billingBlockedReason', }, customBlock: { - id: 'id', - organizationId: 'organizationId', - workflowId: 'workflowId', - type: 'type', - name: 'name', - description: 'description', - iconUrl: 'iconUrl', - inputs: 'inputs', - outputs: 'outputs', - enabled: 'enabled', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'customBlock.id', + organizationId: 'customBlock.organizationId', + workflowId: 'customBlock.workflowId', + type: 'customBlock.type', + name: 'customBlock.name', + description: 'customBlock.description', + iconUrl: 'customBlock.iconUrl', + inputs: 'customBlock.inputs', + outputs: 'customBlock.outputs', + enabled: 'customBlock.enabled', + createdBy: 'customBlock.createdBy', + createdAt: 'customBlock.createdAt', + updatedAt: 'customBlock.updatedAt', }, customTools: { - id: 'id', - workspaceId: 'workspaceId', - userId: 'userId', - title: 'title', - schema: 'schema', - code: 'code', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'customTools.id', + workspaceId: 'customTools.workspaceId', + userId: 'customTools.userId', + title: 'customTools.title', + schema: 'customTools.schema', + code: 'customTools.code', + createdAt: 'customTools.createdAt', + updatedAt: 'customTools.updatedAt', }, skill: { - id: 'id', - workspaceId: 'workspaceId', - userId: 'userId', - name: 'name', - description: 'description', - content: 'content', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'skill.id', + workspaceId: 'skill.workspaceId', + userId: 'skill.userId', + name: 'skill.name', + description: 'skill.description', + content: 'skill.content', + createdAt: 'skill.createdAt', + updatedAt: 'skill.updatedAt', }, skillMember: { - id: 'id', - skillId: 'skillId', - userId: 'userId', - invitedBy: 'invitedBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'skillMember.id', + skillId: 'skillMember.skillId', + userId: 'skillMember.userId', + invitedBy: 'skillMember.invitedBy', + createdAt: 'skillMember.createdAt', + updatedAt: 'skillMember.updatedAt', }, subscription: { - id: 'id', - plan: 'plan', - referenceId: 'referenceId', - stripeCustomerId: 'stripeCustomerId', - stripeSubscriptionId: 'stripeSubscriptionId', - status: 'status', - periodStart: 'periodStart', - periodEnd: 'periodEnd', - cancelAtPeriodEnd: 'cancelAtPeriodEnd', - seats: 'seats', - trialStart: 'trialStart', - trialEnd: 'trialEnd', - metadata: 'metadata', + id: 'subscription.id', + plan: 'subscription.plan', + referenceId: 'subscription.referenceId', + stripeCustomerId: 'subscription.stripeCustomerId', + stripeSubscriptionId: 'subscription.stripeSubscriptionId', + status: 'subscription.status', + periodStart: 'subscription.periodStart', + periodEnd: 'subscription.periodEnd', + cancelAtPeriodEnd: 'subscription.cancelAtPeriodEnd', + seats: 'subscription.seats', + trialStart: 'subscription.trialStart', + trialEnd: 'subscription.trialEnd', + metadata: 'subscription.metadata', }, rateLimitBucket: { - key: 'key', - tokens: 'tokens', - lastRefillAt: 'lastRefillAt', - updatedAt: 'updatedAt', + key: 'rateLimitBucket.key', + tokens: 'rateLimitBucket.tokens', + lastRefillAt: 'rateLimitBucket.lastRefillAt', + updatedAt: 'rateLimitBucket.updatedAt', }, chat: { - id: 'id', - workflowId: 'workflowId', - userId: 'userId', - identifier: 'identifier', - title: 'title', - description: 'description', - isActive: 'isActive', - customizations: 'customizations', - authType: 'authType', - password: 'password', - allowedEmails: 'allowedEmails', - outputConfigs: 'outputConfigs', - includeThinking: 'includeThinking', - includeToolCalls: 'includeToolCalls', - archivedAt: 'archivedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'chat.id', + workflowId: 'chat.workflowId', + userId: 'chat.userId', + identifier: 'chat.identifier', + title: 'chat.title', + description: 'chat.description', + isActive: 'chat.isActive', + customizations: 'chat.customizations', + authType: 'chat.authType', + password: 'chat.password', + allowedEmails: 'chat.allowedEmails', + outputConfigs: 'chat.outputConfigs', + includeThinking: 'chat.includeThinking', + includeToolCalls: 'chat.includeToolCalls', + archivedAt: 'chat.archivedAt', + createdAt: 'chat.createdAt', + updatedAt: 'chat.updatedAt', }, organization: { - id: 'id', - name: 'name', - slug: 'slug', - logo: 'logo', - metadata: 'metadata', - whitelabelSettings: 'whitelabelSettings', - orgUsageLimit: 'orgUsageLimit', - storageUsedBytes: 'storageUsedBytes', - departedMemberUsage: 'departedMemberUsage', - creditBalance: 'creditBalance', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'organization.id', + name: 'organization.name', + slug: 'organization.slug', + logo: 'organization.logo', + metadata: 'organization.metadata', + whitelabelSettings: 'organization.whitelabelSettings', + orgUsageLimit: 'organization.orgUsageLimit', + storageUsedBytes: 'organization.storageUsedBytes', + departedMemberUsage: 'organization.departedMemberUsage', + creditBalance: 'organization.creditBalance', + createdAt: 'organization.createdAt', + updatedAt: 'organization.updatedAt', }, member: { - id: 'id', - userId: 'userId', - organizationId: 'organizationId', - role: 'role', - createdAt: 'createdAt', + id: 'member.id', + userId: 'member.userId', + organizationId: 'member.organizationId', + role: 'member.role', + createdAt: 'member.createdAt', }, invitationKindEnum: { enumValues: ['organization', 'workspace'] as const }, invitationMembershipIntentEnum: { enumValues: ['internal', 'external'] as const }, @@ -561,560 +572,561 @@ export const schemaMock = { enumValues: ['pending', 'accepted', 'rejected', 'cancelled', 'expired'] as const, }, invitation: { - id: 'id', - kind: 'kind', - email: 'email', - inviterId: 'inviterId', - organizationId: 'organizationId', - membershipIntent: 'membershipIntent', - role: 'role', - status: 'status', - token: 'token', - expiresAt: 'expiresAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'invitation.id', + kind: 'invitation.kind', + email: 'invitation.email', + inviterId: 'invitation.inviterId', + organizationId: 'invitation.organizationId', + membershipIntent: 'invitation.membershipIntent', + role: 'invitation.role', + status: 'invitation.status', + token: 'invitation.token', + expiresAt: 'invitation.expiresAt', + createdAt: 'invitation.createdAt', + updatedAt: 'invitation.updatedAt', }, invitationWorkspaceGrant: { - id: 'id', - invitationId: 'invitationId', - workspaceId: 'workspaceId', - permission: 'permission', - createdAt: 'createdAt', + id: 'invitationWorkspaceGrant.id', + invitationId: 'invitationWorkspaceGrant.invitationId', + workspaceId: 'invitationWorkspaceGrant.workspaceId', + permission: 'invitationWorkspaceGrant.permission', + createdAt: 'invitationWorkspaceGrant.createdAt', }, workspaceModeEnum: { enumValues: ['personal', 'organization', 'grandfathered_shared'] as const, }, workspace: { - id: 'id', - name: 'name', - color: 'color', - logoUrl: 'logoUrl', - ownerId: 'ownerId', - organizationId: 'organizationId', - workspaceMode: 'workspaceMode', - billedAccountUserId: 'billedAccountUserId', - storageUsedBytes: 'storageUsedBytes', - allowPersonalApiKeys: 'allowPersonalApiKeys', - inboxEnabled: 'inboxEnabled', - inboxAddress: 'inboxAddress', - inboxProviderId: 'inboxProviderId', - archivedAt: 'archivedAt', - forkedFromWorkspaceId: 'forkedFromWorkspaceId', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspace.id', + name: 'workspace.name', + color: 'workspace.color', + logoUrl: 'workspace.logoUrl', + ownerId: 'workspace.ownerId', + organizationId: 'workspace.organizationId', + workspaceMode: 'workspace.workspaceMode', + billedAccountUserId: 'workspace.billedAccountUserId', + storageUsedBytes: 'workspace.storageUsedBytes', + allowPersonalApiKeys: 'workspace.allowPersonalApiKeys', + inboxEnabled: 'workspace.inboxEnabled', + inboxAddress: 'workspace.inboxAddress', + inboxProviderId: 'workspace.inboxProviderId', + archivedAt: 'workspace.archivedAt', + forkedFromWorkspaceId: 'workspace.forkedFromWorkspaceId', + createdAt: 'workspace.createdAt', + updatedAt: 'workspace.updatedAt', }, backgroundWorkStatus: { - id: 'id', - workspaceId: 'workspaceId', - workflowId: 'workflowId', - kind: 'kind', - status: 'status', - message: 'message', - error: 'error', - metadata: 'metadata', - startedAt: 'startedAt', - completedAt: 'completedAt', - updatedAt: 'updatedAt', + id: 'backgroundWorkStatus.id', + workspaceId: 'backgroundWorkStatus.workspaceId', + workflowId: 'backgroundWorkStatus.workflowId', + kind: 'backgroundWorkStatus.kind', + status: 'backgroundWorkStatus.status', + message: 'backgroundWorkStatus.message', + error: 'backgroundWorkStatus.error', + metadata: 'backgroundWorkStatus.metadata', + startedAt: 'backgroundWorkStatus.startedAt', + completedAt: 'backgroundWorkStatus.completedAt', + updatedAt: 'backgroundWorkStatus.updatedAt', }, workspaceFile: { - id: 'id', - workspaceId: 'workspaceId', - name: 'name', - key: 'key', - size: 'size', - type: 'type', - uploadedBy: 'uploadedBy', - deletedAt: 'deletedAt', - uploadedAt: 'uploadedAt', + id: 'workspaceFile.id', + workspaceId: 'workspaceFile.workspaceId', + name: 'workspaceFile.name', + key: 'workspaceFile.key', + size: 'workspaceFile.size', + type: 'workspaceFile.type', + uploadedBy: 'workspaceFile.uploadedBy', + deletedAt: 'workspaceFile.deletedAt', + uploadedAt: 'workspaceFile.uploadedAt', }, workspaceFiles: { - id: 'id', - key: 'key', - userId: 'userId', - workspaceId: 'workspaceId', - context: 'context', - chatId: 'chatId', - originalName: 'originalName', - contentType: 'contentType', - size: 'size', - deletedAt: 'deletedAt', - uploadedAt: 'uploadedAt', - updatedAt: 'updatedAt', - contentUpdatedAt: 'contentUpdatedAt', - secretProvenanceVersion: 'secretProvenanceVersion', + id: 'workspaceFiles.id', + key: 'workspaceFiles.key', + userId: 'workspaceFiles.userId', + workspaceId: 'workspaceFiles.workspaceId', + context: 'workspaceFiles.context', + chatId: 'workspaceFiles.chatId', + originalName: 'workspaceFiles.originalName', + contentType: 'workspaceFiles.contentType', + size: 'workspaceFiles.size', + deletedAt: 'workspaceFiles.deletedAt', + uploadedAt: 'workspaceFiles.uploadedAt', + updatedAt: 'workspaceFiles.updatedAt', + contentUpdatedAt: 'workspaceFiles.contentUpdatedAt', + secretProvenanceVersion: 'workspaceFiles.secretProvenanceVersion', }, workspaceFileSecretProvenance: { - fileId: 'fileId', - contentUpdatedAt: 'contentUpdatedAt', - status: 'status', - entries: 'entries', - updatedAt: 'updatedAt', + fileId: 'workspaceFileSecretProvenance.fileId', + contentUpdatedAt: 'workspaceFileSecretProvenance.contentUpdatedAt', + status: 'workspaceFileSecretProvenance.status', + entries: 'workspaceFileSecretProvenance.entries', + updatedAt: 'workspaceFileSecretProvenance.updatedAt', }, uploadSession: { - id: 'id', - tokenHash: 'tokenHash', - userId: 'userId', - workspaceId: 'workspaceId', - knowledgeBaseId: 'knowledgeBaseId', - workflowId: 'workflowId', - executionId: 'executionId', - purpose: 'purpose', - method: 'method', - storageContext: 'storageContext', - finalKey: 'finalKey', - storageProvider: 'storageProvider', - providerUploadId: 'providerUploadId', - providerObjectVersion: 'providerObjectVersion', - fileName: 'fileName', - contentType: 'contentType', - fileSize: 'fileSize', - partSize: 'partSize', - partCount: 'partCount', - status: 'status', - metadata: 'metadata', - processingLeaseId: 'processingLeaseId', - processingLeaseExpiresAt: 'processingLeaseExpiresAt', - completedFileId: 'completedFileId', - error: 'error', - createdAt: 'createdAt', - expiresAt: 'expiresAt', - completedAt: 'completedAt', - updatedAt: 'updatedAt', + id: 'uploadSession.id', + tokenHash: 'uploadSession.tokenHash', + userId: 'uploadSession.userId', + workspaceId: 'uploadSession.workspaceId', + knowledgeBaseId: 'uploadSession.knowledgeBaseId', + workflowId: 'uploadSession.workflowId', + executionId: 'uploadSession.executionId', + purpose: 'uploadSession.purpose', + method: 'uploadSession.method', + storageContext: 'uploadSession.storageContext', + finalKey: 'uploadSession.finalKey', + storageProvider: 'uploadSession.storageProvider', + providerUploadId: 'uploadSession.providerUploadId', + providerObjectVersion: 'uploadSession.providerObjectVersion', + fileName: 'uploadSession.fileName', + contentType: 'uploadSession.contentType', + fileSize: 'uploadSession.fileSize', + partSize: 'uploadSession.partSize', + partCount: 'uploadSession.partCount', + status: 'uploadSession.status', + metadata: 'uploadSession.metadata', + processingLeaseId: 'uploadSession.processingLeaseId', + processingLeaseExpiresAt: 'uploadSession.processingLeaseExpiresAt', + completedFileId: 'uploadSession.completedFileId', + error: 'uploadSession.error', + createdAt: 'uploadSession.createdAt', + expiresAt: 'uploadSession.expiresAt', + completedAt: 'uploadSession.completedAt', + updatedAt: 'uploadSession.updatedAt', }, permissionTypeEnum: 'permissionTypeEnum', workspaceInvitationStatusEnum: 'workspaceInvitationStatusEnum', workspaceInvitation: { - id: 'id', - workspaceId: 'workspaceId', - email: 'email', - inviterId: 'inviterId', - role: 'role', - status: 'status', - token: 'token', - permissions: 'permissions', - orgInvitationId: 'orgInvitationId', - expiresAt: 'expiresAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceInvitation.id', + workspaceId: 'workspaceInvitation.workspaceId', + email: 'workspaceInvitation.email', + inviterId: 'workspaceInvitation.inviterId', + role: 'workspaceInvitation.role', + status: 'workspaceInvitation.status', + token: 'workspaceInvitation.token', + permissions: 'workspaceInvitation.permissions', + orgInvitationId: 'workspaceInvitation.orgInvitationId', + expiresAt: 'workspaceInvitation.expiresAt', + createdAt: 'workspaceInvitation.createdAt', + updatedAt: 'workspaceInvitation.updatedAt', }, permissions: { - id: 'id', - userId: 'userId', - entityType: 'entityType', - entityId: 'entityId', - permissionType: 'permissionType', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'permissions.id', + userId: 'permissions.userId', + entityType: 'permissions.entityType', + entityId: 'permissions.entityId', + permissionType: 'permissions.permissionType', + createdAt: 'permissions.createdAt', + updatedAt: 'permissions.updatedAt', }, memory: { - id: 'id', - workspaceId: 'workspaceId', - key: 'key', - data: 'data', - secretProvenanceVersion: 'secretProvenanceVersion', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - deletedAt: 'deletedAt', + id: 'memory.id', + workspaceId: 'memory.workspaceId', + key: 'memory.key', + data: 'memory.data', + secretProvenanceVersion: 'memory.secretProvenanceVersion', + createdAt: 'memory.createdAt', + updatedAt: 'memory.updatedAt', + deletedAt: 'memory.deletedAt', }, memorySecretProvenance: { - memoryId: 'memoryId', - contentHash: 'contentHash', - status: 'status', - entries: 'entries', - updatedAt: 'updatedAt', + memoryId: 'memorySecretProvenance.memoryId', + contentHash: 'memorySecretProvenance.contentHash', + status: 'memorySecretProvenance.status', + entries: 'memorySecretProvenance.entries', + updatedAt: 'memorySecretProvenance.updatedAt', }, knowledgeBase: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - name: 'name', - description: 'description', - tokenCount: 'tokenCount', - embeddingModel: 'embeddingModel', - embeddingDimension: 'embeddingDimension', - chunkingConfig: 'chunkingConfig', - deletedAt: 'deletedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'knowledgeBase.id', + userId: 'knowledgeBase.userId', + workspaceId: 'knowledgeBase.workspaceId', + name: 'knowledgeBase.name', + description: 'knowledgeBase.description', + tokenCount: 'knowledgeBase.tokenCount', + embeddingModel: 'knowledgeBase.embeddingModel', + embeddingDimension: 'knowledgeBase.embeddingDimension', + chunkingConfig: 'knowledgeBase.chunkingConfig', + deletedAt: 'knowledgeBase.deletedAt', + createdAt: 'knowledgeBase.createdAt', + updatedAt: 'knowledgeBase.updatedAt', }, document: { - id: 'id', - knowledgeBaseId: 'knowledgeBaseId', - filename: 'filename', - fileUrl: 'fileUrl', - fileSize: 'fileSize', - mimeType: 'mimeType', - chunkCount: 'chunkCount', - tokenCount: 'tokenCount', - characterCount: 'characterCount', - processingStatus: 'processingStatus', - processingQueuedAt: 'processingQueuedAt', - processingStartedAt: 'processingStartedAt', - processingCompletedAt: 'processingCompletedAt', - processingError: 'processingError', - enabled: 'enabled', - archivedAt: 'archivedAt', - deletedAt: 'deletedAt', - userExcluded: 'userExcluded', - tag1: 'tag1', - tag2: 'tag2', - tag3: 'tag3', - tag4: 'tag4', - tag5: 'tag5', - tag6: 'tag6', - tag7: 'tag7', - number1: 'number1', - number2: 'number2', - number3: 'number3', - number4: 'number4', - number5: 'number5', - date1: 'date1', - date2: 'date2', - boolean1: 'boolean1', - boolean2: 'boolean2', - boolean3: 'boolean3', - connectorId: 'connectorId', - externalId: 'externalId', - contentHash: 'contentHash', - sourceUrl: 'sourceUrl', - secretProvenanceVersion: 'secretProvenanceVersion', - uploadedAt: 'uploadedAt', + id: 'document.id', + knowledgeBaseId: 'document.knowledgeBaseId', + filename: 'document.filename', + fileUrl: 'document.fileUrl', + fileSize: 'document.fileSize', + mimeType: 'document.mimeType', + chunkCount: 'document.chunkCount', + tokenCount: 'document.tokenCount', + characterCount: 'document.characterCount', + processingStatus: 'document.processingStatus', + processingAttempts: 'document.processingAttempts', + processingQueuedAt: 'document.processingQueuedAt', + processingStartedAt: 'document.processingStartedAt', + processingCompletedAt: 'document.processingCompletedAt', + processingError: 'document.processingError', + enabled: 'document.enabled', + archivedAt: 'document.archivedAt', + deletedAt: 'document.deletedAt', + userExcluded: 'document.userExcluded', + tag1: 'document.tag1', + tag2: 'document.tag2', + tag3: 'document.tag3', + tag4: 'document.tag4', + tag5: 'document.tag5', + tag6: 'document.tag6', + tag7: 'document.tag7', + number1: 'document.number1', + number2: 'document.number2', + number3: 'document.number3', + number4: 'document.number4', + number5: 'document.number5', + date1: 'document.date1', + date2: 'document.date2', + boolean1: 'document.boolean1', + boolean2: 'document.boolean2', + boolean3: 'document.boolean3', + connectorId: 'document.connectorId', + externalId: 'document.externalId', + contentHash: 'document.contentHash', + sourceUrl: 'document.sourceUrl', + secretProvenanceVersion: 'document.secretProvenanceVersion', + uploadedAt: 'document.uploadedAt', }, documentSecretProvenance: { - documentId: 'documentId', - sourceHash: 'sourceHash', - status: 'status', - entries: 'entries', - updatedAt: 'updatedAt', + documentId: 'documentSecretProvenance.documentId', + sourceHash: 'documentSecretProvenance.sourceHash', + status: 'documentSecretProvenance.status', + entries: 'documentSecretProvenance.entries', + updatedAt: 'documentSecretProvenance.updatedAt', }, knowledgeBaseTagDefinitions: { - id: 'id', - knowledgeBaseId: 'knowledgeBaseId', - tagSlot: 'tagSlot', - displayName: 'displayName', - fieldType: 'fieldType', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'knowledgeBaseTagDefinitions.id', + knowledgeBaseId: 'knowledgeBaseTagDefinitions.knowledgeBaseId', + tagSlot: 'knowledgeBaseTagDefinitions.tagSlot', + displayName: 'knowledgeBaseTagDefinitions.displayName', + fieldType: 'knowledgeBaseTagDefinitions.fieldType', + createdAt: 'knowledgeBaseTagDefinitions.createdAt', + updatedAt: 'knowledgeBaseTagDefinitions.updatedAt', }, embedding: { - id: 'id', - knowledgeBaseId: 'knowledgeBaseId', - documentId: 'documentId', - chunkIndex: 'chunkIndex', - chunkHash: 'chunkHash', - content: 'content', - secretProvenanceVersion: 'secretProvenanceVersion', - contentLength: 'contentLength', - tokenCount: 'tokenCount', - embedding: 'embedding', - embeddingModel: 'embeddingModel', - startOffset: 'startOffset', - endOffset: 'endOffset', - tag1: 'tag1', - tag2: 'tag2', - tag3: 'tag3', - tag4: 'tag4', - tag5: 'tag5', - tag6: 'tag6', - tag7: 'tag7', - number1: 'number1', - number2: 'number2', - number3: 'number3', - number4: 'number4', - number5: 'number5', - date1: 'date1', - date2: 'date2', - boolean1: 'boolean1', - boolean2: 'boolean2', - boolean3: 'boolean3', - enabled: 'enabled', - contentTsv: 'contentTsv', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'embedding.id', + knowledgeBaseId: 'embedding.knowledgeBaseId', + documentId: 'embedding.documentId', + chunkIndex: 'embedding.chunkIndex', + chunkHash: 'embedding.chunkHash', + content: 'embedding.content', + secretProvenanceVersion: 'embedding.secretProvenanceVersion', + contentLength: 'embedding.contentLength', + tokenCount: 'embedding.tokenCount', + embedding: 'embedding.embedding', + embeddingModel: 'embedding.embeddingModel', + startOffset: 'embedding.startOffset', + endOffset: 'embedding.endOffset', + tag1: 'embedding.tag1', + tag2: 'embedding.tag2', + tag3: 'embedding.tag3', + tag4: 'embedding.tag4', + tag5: 'embedding.tag5', + tag6: 'embedding.tag6', + tag7: 'embedding.tag7', + number1: 'embedding.number1', + number2: 'embedding.number2', + number3: 'embedding.number3', + number4: 'embedding.number4', + number5: 'embedding.number5', + date1: 'embedding.date1', + date2: 'embedding.date2', + boolean1: 'embedding.boolean1', + boolean2: 'embedding.boolean2', + boolean3: 'embedding.boolean3', + enabled: 'embedding.enabled', + contentTsv: 'embedding.contentTsv', + createdAt: 'embedding.createdAt', + updatedAt: 'embedding.updatedAt', }, embeddingSecretProvenance: { - embeddingId: 'embeddingId', - contentHash: 'contentHash', - status: 'status', - entries: 'entries', - updatedAt: 'updatedAt', + embeddingId: 'embeddingSecretProvenance.embeddingId', + contentHash: 'embeddingSecretProvenance.contentHash', + status: 'embeddingSecretProvenance.status', + entries: 'embeddingSecretProvenance.entries', + updatedAt: 'embeddingSecretProvenance.updatedAt', }, docsEmbeddings: { - chunkId: 'chunkId', - chunkText: 'chunkText', - sourceDocument: 'sourceDocument', - sourceLink: 'sourceLink', - headerText: 'headerText', - headerLevel: 'headerLevel', - tokenCount: 'tokenCount', - embedding: 'embedding', - embeddingModel: 'embeddingModel', - metadata: 'metadata', - chunkTextTsv: 'chunkTextTsv', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + chunkId: 'docsEmbeddings.chunkId', + chunkText: 'docsEmbeddings.chunkText', + sourceDocument: 'docsEmbeddings.sourceDocument', + sourceLink: 'docsEmbeddings.sourceLink', + headerText: 'docsEmbeddings.headerText', + headerLevel: 'docsEmbeddings.headerLevel', + tokenCount: 'docsEmbeddings.tokenCount', + embedding: 'docsEmbeddings.embedding', + embeddingModel: 'docsEmbeddings.embeddingModel', + metadata: 'docsEmbeddings.metadata', + chunkTextTsv: 'docsEmbeddings.chunkTextTsv', + createdAt: 'docsEmbeddings.createdAt', + updatedAt: 'docsEmbeddings.updatedAt', }, chatTypeEnum: 'chatTypeEnum', copilotChats: { - id: 'id', - userId: 'userId', - workflowId: 'workflowId', - workspaceId: 'workspaceId', - type: 'type', - title: 'title', - messages: 'messages', - model: 'model', - conversationId: 'conversationId', - previewYaml: 'previewYaml', - config: 'config', - resources: 'resources', - lastSeenAt: 'lastSeenAt', - pinned: 'pinned', - deletedAt: 'deletedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'copilotChats.id', + userId: 'copilotChats.userId', + workflowId: 'copilotChats.workflowId', + workspaceId: 'copilotChats.workspaceId', + type: 'copilotChats.type', + title: 'copilotChats.title', + messages: 'copilotChats.messages', + model: 'copilotChats.model', + conversationId: 'copilotChats.conversationId', + previewYaml: 'copilotChats.previewYaml', + config: 'copilotChats.config', + resources: 'copilotChats.resources', + lastSeenAt: 'copilotChats.lastSeenAt', + pinned: 'copilotChats.pinned', + deletedAt: 'copilotChats.deletedAt', + createdAt: 'copilotChats.createdAt', + updatedAt: 'copilotChats.updatedAt', }, copilotMessages: { - id: 'id', - chatId: 'chatId', - messageId: 'messageId', - role: 'role', - content: 'content', - streamId: 'streamId', - parentMessageId: 'parentMessageId', - model: 'model', - tokensIn: 'tokensIn', - tokensOut: 'tokensOut', - seq: 'seq', - deletedAt: 'deletedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'copilotMessages.id', + chatId: 'copilotMessages.chatId', + messageId: 'copilotMessages.messageId', + role: 'copilotMessages.role', + content: 'copilotMessages.content', + streamId: 'copilotMessages.streamId', + parentMessageId: 'copilotMessages.parentMessageId', + model: 'copilotMessages.model', + tokensIn: 'copilotMessages.tokensIn', + tokensOut: 'copilotMessages.tokensOut', + seq: 'copilotMessages.seq', + deletedAt: 'copilotMessages.deletedAt', + createdAt: 'copilotMessages.createdAt', + updatedAt: 'copilotMessages.updatedAt', }, copilotWorkflowReadHashes: { - id: 'id', - chatId: 'chatId', - workflowId: 'workflowId', - hash: 'hash', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'copilotWorkflowReadHashes.id', + chatId: 'copilotWorkflowReadHashes.chatId', + workflowId: 'copilotWorkflowReadHashes.workflowId', + hash: 'copilotWorkflowReadHashes.hash', + createdAt: 'copilotWorkflowReadHashes.createdAt', + updatedAt: 'copilotWorkflowReadHashes.updatedAt', }, workflowCheckpoints: { - id: 'id', - userId: 'userId', - workflowId: 'workflowId', - chatId: 'chatId', - messageId: 'messageId', - workflowState: 'workflowState', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowCheckpoints.id', + userId: 'workflowCheckpoints.userId', + workflowId: 'workflowCheckpoints.workflowId', + chatId: 'workflowCheckpoints.chatId', + messageId: 'workflowCheckpoints.messageId', + workflowState: 'workflowCheckpoints.workflowState', + createdAt: 'workflowCheckpoints.createdAt', + updatedAt: 'workflowCheckpoints.updatedAt', }, copilotRunStatusEnum: 'copilotRunStatusEnum', copilotAsyncToolStatusEnum: 'copilotAsyncToolStatusEnum', copilotRuns: { - id: 'id', - executionId: 'executionId', - parentRunId: 'parentRunId', - chatId: 'chatId', - userId: 'userId', - workflowId: 'workflowId', - workspaceId: 'workspaceId', - streamId: 'streamId', - agent: 'agent', - model: 'model', - provider: 'provider', - status: 'status', - requestContext: 'requestContext', - startedAt: 'startedAt', - completedAt: 'completedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - error: 'error', + id: 'copilotRuns.id', + executionId: 'copilotRuns.executionId', + parentRunId: 'copilotRuns.parentRunId', + chatId: 'copilotRuns.chatId', + userId: 'copilotRuns.userId', + workflowId: 'copilotRuns.workflowId', + workspaceId: 'copilotRuns.workspaceId', + streamId: 'copilotRuns.streamId', + agent: 'copilotRuns.agent', + model: 'copilotRuns.model', + provider: 'copilotRuns.provider', + status: 'copilotRuns.status', + requestContext: 'copilotRuns.requestContext', + startedAt: 'copilotRuns.startedAt', + completedAt: 'copilotRuns.completedAt', + createdAt: 'copilotRuns.createdAt', + updatedAt: 'copilotRuns.updatedAt', + error: 'copilotRuns.error', }, copilotRunCheckpoints: { - id: 'id', - runId: 'runId', - pendingToolCallId: 'pendingToolCallId', - conversationSnapshot: 'conversationSnapshot', - agentState: 'agentState', - providerRequest: 'providerRequest', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'copilotRunCheckpoints.id', + runId: 'copilotRunCheckpoints.runId', + pendingToolCallId: 'copilotRunCheckpoints.pendingToolCallId', + conversationSnapshot: 'copilotRunCheckpoints.conversationSnapshot', + agentState: 'copilotRunCheckpoints.agentState', + providerRequest: 'copilotRunCheckpoints.providerRequest', + createdAt: 'copilotRunCheckpoints.createdAt', + updatedAt: 'copilotRunCheckpoints.updatedAt', }, copilotAsyncToolCalls: { - id: 'id', - runId: 'runId', - checkpointId: 'checkpointId', - toolCallId: 'toolCallId', - toolName: 'toolName', - args: 'args', - status: 'status', - result: 'result', - error: 'error', - claimedAt: 'claimedAt', - claimedBy: 'claimedBy', - completedAt: 'completedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'copilotAsyncToolCalls.id', + runId: 'copilotAsyncToolCalls.runId', + checkpointId: 'copilotAsyncToolCalls.checkpointId', + toolCallId: 'copilotAsyncToolCalls.toolCallId', + toolName: 'copilotAsyncToolCalls.toolName', + args: 'copilotAsyncToolCalls.args', + status: 'copilotAsyncToolCalls.status', + result: 'copilotAsyncToolCalls.result', + error: 'copilotAsyncToolCalls.error', + claimedAt: 'copilotAsyncToolCalls.claimedAt', + claimedBy: 'copilotAsyncToolCalls.claimedBy', + completedAt: 'copilotAsyncToolCalls.completedAt', + createdAt: 'copilotAsyncToolCalls.createdAt', + updatedAt: 'copilotAsyncToolCalls.updatedAt', }, copilotFeedback: { - feedbackId: 'feedbackId', - userId: 'userId', - chatId: 'chatId', - userQuery: 'userQuery', - agentResponse: 'agentResponse', - isPositive: 'isPositive', - feedback: 'feedback', - workflowYaml: 'workflowYaml', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + feedbackId: 'copilotFeedback.feedbackId', + userId: 'copilotFeedback.userId', + chatId: 'copilotFeedback.chatId', + userQuery: 'copilotFeedback.userQuery', + agentResponse: 'copilotFeedback.agentResponse', + isPositive: 'copilotFeedback.isPositive', + feedback: 'copilotFeedback.feedback', + workflowYaml: 'copilotFeedback.workflowYaml', + createdAt: 'copilotFeedback.createdAt', + updatedAt: 'copilotFeedback.updatedAt', }, workflowDeploymentVersion: { - id: 'id', - workflowId: 'workflowId', - version: 'version', - name: 'name', - description: 'description', - state: 'state', - isActive: 'isActive', - createdAt: 'createdAt', - createdBy: 'createdBy', + id: 'workflowDeploymentVersion.id', + workflowId: 'workflowDeploymentVersion.workflowId', + version: 'workflowDeploymentVersion.version', + name: 'workflowDeploymentVersion.name', + description: 'workflowDeploymentVersion.description', + state: 'workflowDeploymentVersion.state', + isActive: 'workflowDeploymentVersion.isActive', + createdAt: 'workflowDeploymentVersion.createdAt', + createdBy: 'workflowDeploymentVersion.createdBy', }, workflowDeploymentOperation: { - id: 'id', - workflowId: 'workflowId', - deploymentVersionId: 'deploymentVersionId', - version: 'version', - previousActiveVersionId: 'previousActiveVersionId', - action: 'action', - protocolVersion: 'protocolVersion', - generation: 'generation', - status: 'status', - componentReadiness: 'componentReadiness', - errorCode: 'errorCode', - errorMessage: 'errorMessage', - idempotencyKey: 'idempotencyKey', - requestHash: 'requestHash', - actorId: 'actorId', - completedAt: 'completedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowDeploymentOperation.id', + workflowId: 'workflowDeploymentOperation.workflowId', + deploymentVersionId: 'workflowDeploymentOperation.deploymentVersionId', + version: 'workflowDeploymentOperation.version', + previousActiveVersionId: 'workflowDeploymentOperation.previousActiveVersionId', + action: 'workflowDeploymentOperation.action', + protocolVersion: 'workflowDeploymentOperation.protocolVersion', + generation: 'workflowDeploymentOperation.generation', + status: 'workflowDeploymentOperation.status', + componentReadiness: 'workflowDeploymentOperation.componentReadiness', + errorCode: 'workflowDeploymentOperation.errorCode', + errorMessage: 'workflowDeploymentOperation.errorMessage', + idempotencyKey: 'workflowDeploymentOperation.idempotencyKey', + requestHash: 'workflowDeploymentOperation.requestHash', + actorId: 'workflowDeploymentOperation.actorId', + completedAt: 'workflowDeploymentOperation.completedAt', + createdAt: 'workflowDeploymentOperation.createdAt', + updatedAt: 'workflowDeploymentOperation.updatedAt', }, idempotencyKey: { - key: 'key', - result: 'result', - createdAt: 'createdAt', + key: 'idempotencyKey.key', + result: 'idempotencyKey.result', + createdAt: 'idempotencyKey.createdAt', }, mcpServers: { - id: 'id', - workspaceId: 'workspaceId', - createdBy: 'createdBy', - name: 'name', - description: 'description', - transport: 'transport', - url: 'url', - headers: 'headers', - timeout: 'timeout', - retries: 'retries', - enabled: 'enabled', - lastConnected: 'lastConnected', - connectionStatus: 'connectionStatus', - lastError: 'lastError', - statusConfig: 'statusConfig', - toolCount: 'toolCount', - lastToolsRefresh: 'lastToolsRefresh', - totalRequests: 'totalRequests', - lastUsed: 'lastUsed', - deletedAt: 'deletedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'mcpServers.id', + workspaceId: 'mcpServers.workspaceId', + createdBy: 'mcpServers.createdBy', + name: 'mcpServers.name', + description: 'mcpServers.description', + transport: 'mcpServers.transport', + url: 'mcpServers.url', + headers: 'mcpServers.headers', + timeout: 'mcpServers.timeout', + retries: 'mcpServers.retries', + enabled: 'mcpServers.enabled', + lastConnected: 'mcpServers.lastConnected', + connectionStatus: 'mcpServers.connectionStatus', + lastError: 'mcpServers.lastError', + statusConfig: 'mcpServers.statusConfig', + toolCount: 'mcpServers.toolCount', + lastToolsRefresh: 'mcpServers.lastToolsRefresh', + totalRequests: 'mcpServers.totalRequests', + lastUsed: 'mcpServers.lastUsed', + deletedAt: 'mcpServers.deletedAt', + createdAt: 'mcpServers.createdAt', + updatedAt: 'mcpServers.updatedAt', }, mcpServerOauth: { - id: 'id', - mcpServerId: 'mcpServerId', - userId: 'userId', - workspaceId: 'workspaceId', - clientInformation: 'clientInformation', - tokens: 'tokens', - codeVerifier: 'codeVerifier', - state: 'state', - lastRefreshedAt: 'lastRefreshedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'mcpServerOauth.id', + mcpServerId: 'mcpServerOauth.mcpServerId', + userId: 'mcpServerOauth.userId', + workspaceId: 'mcpServerOauth.workspaceId', + clientInformation: 'mcpServerOauth.clientInformation', + tokens: 'mcpServerOauth.tokens', + codeVerifier: 'mcpServerOauth.codeVerifier', + state: 'mcpServerOauth.state', + lastRefreshedAt: 'mcpServerOauth.lastRefreshedAt', + createdAt: 'mcpServerOauth.createdAt', + updatedAt: 'mcpServerOauth.updatedAt', }, ssoProvider: { - id: 'id', - issuer: 'issuer', - domain: 'domain', - oidcConfig: 'oidcConfig', - samlConfig: 'samlConfig', - userId: 'userId', - providerId: 'providerId', - organizationId: 'organizationId', - domainVerified: 'domainVerified', + id: 'ssoProvider.id', + issuer: 'ssoProvider.issuer', + domain: 'ssoProvider.domain', + oidcConfig: 'ssoProvider.oidcConfig', + samlConfig: 'ssoProvider.samlConfig', + userId: 'ssoProvider.userId', + providerId: 'ssoProvider.providerId', + organizationId: 'ssoProvider.organizationId', + domainVerified: 'ssoProvider.domainVerified', }, ssoDomain: { - id: 'id', - organizationId: 'organizationId', - domain: 'domain', - status: 'status', - verificationToken: 'verificationToken', - verifiedAt: 'verifiedAt', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'ssoDomain.id', + organizationId: 'ssoDomain.organizationId', + domain: 'ssoDomain.domain', + status: 'ssoDomain.status', + verificationToken: 'ssoDomain.verificationToken', + verifiedAt: 'ssoDomain.verifiedAt', + createdBy: 'ssoDomain.createdBy', + createdAt: 'ssoDomain.createdAt', + updatedAt: 'ssoDomain.updatedAt', }, workflowMcpServer: { - id: 'id', - workspaceId: 'workspaceId', - createdBy: 'createdBy', - name: 'name', - description: 'description', - isPublic: 'isPublic', - deletedAt: 'deletedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowMcpServer.id', + workspaceId: 'workflowMcpServer.workspaceId', + createdBy: 'workflowMcpServer.createdBy', + name: 'workflowMcpServer.name', + description: 'workflowMcpServer.description', + isPublic: 'workflowMcpServer.isPublic', + deletedAt: 'workflowMcpServer.deletedAt', + createdAt: 'workflowMcpServer.createdAt', + updatedAt: 'workflowMcpServer.updatedAt', }, workflowMcpTool: { - id: 'id', - serverId: 'serverId', - workflowId: 'workflowId', - toolName: 'toolName', - toolDescription: 'toolDescription', - parameterSchema: 'parameterSchema', - archivedAt: 'archivedAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workflowMcpTool.id', + serverId: 'workflowMcpTool.serverId', + workflowId: 'workflowMcpTool.workflowId', + toolName: 'workflowMcpTool.toolName', + toolDescription: 'workflowMcpTool.toolDescription', + parameterSchema: 'workflowMcpTool.parameterSchema', + archivedAt: 'workflowMcpTool.archivedAt', + createdAt: 'workflowMcpTool.createdAt', + updatedAt: 'workflowMcpTool.updatedAt', }, auditLog: { - id: 'id', - workspaceId: 'workspaceId', - actorId: 'actorId', - action: 'action', - resourceType: 'resourceType', - resourceId: 'resourceId', - actorName: 'actorName', - actorEmail: 'actorEmail', - resourceName: 'resourceName', - description: 'description', - metadata: 'metadata', - ipAddress: 'ipAddress', - userAgent: 'userAgent', - createdAt: 'createdAt', + id: 'auditLog.id', + workspaceId: 'auditLog.workspaceId', + actorId: 'auditLog.actorId', + action: 'auditLog.action', + resourceType: 'auditLog.resourceType', + resourceId: 'auditLog.resourceId', + actorName: 'auditLog.actorName', + actorEmail: 'auditLog.actorEmail', + resourceName: 'auditLog.resourceName', + description: 'auditLog.description', + metadata: 'auditLog.metadata', + ipAddress: 'auditLog.ipAddress', + userAgent: 'auditLog.userAgent', + createdAt: 'auditLog.createdAt', }, usageLogCategoryEnum: 'usageLogCategoryEnum', usageLogSourceEnum: 'usageLogSourceEnum', usageLog: { - id: 'id', - userId: 'userId', - category: 'category', - source: 'source', - description: 'description', - metadata: 'metadata', - cost: 'cost', - workspaceId: 'workspaceId', - workflowId: 'workflowId', - executionId: 'executionId', - createdAt: 'createdAt', + id: 'usageLog.id', + userId: 'usageLog.userId', + category: 'usageLog.category', + source: 'usageLog.source', + description: 'usageLog.description', + metadata: 'usageLog.metadata', + cost: 'usageLog.cost', + workspaceId: 'usageLog.workspaceId', + workflowId: 'usageLog.workflowId', + executionId: 'usageLog.executionId', + createdAt: 'usageLog.createdAt', }, credentialTypeEnum: { enumValues: [ @@ -1129,377 +1141,377 @@ export const schemaMock = { enumValues: ['active', 'needs_reauth', 'revoked'] as const, }, credential: { - id: 'id', - workspaceId: 'workspaceId', - type: 'type', - displayName: 'displayName', - description: 'description', - providerId: 'providerId', - accountId: 'accountId', - envKey: 'envKey', - envOwnerUserId: 'envOwnerUserId', - encryptedServiceAccountKey: 'encryptedServiceAccountKey', - authorizationAppId: 'authorizationAppId', - providerSubjectId: 'providerSubjectId', - providerTenantId: 'providerTenantId', - managedOauthStatus: 'managedOauthStatus', - grantedScopes: 'grantedScopes', - providerMetadata: 'providerMetadata', - encryptedOauthTokenSet: 'encryptedOauthTokenSet', - grantedAt: 'grantedAt', - revokedAt: 'revokedAt', - accessTokenExpiresAt: 'accessTokenExpiresAt', - refreshTokenExpiresAt: 'refreshTokenExpiresAt', - lastRefreshedAt: 'lastRefreshedAt', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'credential.id', + workspaceId: 'credential.workspaceId', + type: 'credential.type', + displayName: 'credential.displayName', + description: 'credential.description', + providerId: 'credential.providerId', + accountId: 'credential.accountId', + envKey: 'credential.envKey', + envOwnerUserId: 'credential.envOwnerUserId', + encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', + authorizationAppId: 'credential.authorizationAppId', + providerSubjectId: 'credential.providerSubjectId', + providerTenantId: 'credential.providerTenantId', + managedOauthStatus: 'credential.managedOauthStatus', + grantedScopes: 'credential.grantedScopes', + providerMetadata: 'credential.providerMetadata', + encryptedOauthTokenSet: 'credential.encryptedOauthTokenSet', + grantedAt: 'credential.grantedAt', + revokedAt: 'credential.revokedAt', + accessTokenExpiresAt: 'credential.accessTokenExpiresAt', + refreshTokenExpiresAt: 'credential.refreshTokenExpiresAt', + lastRefreshedAt: 'credential.lastRefreshedAt', + createdBy: 'credential.createdBy', + createdAt: 'credential.createdAt', + updatedAt: 'credential.updatedAt', }, credentialGroupStatusEnum: { enumValues: ['active', 'disabled'] as const, }, credentialGroup: { - id: 'id', - workspaceId: 'workspaceId', - publicId: 'publicId', - name: 'name', - description: 'description', - options: 'options', - status: 'status', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'credentialGroup.id', + workspaceId: 'credentialGroup.workspaceId', + publicId: 'credentialGroup.publicId', + name: 'credentialGroup.name', + description: 'credentialGroup.description', + options: 'credentialGroup.options', + status: 'credentialGroup.status', + createdBy: 'credentialGroup.createdBy', + createdAt: 'credentialGroup.createdAt', + updatedAt: 'credentialGroup.updatedAt', }, credentialGroupEnrollmentStatusEnum: { enumValues: ['invited', 'delivery_failed', 'in_progress', 'completed', 'revoked'] as const, }, credentialGroupEnrollment: { - id: 'id', - credentialGroupId: 'credentialGroupId', - email: 'email', - status: 'status', - invitationTokenHash: 'invitationTokenHash', - invitationExpiresAt: 'invitationExpiresAt', - invitedAt: 'invitedAt', - sentAt: 'sentAt', - completedAt: 'completedAt', - revokedAt: 'revokedAt', - lastDeliveryError: 'lastDeliveryError', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'credentialGroupEnrollment.id', + credentialGroupId: 'credentialGroupEnrollment.credentialGroupId', + email: 'credentialGroupEnrollment.email', + status: 'credentialGroupEnrollment.status', + invitationTokenHash: 'credentialGroupEnrollment.invitationTokenHash', + invitationExpiresAt: 'credentialGroupEnrollment.invitationExpiresAt', + invitedAt: 'credentialGroupEnrollment.invitedAt', + sentAt: 'credentialGroupEnrollment.sentAt', + completedAt: 'credentialGroupEnrollment.completedAt', + revokedAt: 'credentialGroupEnrollment.revokedAt', + lastDeliveryError: 'credentialGroupEnrollment.lastDeliveryError', + createdBy: 'credentialGroupEnrollment.createdBy', + createdAt: 'credentialGroupEnrollment.createdAt', + updatedAt: 'credentialGroupEnrollment.updatedAt', }, credentialMemberRoleEnum: 'credentialMemberRoleEnum', credentialMemberStatusEnum: 'credentialMemberStatusEnum', credentialMember: { - id: 'id', - credentialId: 'credentialId', - userId: 'userId', - role: 'role', - status: 'status', - joinedAt: 'joinedAt', - invitedBy: 'invitedBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'credentialMember.id', + credentialId: 'credentialMember.credentialId', + userId: 'credentialMember.userId', + role: 'credentialMember.role', + status: 'credentialMember.status', + joinedAt: 'credentialMember.joinedAt', + invitedBy: 'credentialMember.invitedBy', + createdAt: 'credentialMember.createdAt', + updatedAt: 'credentialMember.updatedAt', }, pendingCredentialDraft: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - providerId: 'providerId', - displayName: 'displayName', - description: 'description', - credentialId: 'credentialId', - expiresAt: 'expiresAt', - createdAt: 'createdAt', + id: 'pendingCredentialDraft.id', + userId: 'pendingCredentialDraft.userId', + workspaceId: 'pendingCredentialDraft.workspaceId', + providerId: 'pendingCredentialDraft.providerId', + displayName: 'pendingCredentialDraft.displayName', + description: 'pendingCredentialDraft.description', + credentialId: 'pendingCredentialDraft.credentialId', + expiresAt: 'pendingCredentialDraft.expiresAt', + createdAt: 'pendingCredentialDraft.createdAt', }, permissionGroup: { - id: 'id', - organizationId: 'organizationId', - name: 'name', - description: 'description', - config: 'config', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - isDefault: 'isDefault', + id: 'permissionGroup.id', + organizationId: 'permissionGroup.organizationId', + name: 'permissionGroup.name', + description: 'permissionGroup.description', + config: 'permissionGroup.config', + createdBy: 'permissionGroup.createdBy', + createdAt: 'permissionGroup.createdAt', + updatedAt: 'permissionGroup.updatedAt', + isDefault: 'permissionGroup.isDefault', }, permissionGroupWorkspace: { - id: 'id', - permissionGroupId: 'permissionGroupId', - workspaceId: 'workspaceId', - organizationId: 'organizationId', - createdAt: 'createdAt', + id: 'permissionGroupWorkspace.id', + permissionGroupId: 'permissionGroupWorkspace.permissionGroupId', + workspaceId: 'permissionGroupWorkspace.workspaceId', + organizationId: 'permissionGroupWorkspace.organizationId', + createdAt: 'permissionGroupWorkspace.createdAt', }, permissionGroupMember: { - id: 'id', - permissionGroupId: 'permissionGroupId', - organizationId: 'organizationId', - userId: 'userId', - assignedBy: 'assignedBy', - assignedAt: 'assignedAt', + id: 'permissionGroupMember.id', + permissionGroupId: 'permissionGroupMember.permissionGroupId', + organizationId: 'permissionGroupMember.organizationId', + userId: 'permissionGroupMember.userId', + assignedBy: 'permissionGroupMember.assignedBy', + assignedAt: 'permissionGroupMember.assignedAt', }, asyncJobs: { - id: 'id', - type: 'type', - payload: 'payload', - status: 'status', - createdAt: 'createdAt', - startedAt: 'startedAt', - completedAt: 'completedAt', - runAt: 'runAt', - attempts: 'attempts', - maxAttempts: 'maxAttempts', - error: 'error', - output: 'output', - metadata: 'metadata', - updatedAt: 'updatedAt', + id: 'asyncJobs.id', + type: 'asyncJobs.type', + payload: 'asyncJobs.payload', + status: 'asyncJobs.status', + createdAt: 'asyncJobs.createdAt', + startedAt: 'asyncJobs.startedAt', + completedAt: 'asyncJobs.completedAt', + runAt: 'asyncJobs.runAt', + attempts: 'asyncJobs.attempts', + maxAttempts: 'asyncJobs.maxAttempts', + error: 'asyncJobs.error', + output: 'asyncJobs.output', + metadata: 'asyncJobs.metadata', + updatedAt: 'asyncJobs.updatedAt', }, knowledgeConnector: { - id: 'id', - knowledgeBaseId: 'knowledgeBaseId', - connectorType: 'connectorType', - credentialId: 'credentialId', - encryptedApiKey: 'encryptedApiKey', - sourceConfig: 'sourceConfig', - syncMode: 'syncMode', - syncIntervalMinutes: 'syncIntervalMinutes', - status: 'status', - lastSyncAt: 'lastSyncAt', - lastSyncError: 'lastSyncError', - lastSyncDocCount: 'lastSyncDocCount', - nextSyncAt: 'nextSyncAt', - consecutiveFailures: 'consecutiveFailures', - syncLockToken: 'syncLockToken', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - archivedAt: 'archivedAt', - deletedAt: 'deletedAt', + id: 'knowledgeConnector.id', + knowledgeBaseId: 'knowledgeConnector.knowledgeBaseId', + connectorType: 'knowledgeConnector.connectorType', + credentialId: 'knowledgeConnector.credentialId', + encryptedApiKey: 'knowledgeConnector.encryptedApiKey', + sourceConfig: 'knowledgeConnector.sourceConfig', + syncMode: 'knowledgeConnector.syncMode', + syncIntervalMinutes: 'knowledgeConnector.syncIntervalMinutes', + status: 'knowledgeConnector.status', + lastSyncAt: 'knowledgeConnector.lastSyncAt', + lastSyncError: 'knowledgeConnector.lastSyncError', + lastSyncDocCount: 'knowledgeConnector.lastSyncDocCount', + nextSyncAt: 'knowledgeConnector.nextSyncAt', + consecutiveFailures: 'knowledgeConnector.consecutiveFailures', + syncLockToken: 'knowledgeConnector.syncLockToken', + createdAt: 'knowledgeConnector.createdAt', + updatedAt: 'knowledgeConnector.updatedAt', + archivedAt: 'knowledgeConnector.archivedAt', + deletedAt: 'knowledgeConnector.deletedAt', }, knowledgeConnectorSyncLog: { - id: 'id', - connectorId: 'connectorId', - status: 'status', - startedAt: 'startedAt', - completedAt: 'completedAt', - docsAdded: 'docsAdded', - docsUpdated: 'docsUpdated', - docsDeleted: 'docsDeleted', - docsUnchanged: 'docsUnchanged', - docsFailed: 'docsFailed', - errorMessage: 'errorMessage', + id: 'knowledgeConnectorSyncLog.id', + connectorId: 'knowledgeConnectorSyncLog.connectorId', + status: 'knowledgeConnectorSyncLog.status', + startedAt: 'knowledgeConnectorSyncLog.startedAt', + completedAt: 'knowledgeConnectorSyncLog.completedAt', + docsAdded: 'knowledgeConnectorSyncLog.docsAdded', + docsUpdated: 'knowledgeConnectorSyncLog.docsUpdated', + docsDeleted: 'knowledgeConnectorSyncLog.docsDeleted', + docsUnchanged: 'knowledgeConnectorSyncLog.docsUnchanged', + docsFailed: 'knowledgeConnectorSyncLog.docsFailed', + errorMessage: 'knowledgeConnectorSyncLog.errorMessage', }, userTableDefinitions: { - id: 'id', - workspaceId: 'workspaceId', - name: 'name', - description: 'description', - schema: 'schema', - metadata: 'metadata', - maxRows: 'maxRows', - rowCount: 'rowCount', - rowsVersion: 'rowsVersion', - schemaLocked: 'schemaLocked', - insertLocked: 'insertLocked', - updateLocked: 'updateLocked', - deleteLocked: 'deleteLocked', - archivedAt: 'archivedAt', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'userTableDefinitions.id', + workspaceId: 'userTableDefinitions.workspaceId', + name: 'userTableDefinitions.name', + description: 'userTableDefinitions.description', + schema: 'userTableDefinitions.schema', + metadata: 'userTableDefinitions.metadata', + maxRows: 'userTableDefinitions.maxRows', + rowCount: 'userTableDefinitions.rowCount', + rowsVersion: 'userTableDefinitions.rowsVersion', + schemaLocked: 'userTableDefinitions.schemaLocked', + insertLocked: 'userTableDefinitions.insertLocked', + updateLocked: 'userTableDefinitions.updateLocked', + deleteLocked: 'userTableDefinitions.deleteLocked', + archivedAt: 'userTableDefinitions.archivedAt', + createdBy: 'userTableDefinitions.createdBy', + createdAt: 'userTableDefinitions.createdAt', + updatedAt: 'userTableDefinitions.updatedAt', }, userTableRows: { - id: 'id', - tableId: 'tableId', - workspaceId: 'workspaceId', - data: 'data', - position: 'position', - secretProvenanceVersion: 'secretProvenanceVersion', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - createdBy: 'createdBy', + id: 'userTableRows.id', + tableId: 'userTableRows.tableId', + workspaceId: 'userTableRows.workspaceId', + data: 'userTableRows.data', + position: 'userTableRows.position', + secretProvenanceVersion: 'userTableRows.secretProvenanceVersion', + createdAt: 'userTableRows.createdAt', + updatedAt: 'userTableRows.updatedAt', + createdBy: 'userTableRows.createdBy', }, userTableRowSecretProvenance: { - rowId: 'rowId', - contentUpdatedAt: 'contentUpdatedAt', - status: 'status', - entries: 'entries', - updatedAt: 'updatedAt', + rowId: 'userTableRowSecretProvenance.rowId', + contentUpdatedAt: 'userTableRowSecretProvenance.contentUpdatedAt', + status: 'userTableRowSecretProvenance.status', + entries: 'userTableRowSecretProvenance.entries', + updatedAt: 'userTableRowSecretProvenance.updatedAt', }, tableJobs: { - id: 'id', - tableId: 'tableId', - workspaceId: 'workspaceId', - type: 'type', - status: 'status', - payload: 'payload', - rowsProcessed: 'rowsProcessed', - error: 'error', - startedAt: 'startedAt', - updatedAt: 'updatedAt', - completedAt: 'completedAt', + id: 'tableJobs.id', + tableId: 'tableJobs.tableId', + workspaceId: 'tableJobs.workspaceId', + type: 'tableJobs.type', + status: 'tableJobs.status', + payload: 'tableJobs.payload', + rowsProcessed: 'tableJobs.rowsProcessed', + error: 'tableJobs.error', + startedAt: 'tableJobs.startedAt', + updatedAt: 'tableJobs.updatedAt', + completedAt: 'tableJobs.completedAt', }, tableViews: { - id: 'id', - tableId: 'tableId', - workspaceId: 'workspaceId', - name: 'name', - config: 'config', - isDefault: 'isDefault', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'tableViews.id', + tableId: 'tableViews.tableId', + workspaceId: 'tableViews.workspaceId', + name: 'tableViews.name', + config: 'tableViews.config', + isDefault: 'tableViews.isDefault', + createdBy: 'tableViews.createdBy', + createdAt: 'tableViews.createdAt', + updatedAt: 'tableViews.updatedAt', }, tableRowExecutions: { - tableId: 'tableId', - rowId: 'rowId', - groupId: 'groupId', - status: 'status', - executionId: 'executionId', - jobId: 'jobId', - workflowId: 'workflowId', - error: 'error', - runningBlockIds: 'runningBlockIds', - blockErrors: 'blockErrors', - cancelledAt: 'cancelledAt', - updatedAt: 'updatedAt', + tableId: 'tableRowExecutions.tableId', + rowId: 'tableRowExecutions.rowId', + groupId: 'tableRowExecutions.groupId', + status: 'tableRowExecutions.status', + executionId: 'tableRowExecutions.executionId', + jobId: 'tableRowExecutions.jobId', + workflowId: 'tableRowExecutions.workflowId', + error: 'tableRowExecutions.error', + runningBlockIds: 'tableRowExecutions.runningBlockIds', + blockErrors: 'tableRowExecutions.blockErrors', + cancelledAt: 'tableRowExecutions.cancelledAt', + updatedAt: 'tableRowExecutions.updatedAt', }, tableRunDispatches: { - id: 'id', - tableId: 'tableId', - workspaceId: 'workspaceId', - requestId: 'requestId', - mode: 'mode', - scope: 'scope', - status: 'status', - cursor: 'cursor', - isManualRun: 'isManualRun', - requestedAt: 'requestedAt', - completedAt: 'completedAt', - cancelledAt: 'cancelledAt', + id: 'tableRunDispatches.id', + tableId: 'tableRunDispatches.tableId', + workspaceId: 'tableRunDispatches.workspaceId', + requestId: 'tableRunDispatches.requestId', + mode: 'tableRunDispatches.mode', + scope: 'tableRunDispatches.scope', + status: 'tableRunDispatches.status', + cursor: 'tableRunDispatches.cursor', + isManualRun: 'tableRunDispatches.isManualRun', + requestedAt: 'tableRunDispatches.requestedAt', + completedAt: 'tableRunDispatches.completedAt', + cancelledAt: 'tableRunDispatches.cancelledAt', }, mothershipInboxAllowedSender: { - id: 'id', - workspaceId: 'workspaceId', - email: 'email', - label: 'label', - addedBy: 'addedBy', - createdAt: 'createdAt', + id: 'mothershipInboxAllowedSender.id', + workspaceId: 'mothershipInboxAllowedSender.workspaceId', + email: 'mothershipInboxAllowedSender.email', + label: 'mothershipInboxAllowedSender.label', + addedBy: 'mothershipInboxAllowedSender.addedBy', + createdAt: 'mothershipInboxAllowedSender.createdAt', }, mothershipInboxTask: { - id: 'id', - workspaceId: 'workspaceId', - fromEmail: 'fromEmail', - fromName: 'fromName', - subject: 'subject', - bodyPreview: 'bodyPreview', - bodyText: 'bodyText', - bodyHtml: 'bodyHtml', - emailMessageId: 'emailMessageId', - inReplyTo: 'inReplyTo', - responseMessageId: 'responseMessageId', - agentmailMessageId: 'agentmailMessageId', - status: 'status', - chatId: 'chatId', - triggerJobId: 'triggerJobId', - resultSummary: 'resultSummary', - errorMessage: 'errorMessage', - rejectionReason: 'rejectionReason', - hasAttachments: 'hasAttachments', - ccRecipients: 'ccRecipients', - createdAt: 'createdAt', - processingStartedAt: 'processingStartedAt', - completedAt: 'completedAt', + id: 'mothershipInboxTask.id', + workspaceId: 'mothershipInboxTask.workspaceId', + fromEmail: 'mothershipInboxTask.fromEmail', + fromName: 'mothershipInboxTask.fromName', + subject: 'mothershipInboxTask.subject', + bodyPreview: 'mothershipInboxTask.bodyPreview', + bodyText: 'mothershipInboxTask.bodyText', + bodyHtml: 'mothershipInboxTask.bodyHtml', + emailMessageId: 'mothershipInboxTask.emailMessageId', + inReplyTo: 'mothershipInboxTask.inReplyTo', + responseMessageId: 'mothershipInboxTask.responseMessageId', + agentmailMessageId: 'mothershipInboxTask.agentmailMessageId', + status: 'mothershipInboxTask.status', + chatId: 'mothershipInboxTask.chatId', + triggerJobId: 'mothershipInboxTask.triggerJobId', + resultSummary: 'mothershipInboxTask.resultSummary', + errorMessage: 'mothershipInboxTask.errorMessage', + rejectionReason: 'mothershipInboxTask.rejectionReason', + hasAttachments: 'mothershipInboxTask.hasAttachments', + ccRecipients: 'mothershipInboxTask.ccRecipients', + createdAt: 'mothershipInboxTask.createdAt', + processingStartedAt: 'mothershipInboxTask.processingStartedAt', + completedAt: 'mothershipInboxTask.completedAt', }, mothershipInboxWebhook: { - id: 'id', - workspaceId: 'workspaceId', - webhookId: 'webhookId', - secret: 'secret', - createdAt: 'createdAt', + id: 'mothershipInboxWebhook.id', + workspaceId: 'mothershipInboxWebhook.workspaceId', + webhookId: 'mothershipInboxWebhook.webhookId', + secret: 'mothershipInboxWebhook.secret', + createdAt: 'mothershipInboxWebhook.createdAt', }, academyCertStatusEnum: 'academyCertStatusEnum', academyCertificate: { - id: 'id', - userId: 'userId', - courseId: 'courseId', - status: 'status', - issuedAt: 'issuedAt', - expiresAt: 'expiresAt', - certificateNumber: 'certificateNumber', - metadata: 'metadata', - createdAt: 'createdAt', + id: 'academyCertificate.id', + userId: 'academyCertificate.userId', + courseId: 'academyCertificate.courseId', + status: 'academyCertificate.status', + issuedAt: 'academyCertificate.issuedAt', + expiresAt: 'academyCertificate.expiresAt', + certificateNumber: 'academyCertificate.certificateNumber', + metadata: 'academyCertificate.metadata', + createdAt: 'academyCertificate.createdAt', }, dataDrains: { - id: 'id', - organizationId: 'organizationId', - name: 'name', - source: 'source', - destinationType: 'destinationType', - destinationConfig: 'destinationConfig', - destinationCredentials: 'destinationCredentials', - scheduleCadence: 'scheduleCadence', - enabled: 'enabled', - cursor: 'cursor', - lastRunAt: 'lastRunAt', - lastSuccessAt: 'lastSuccessAt', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'dataDrains.id', + organizationId: 'dataDrains.organizationId', + name: 'dataDrains.name', + source: 'dataDrains.source', + destinationType: 'dataDrains.destinationType', + destinationConfig: 'dataDrains.destinationConfig', + destinationCredentials: 'dataDrains.destinationCredentials', + scheduleCadence: 'dataDrains.scheduleCadence', + enabled: 'dataDrains.enabled', + cursor: 'dataDrains.cursor', + lastRunAt: 'dataDrains.lastRunAt', + lastSuccessAt: 'dataDrains.lastSuccessAt', + createdBy: 'dataDrains.createdBy', + createdAt: 'dataDrains.createdAt', + updatedAt: 'dataDrains.updatedAt', }, dataDrainRuns: { - id: 'id', - drainId: 'drainId', - status: 'status', - trigger: 'trigger', - startedAt: 'startedAt', - finishedAt: 'finishedAt', - rowsExported: 'rowsExported', - bytesWritten: 'bytesWritten', - cursorBefore: 'cursorBefore', - cursorAfter: 'cursorAfter', - error: 'error', - locators: 'locators', + id: 'dataDrainRuns.id', + drainId: 'dataDrainRuns.drainId', + status: 'dataDrainRuns.status', + trigger: 'dataDrainRuns.trigger', + startedAt: 'dataDrainRuns.startedAt', + finishedAt: 'dataDrainRuns.finishedAt', + rowsExported: 'dataDrainRuns.rowsExported', + bytesWritten: 'dataDrainRuns.bytesWritten', + cursorBefore: 'dataDrainRuns.cursorBefore', + cursorAfter: 'dataDrainRuns.cursorAfter', + error: 'dataDrainRuns.error', + locators: 'dataDrainRuns.locators', }, workspaceForkDependentValue: { - id: 'id', - childWorkspaceId: 'childWorkspaceId', - targetWorkflowId: 'targetWorkflowId', - targetBlockId: 'targetBlockId', - subBlockKey: 'subBlockKey', - value: 'value', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceForkDependentValue.id', + childWorkspaceId: 'workspaceForkDependentValue.childWorkspaceId', + targetWorkflowId: 'workspaceForkDependentValue.targetWorkflowId', + targetBlockId: 'workspaceForkDependentValue.targetBlockId', + subBlockKey: 'workspaceForkDependentValue.subBlockKey', + value: 'workspaceForkDependentValue.value', + createdAt: 'workspaceForkDependentValue.createdAt', + updatedAt: 'workspaceForkDependentValue.updatedAt', }, workspaceForkResourceMap: { - id: 'id', - childWorkspaceId: 'childWorkspaceId', - resourceType: 'resourceType', - parentResourceId: 'parentResourceId', - childResourceId: 'childResourceId', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceForkResourceMap.id', + childWorkspaceId: 'workspaceForkResourceMap.childWorkspaceId', + resourceType: 'workspaceForkResourceMap.resourceType', + parentResourceId: 'workspaceForkResourceMap.parentResourceId', + childResourceId: 'workspaceForkResourceMap.childResourceId', + createdBy: 'workspaceForkResourceMap.createdBy', + createdAt: 'workspaceForkResourceMap.createdAt', + updatedAt: 'workspaceForkResourceMap.updatedAt', }, workspaceForkBlockMap: { - id: 'id', - childWorkspaceId: 'childWorkspaceId', - parentWorkflowId: 'parentWorkflowId', - parentBlockId: 'parentBlockId', - childWorkflowId: 'childWorkflowId', - childBlockId: 'childBlockId', - createdAt: 'createdAt', - updatedAt: 'updatedAt', + id: 'workspaceForkBlockMap.id', + childWorkspaceId: 'workspaceForkBlockMap.childWorkspaceId', + parentWorkflowId: 'workspaceForkBlockMap.parentWorkflowId', + parentBlockId: 'workspaceForkBlockMap.parentBlockId', + childWorkflowId: 'workspaceForkBlockMap.childWorkflowId', + childBlockId: 'workspaceForkBlockMap.childBlockId', + createdAt: 'workspaceForkBlockMap.createdAt', + updatedAt: 'workspaceForkBlockMap.updatedAt', }, workspaceForkPromoteRun: { - id: 'id', - childWorkspaceId: 'childWorkspaceId', - sourceWorkspaceId: 'sourceWorkspaceId', - targetWorkspaceId: 'targetWorkspaceId', - direction: 'direction', - snapshot: 'snapshot', - createdBy: 'createdBy', - createdAt: 'createdAt', + id: 'workspaceForkPromoteRun.id', + childWorkspaceId: 'workspaceForkPromoteRun.childWorkspaceId', + sourceWorkspaceId: 'workspaceForkPromoteRun.sourceWorkspaceId', + targetWorkspaceId: 'workspaceForkPromoteRun.targetWorkspaceId', + direction: 'workspaceForkPromoteRun.direction', + snapshot: 'workspaceForkPromoteRun.snapshot', + createdBy: 'workspaceForkPromoteRun.createdBy', + createdAt: 'workspaceForkPromoteRun.createdAt', }, /** Custom type export for tsvector */ tsvector: 'tsvector', From d4895da1ac9167d35c4f80f77ce6df7d4e798ed4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 23:39:04 -0700 Subject: [PATCH 13/14] fix(knowledge): recover stranded documents and split the sync lock lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document that exhausted its processing-attempt budget while still `pending` matched no recovery path at all: the connector sweep requires an unspent budget, the timeout path requires `processing`, and retry accepted only terminal states. Since every dispatch charges an attempt, a worker killed before its claim UPDATE burned budget without ever changing status. Retry now also admits `pending` once the shared queued dispatch grace has elapsed, measured from COALESCE(queued_at, uploaded_at) exactly as the sweep measures it, so a double-click still lands inside a live dispatch's window and still matches no rows. Retry committed its reset in its own transaction and then dispatched, so a throwing dispatch stranded the row it had just reset. It now unwinds to `failed` and reports the failure instead of painting success over a dead document — and the orchestration layer stops rejecting `pending` before the guarded requeue can see it, and stops hard-coding success. Four upload paths logged total dispatch failure and walked away. Upload documents carry no connector id, so nothing sweeps them. They now share one unwinding dispatch helper. The funnel is deliberately left alone: the connector sweep wants its documents reclaimable, and the outbox handler must let the throw propagate so the relay retries. `updated_at` carried two meanings, row mtime and lock lease, so any unrelated write renewed a wedged run's lease — including the connector edit path, the only control the UI leaves enabled on one. The lease moves to its own column written solely by lock acquisition and the heartbeat, read as COALESCE(lease, updated_at) so a row already syncing at deploy stays reclaimable, and cleared by both terminal helpers and by the two knowledge-base-deleted writers that flip a possibly-locked row. Connector updates now refuse while a sync holds the lock, matching the sibling sync path. Also: the reaper reports the disabled verdict it actually wrote rather than a timeout the operator cannot wait out, reconciliation hard-deletes in heartbeat-separated chunks, and the sync-log sweep gets a partial index on the column it scans. --- .../knowledge/connectors/sync/route.test.ts | 62 ++++- .../api/knowledge/connectors/sync/route.ts | 45 +++- .../application/add-workspace-files.test.ts | 32 +++ .../application/add-workspace-files.ts | 23 +- .../knowledge/application/documents.test.ts | 55 +++++ .../lib/knowledge/application/documents.ts | 18 +- .../application/upload-sessions.test.ts | 22 +- .../knowledge/application/upload-sessions.ts | 66 ++---- .../lib/knowledge/connectors/queue.test.ts | 36 ++- apps/sim/lib/knowledge/connectors/queue.ts | 13 + .../knowledge/connectors/sync-engine.test.ts | 222 +++++++++++++++++- .../lib/knowledge/connectors/sync-engine.ts | 101 +++++--- .../lib/knowledge/connectors/sync-limits.ts | 10 + .../knowledge/documents/processing-claim.ts | 57 +++++ .../documents/processing-dispatch.ts | 59 +++++ .../documents/retry-processing-grace.test.ts | 166 ++++++++++++- apps/sim/lib/knowledge/documents/service.ts | 96 ++++++-- apps/sim/lib/knowledge/documents/types.ts | 27 +++ .../orchestration/connectors.test.ts | 46 ++++ .../lib/knowledge/orchestration/connectors.ts | 21 ++ .../knowledge/orchestration/documents.test.ts | 106 ++++++++- .../lib/knowledge/orchestration/documents.ts | 51 ++-- .../db/migrations/0298_nosy_ken_ellis.sql | 15 ++ .../migrations/0298_shallow_silver_sable.sql | 2 - .../db/migrations/meta/0298_snapshot.json | 24 +- packages/db/migrations/meta/_journal.json | 4 +- packages/db/schema.ts | 30 +++ packages/testing/src/mocks/schema.mock.ts | 1 + 28 files changed, 1208 insertions(+), 202 deletions(-) create mode 100644 apps/sim/lib/knowledge/documents/processing-dispatch.ts create mode 100644 packages/db/migrations/0298_nosy_ken_ellis.sql delete mode 100644 packages/db/migrations/0298_shallow_silver_sable.sql diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index b721184daa4..a26b34469c2 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -19,6 +19,7 @@ import { import { type NextRequest, NextResponse } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + CONNECTOR_AUTO_DISABLED_ERROR, CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, @@ -65,6 +66,22 @@ function numericBinds(value: unknown): number[] { return asFragment(value).values.filter((v): v is number => typeof v === 'number') } +/** + * Asserts one operand is the lock-lease expression rather than a bare column. + * + * `sync_lock_lease_at` is written only by lock acquisition and the heartbeat, so + * it is the lease; `updated_at` moves on every unrelated write and merely used + * to double as one. It is read through COALESCE rather than backfilled: a plain + * `lease <= cutoff` is NULL-false, so a row already `syncing` when the column + * shipped would never be reclaimed at all. + */ +function expectLeaseExpression(value: unknown): void { + const fragment = asFragment(value) + expect(fragment.toSQL().sql).toBe('COALESCE(?, ?)') + expect(fragment.values[0]).toBe(schemaMock.knowledgeConnector.syncLockLeaseAt) + expect(fragment.values[1]).toBe(schemaMock.knowledgeConnector.updatedAt) +} + function cronRequest(): NextRequest { return new Request('https://sim.ai/api/knowledge/connectors/sync', { headers: { authorization: 'Bearer test-cron-secret' }, @@ -198,6 +215,38 @@ describe('connector sync scheduler stale-lock reaper', () => { expect(setPayloadForUpdate(0).syncLockToken).toBeNull() }) + it('closes the reclaimed run lease alongside its token', async () => { + await runTickRecovering(['connector-1']) + + /** + * A reclaimed row is re-locked by its replacement, which opens a fresh + * lease. Leaving the dead run's lease behind would let the replacement + * inherit an already-expired one and be reclaimed on the very next tick. + */ + expect(setPayloadForUpdate(0).syncLockLeaseAt).toBeNull() + }) + + it('tells the operator the connector is disabled when the reclaim disables it', async () => { + await runTickRecovering(['connector-1']) + + /** + * `reclaimedStatus()` disables at the threshold and `reclaimedNextSyncAt()` + * then writes no next attempt, so an unconditional "timed out, will retry" + * message describes a retry that will never happen. The two CASE arms must + * pivot on the same comparison. + */ + const error = setPayloadForUpdate(0).lastSyncError + expect(renderedSql(error)).toBe('CASE WHEN COALESCE(?, 0) + 1 >= ? THEN ? ELSE ? END') + + const values = asFragment(error).values + expect(values[0]).toBe(schemaMock.knowledgeConnector.consecutiveFailures) + expect(values[1]).toBe(MAX_CONSECUTIVE_FAILURES) + // Sourced from the constant the in-process breaker writes, so the two + // writers of one verdict cannot drift into two different messages. + expect(values[2]).toBe(CONNECTOR_AUTO_DISABLED_ERROR) + expect(values[3]).toBe('Sync timed out (stale lock recovered)') + }) + it('does not stamp lastSyncAt when reclaiming a stale lock', async () => { await runTickRecovering(['connector-1']) @@ -272,7 +321,7 @@ describe('connector sync scheduler stale-lock reaper', () => { expect(bound[3]).toBe(schemaMock.knowledgeConnector.syncLockToken) expect(bound[4]).toBe(schemaMock.knowledgeConnectorSyncLog.id) expect(bound[5]).toBe(schemaMock.knowledgeConnector.status) - expect(bound[6]).toBe(schemaMock.knowledgeConnector.updatedAt) + expectLeaseExpression(bound[6]) }) it('identifies the lock holder by token, not merely by the connector syncing', async () => { @@ -303,7 +352,7 @@ describe('connector sync scheduler stale-lock reaper', () => { * forever. */ const bound = sweepLivenessFragment().values - expect(bound[6]).toBe(schemaMock.knowledgeConnector.updatedAt) + expectLeaseExpression(bound[6]) // Compared by value: `toBeDefined()` passed even for `new Date()`, which // spares nothing and closes rows started a second ago. @@ -392,11 +441,12 @@ describe('connector sync scheduler reclaim predicate', () => { // Deleting this clause reclaims every syncing connector on every tick. const cutoff = flattenMockConditions(where).find( - (node: MockCondition) => - node.type === 'lte' && node.left === schemaMock.knowledgeConnector.updatedAt - ) + (node: MockCondition) => typeof node.toSQL === 'function' + ) as unknown as MockSqlFragment | undefined expect(cutoff).toBeDefined() - expect(cutoff?.right).toEqual(EXPECTED_STALE_CUTOFF) + expect(cutoff?.toSQL().sql).toBe('? <= ?') + expectLeaseExpression(cutoff?.values[0]) + expect((cutoff?.values[1] as { value: Date }).value).toEqual(EXPECTED_STALE_CUTOFF) for (const column of [ schemaMock.knowledgeConnector.archivedAt, diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 95d320aa300..c008f0fed73 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchSync } from '@/lib/knowledge/connectors/queue' import { + CONNECTOR_AUTO_DISABLED_ERROR, CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, @@ -31,6 +32,35 @@ const DISPATCH_CONCURRENCY = 10 const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)' +/** + * How long the connector holding the lock has gone without proving it is alive. + * + * `sync_lock_lease_at` is written only by lock acquisition and the heartbeat, so + * it is the lease; `updated_at` is the row's modification time and merely used + * to double as one. Read through `COALESCE` rather than backfilled: a plain + * `lease <= cutoff` is NULL-false, so a row already `syncing` when this column + * shipped would never be reclaimed — strictly worse than the behaviour it + * replaces. The fallback also keeps the reaper correct against any future + * writer that takes the lock without opening a lease. + */ +function syncLockLease(): SQL { + return sql`COALESCE(${knowledgeConnector.syncLockLeaseAt}, ${knowledgeConnector.updatedAt})` +} + +/** + * The error a reclaimed connector reports. + * + * Mirrors {@link reclaimedStatus}: once the reclaim disables the connector, + * {@link reclaimedNextSyncAt} sets no next attempt, so telling the operator the + * sync merely timed out describes a retry that will never happen. The disabled + * wording is the shared one `buildSyncFailureUpdate` writes, so the in-process + * breaker and this SQL breaker cannot drift into two different messages for one + * verdict. + */ +function reclaimedError(): SQL { + return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN ${CONNECTOR_AUTO_DISABLED_ERROR} ELSE ${STALE_LOCK_ERROR_MESSAGE} END` +} + /** * Excludes a sync-log row belonging to a run that is demonstrably still alive. * @@ -41,14 +71,15 @@ const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)' * successful sync as a failure and losing its counters to * `loadPreviousListingObservation`, which reads only `completed` rows. * - * The heartbeat is the single source of liveness truth, so this defers to it. + * The heartbeat is the single source of liveness truth, so this defers to it, + * reading the same {@link syncLockLease} expression the reclaim predicate does. * Sparing requires all three of: the connector is locked, THIS row's run is the * lock holder, and that lock is being heartbeated. An orphan can satisfy at most * two, so none is ever stranded: * - reclaimed after a hard kill — connector is `error`, token cleared; * - a replacement holds the lock — the token is the successor's, not this row's; * - died without being reclaimed, including on an archived or deleted connector - * the reclaim skips entirely — `updatedAt` is stale. + * the reclaim skips entirely — its lease is stale. * * This re-references the connector row, which an earlier fix deliberately moved * away from. That coupling was different: it restricted the sweep's candidate @@ -62,7 +93,7 @@ function logRowNotHeldByLiveRun(staleCutoff: Date): SQL { WHERE ${knowledgeConnector.id} = ${knowledgeConnectorSyncLog.connectorId} AND ${knowledgeConnector.syncLockToken} = ${knowledgeConnectorSyncLog.id} AND ${knowledgeConnector.status} = 'syncing' - AND ${knowledgeConnector.updatedAt} > ${sql.param(staleCutoff, knowledgeConnector.updatedAt)} + AND ${syncLockLease()} > ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)} )` } @@ -114,18 +145,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .update(knowledgeConnector) .set({ status: reclaimedStatus(), - lastSyncError: STALE_LOCK_ERROR_MESSAGE, + lastSyncError: reclaimedError(), nextSyncAt: reclaimedNextSyncAt(), consecutiveFailures: reclaimedFailureCount(), // Releases the reclaimed run's ownership token so its terminal write can - // no longer match, even before a replacement takes the lock. + // no longer match, even before a replacement takes the lock, and closes + // its lease so a re-locked row starts from a fresh one. syncLockToken: null, + syncLockLeaseAt: null, updatedAt: sql`now()`, }) .where( and( eq(knowledgeConnector.status, 'syncing'), - lte(knowledgeConnector.updatedAt, staleCutoff), + sql`${syncLockLease()} <= ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)}`, isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.test.ts b/apps/sim/lib/knowledge/application/add-workspace-files.test.ts index c245ef702ac..f1be614d442 100644 --- a/apps/sim/lib/knowledge/application/add-workspace-files.test.ts +++ b/apps/sim/lib/knowledge/application/add-workspace-files.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -136,6 +137,37 @@ describe('add workspace files to knowledge base application command', () => { mimeType: workspaceFile.type, }) mocks.processQueue.mockResolvedValue(undefined) + resetDbChainMock() + }) + + /** + * A document added from a workspace file has no `connector_id`, so the + * connector-scoped stuck-document sweep never sees it. Logging the dispatch + * failure and walking away leaves it `pending`, where nothing finds it again. + */ + it('marks the document failed when its processing dispatch never got off the ground', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) + + await addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: ['files/report.pdf'], + }, + }) + // The dispatch is fire-and-forget, so the unwind runs on a later microtask. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + const failureWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'failed' + ) + expect(failureWrite?.[0]).toMatchObject({ + processingStatus: 'failed', + processingError: 'queue unavailable', + }) }) it('bounds file references before canonical knowledge loading', async () => { diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.ts b/apps/sim/lib/knowledge/application/add-workspace-files.ts index e4af11f53db..f7deb79d44d 100644 --- a/apps/sim/lib/knowledge/application/add-workspace-files.ts +++ b/apps/sim/lib/knowledge/application/add-workspace-files.ts @@ -23,11 +23,8 @@ import { resolveActiveKnowledgeBaseContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { - createSingleDocument, - type DocumentData, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' +import { dispatchDocumentProcessing } from '@/lib/knowledge/documents/processing-dispatch' +import { createSingleDocument, type DocumentData } from '@/lib/knowledge/documents/service' import { StorageService } from '@/lib/uploads' import { loadActiveWorkspaceFileContext, @@ -217,18 +214,12 @@ export const addWorkspaceFilesToKnowledgeBase = defineAuthorizedKnowledgeUseCase fileSize: document.fileSize, mimeType: document.mimeType, } - processDocumentsWithQueue( - [processingDocument], - context.knowledgeBaseId, - {}, + void dispatchDocumentProcessing({ + documents: [processingDocument], + knowledgeBaseId: context.knowledgeBaseId, + processingOptions: {}, requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error('Knowledge document processing pipeline failed', { - knowledgeBaseId: context.knowledgeBaseId, - documentId: document.id, - error, - }) + billingAttribution, }) added.push({ documentId: document.id, diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 73bb11249a4..2fed1fc8d9a 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -17,6 +18,8 @@ const mocks = vi.hoisted(() => ({ deleteDocument: vi.fn(), updateDocument: vi.fn(), processQueue: vi.fn(), + createDocumentRecords: vi.fn(), + deleteDocumentById: vi.fn(), getProcessingConfig: vi.fn(), performSingleUpload: vi.fn(), performBulkUpload: vi.fn(), @@ -66,6 +69,8 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ vi.mock('@/lib/knowledge/documents/service', () => ({ getDocuments: mocks.getDocuments, createSingleDocument: mocks.createDocument, + createDocumentRecords: mocks.createDocumentRecords, + deleteDocument: mocks.deleteDocumentById, deleteKnowledgeDocumentInKnowledgeBase: mocks.deleteDocument, updateDocument: mocks.updateDocument, processDocumentsWithQueue: mocks.processQueue, @@ -105,6 +110,7 @@ import { listKnowledgeDocuments, updateKnowledgeDocument, uploadKnowledgeDocument, + upsertKnowledgeDocument, } from '@/lib/knowledge/application/documents' const context = { @@ -177,6 +183,17 @@ describe('knowledge document application use cases', () => { mocks.createDocument.mockResolvedValue(document) mocks.updateDocument.mockResolvedValue(document) mocks.processQueue.mockResolvedValue(undefined) + mocks.createDocumentRecords.mockResolvedValue([ + { + documentId: document.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + ]) + mocks.deleteDocumentById.mockResolvedValue(undefined) + resetDbChainMock() mocks.getProcessingConfig.mockReturnValue({ batchSize: 10, maxConcurrentDocuments: 2 }) mocks.performBulkUpload.mockResolvedValue({ success: true, @@ -196,6 +213,44 @@ describe('knowledge document application use cases', () => { }) }) + /** + * An upserted document has no `connector_id`, so the connector-scoped + * stuck-document sweep never sees it. Logging the dispatch failure and walking + * away leaves it `pending`, where nothing finds it again. + */ + it('marks an upserted document failed when its dispatch never got off the ground', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) + + await upsertKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + resolveBillingAttribution: async () => ({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + }), + resolveSecretProvenances: () => undefined, + }, + }) + // The dispatch is fire-and-forget, so the unwind runs on a later microtask. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + const failureWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'failed' + ) + expect(failureWrite?.[0]).toMatchObject({ + processingStatus: 'failed', + processingError: 'queue unavailable', + }) + }) + it('authorizes the canonical knowledge base before listing documents', async () => { await listKnowledgeDocuments.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index a2faed4ba83..08db2444b6c 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -38,6 +38,7 @@ import { type AllTagSlot, MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE, } from '@/lib/knowledge/constants' +import { dispatchDocumentProcessing } from '@/lib/knowledge/documents/processing-dispatch' import { bulkDocumentOperation, bulkDocumentOperationByFilter, @@ -48,7 +49,6 @@ import { getDocuments, getProcessingConfig, type ProcessingOptions, - processDocumentsWithQueue, updateDocument, } from '@/lib/knowledge/documents/service' import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter' @@ -690,18 +690,12 @@ export const upsertKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ throw new Error('Failed to replace existing document', { cause: error }) } } - processDocumentsWithQueue( - createdDocuments, - context.knowledgeBaseId, - input.processingOptions ?? {}, + void dispatchDocumentProcessing({ + documents: createdDocuments, + knowledgeBaseId: context.knowledgeBaseId, + processingOptions: input.processingOptions ?? {}, requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error('Knowledge document upsert processing pipeline failed', { - knowledgeBaseId: context.knowledgeBaseId, - documentId: createdDocument.documentId, - error, - }) + billingAttribution, }) const isUpdate = existingDocumentId !== null const { maxConcurrentDocuments, batchSize } = getProcessingConfig() diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts index 6f870c76254..796f0b3c820 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.test.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -11,7 +12,6 @@ const mocks = vi.hoisted(() => ({ createDocument: vi.fn(), createPartUrls: vi.fn(), createUpload: vi.fn(), - failUndispatched: vi.fn(), findBound: vi.fn(), getUpload: vi.fn(), processQueue: vi.fn(), @@ -49,10 +49,6 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ resolveActiveKnowledgeBaseContext: mocks.resolveContext, })) -vi.mock('@/lib/knowledge/documents/processing-claim', () => ({ - failUndispatchedDocumentProcessing: mocks.failUndispatched, -})) - vi.mock('@/lib/knowledge/documents/service', () => ({ createSingleDocument: mocks.createDocument, processDocumentsWithQueue: mocks.processQueue, @@ -177,6 +173,7 @@ const REQUEST = { headers: new Headers() } describe('knowledge-document upload application lifecycle', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mocks.resolveContext.mockResolvedValue(CONTEXT) mocks.resolvePermission.mockResolvedValue('write') mocks.resolveBilling.mockResolvedValue(BILLING) @@ -200,7 +197,6 @@ describe('knowledge-document upload application lifecycle', () => { mocks.findBound.mockResolvedValue({ status: 'absent' }) mocks.createDocument.mockResolvedValue(DOCUMENT) mocks.processQueue.mockResolvedValue(undefined) - mocks.failUndispatched.mockResolvedValue(true) }) it('admits, binds, and records ownership before returning upload credentials', async () => { @@ -481,17 +477,21 @@ describe('knowledge-document upload application lifecycle', () => { request: REQUEST, }) - expect(mocks.failUndispatched).toHaveBeenCalledWith({ - documentId: DOCUMENT.id, - knowledgeBaseId: 'knowledge-1', - error: 'queue unavailable', + const failedWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'failed' + ) + expect(failedWrite).toBeDefined() + expect(failedWrite?.[0]).toMatchObject({ + processingStatus: 'failed', + processingError: 'queue unavailable', }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.document) }) /** Recording the failure is itself best-effort; it must not resurface as a 500. */ it('still completes when the dispatch failure cannot be recorded', async () => { mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) - mocks.failUndispatched.mockRejectedValue(new Error('database unavailable')) + dbChainMockFns.returning.mockRejectedValue(new Error('database unavailable')) mocks.completeUpload.mockImplementation( async (params: { session: UploadSessionRecord diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 748f8408838..1d1ed33d2c1 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -1,8 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { truncate } from '@sim/utils/string' import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { authorizeWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -19,12 +17,8 @@ import { resolveActiveKnowledgeBaseContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { failUndispatchedDocumentProcessing } from '@/lib/knowledge/documents/processing-claim' -import { - createSingleDocument, - type DocumentData, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' +import { dispatchDocumentProcessing } from '@/lib/knowledge/documents/processing-dispatch' +import { createSingleDocument, type DocumentData } from '@/lib/knowledge/documents/service' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { @@ -46,11 +40,6 @@ import { validateFileType } from '@/lib/uploads/utils/validation' const logger = createLogger('KnowledgeUploadSessions') -const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' - -/** Keeps a driver or provider message from filling the document row's error column. */ -const DISPATCH_FAILURE_MESSAGE_MAX_LENGTH = 500 - export class KnowledgeDocumentUnsupportedMediaTypeError extends Error { constructor(message: string) { super(message) @@ -403,13 +392,12 @@ interface PendingProcessingDispatch { * `200 completed`. * * The dispatch outcome is not lost by going unraised. `processDocumentsWithQueue` - * marks the document `failed` with its error when processing itself breaks. When - * the dispatch never got off the ground the document would instead be left at - * `pending`, which nothing sweeps and which `retryProcessing` refuses, so - * {@link failUndispatchedDocumentProcessing} records the failure on the row. - * Either way the error is visible on every subsequent read of the document, and - * the document can be re-queued through - * `PATCH /api/knowledge/{id}/documents/{documentId}` with `retryProcessing`. + * marks the document `failed` with its error when processing itself breaks, and + * {@link dispatchDocumentProcessing} records the failure on the row when the + * dispatch never got off the ground at all. Either way the error is visible on + * every subsequent read of the document, and the document can be re-queued + * through `PATCH /api/knowledge/{id}/documents/{documentId}` with + * `retryProcessing`. */ async function queueKnowledgeDocumentProcessing( dispatch: PendingProcessingDispatch, @@ -422,37 +410,13 @@ async function queueKnowledgeDocumentProcessing( fileSize: dispatch.document.fileSize, mimeType: dispatch.document.mimeType, } - try { - await processDocumentsWithQueue( - [processingDocument], - dispatch.knowledgeBaseId, - dispatch.processingOptions ?? {}, - requestId, - dispatch.billingAttribution - ) - } catch (error) { - const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') - logger.error(PROCESSING_DISPATCH_FAILURE_MESSAGE, { - requestId, - documentId: dispatch.document.id, - knowledgeBaseId: dispatch.knowledgeBaseId, - error: failureMessage, - }) - try { - await failUndispatchedDocumentProcessing({ - documentId: dispatch.document.id, - knowledgeBaseId: dispatch.knowledgeBaseId, - error: truncate(failureMessage, DISPATCH_FAILURE_MESSAGE_MAX_LENGTH), - }) - } catch (markError) { - logger.error('Failed to record a knowledge document dispatch failure', { - requestId, - documentId: dispatch.document.id, - knowledgeBaseId: dispatch.knowledgeBaseId, - error: getErrorMessage(markError), - }) - } - } + await dispatchDocumentProcessing({ + documents: [processingDocument], + knowledgeBaseId: dispatch.knowledgeBaseId, + processingOptions: dispatch.processingOptions ?? {}, + requestId, + billingAttribution: dispatch.billingAttribution, + }) } async function loadBoundKnowledgeDocumentUpload( diff --git a/apps/sim/lib/knowledge/connectors/queue.test.ts b/apps/sim/lib/knowledge/connectors/queue.test.ts index 9ad039247b1..f867517f435 100644 --- a/apps/sim/lib/knowledge/connectors/queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/queue.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteSync, mockIsTriggerAvailable, mockResolveTriggerRegion, mockTrigger } = @@ -110,6 +110,40 @@ describe('connector sync queue', () => { ) }) + it('releases the lock when it errors a connector whose knowledge base is gone', async () => { + resetDbChainMock() + queueTableRows(schemaMock.knowledgeConnector, [ + { + knowledgeBaseId: 'knowledge-base-1', + connectorArchivedAt: null, + connectorDeletedAt: null, + workspaceId: 'workspace-paid', + kbDeletedAt: new Date('2026-08-20T00:00:00.000Z'), + }, + ]) + + await dispatchSync('connector-1', { + billingAttribution: BILLING_ATTRIBUTION, + requestId: 'request-1', + }) + + /** + * This write is unconditional on status, so it can land on a row a previous + * run left `syncing`. Flipping status without releasing the token and lease + * left a row that was neither locked nor reclaimable — the reaper only looks + * at `syncing` rows, and the old run's terminal write could still match its + * own token. + */ + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + syncLockToken: null, + syncLockLeaseAt: null, + }) + ) + expect(mockTrigger).not.toHaveBeenCalled() + }) + it('rejects legacy payloads without billing attribution', () => { expect(() => assertConnectorSyncPayload({ diff --git a/apps/sim/lib/knowledge/connectors/queue.ts b/apps/sim/lib/knowledge/connectors/queue.ts index 3bd73456ad5..ad9163a5805 100644 --- a/apps/sim/lib/knowledge/connectors/queue.ts +++ b/apps/sim/lib/knowledge/connectors/queue.ts @@ -122,6 +122,19 @@ export async function dispatchSync( status: 'error', nextSyncAt: null, lastSyncError: 'Knowledge base deleted', + /** + * Clears the lock alongside the status. + * + * This write runs BEFORE the lock is taken, but it is unconditional on + * status, so it can land on a row a previous run left `syncing` — a run + * that may still be alive. Flipping status without releasing the token + * left a row that was neither locked nor reclaimable: the reaper only + * looks at `syncing` rows, and the old run's terminal write could still + * match its own token and resurrect a state for a knowledge base that no + * longer exists. Releasing both makes the transition terminal. + */ + syncLockToken: null, + syncLockLeaseAt: null, updatedAt: new Date(), }) .where(eq(knowledgeConnector.id, connectorId)) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index f0c36fa64b3..34bcdf45894 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -741,7 +741,7 @@ describe('isStuckDocumentSweepEligible', () => { }) /** - * Pinned to `QUEUED_DISPATCH_GRACE_MINUTES` in sync-engine. A change to it + * Pinned to `QUEUED_DISPATCH_GRACE_MS` in documents/types. A change to it * should fail here so it is re-checked deliberately rather than absorbed * silently. */ @@ -1368,6 +1368,49 @@ describe('buildSyncFailureUpdate', () => { expect(buildSyncFailureUpdate(now, 0, 'boom').syncLockToken).toBeNull() expect(buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES, 'boom').syncLockToken).toBeNull() }) + + it('closes the lock lease alongside the token', async () => { + const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * A run that ends leaves no lease behind. Otherwise the reaper waits out a + * full TTL against a lease belonging to a run that is already over. + */ + expect(buildSyncFailureUpdate(now, 0, 'boom').syncLockLeaseAt).toBeNull() + }) + + it('sources the auto-disabled message from the constant the reaper shares', async () => { + const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + const { CONNECTOR_AUTO_DISABLED_ERROR, MAX_CONSECUTIVE_FAILURES } = await import( + '@/lib/knowledge/connectors/sync-limits' + ) + + // Two writers advance one verdict; a second copy of the wording lets the + // in-process breaker and the SQL breaker disagree about what happened. + expect(buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES, 'boom').lastSyncError).toBe( + CONNECTOR_AUTO_DISABLED_ERROR + ) + }) +}) + +describe('sync lock lease', () => { + const now = new Date('2026-08-20T00:00:00.000Z') + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('opens the lease in the same statement that takes the lock', async () => { + const { buildSyncLockAcquisition } = await import('@/lib/knowledge/connectors/sync-engine') + + const values = buildSyncLockAcquisition('log-1', now) + + // A lease opened after the lock leaves a window where the reaper reads a + // NULL lease and falls back to a stale `updatedAt`. + expect(values.syncLockLeaseAt).toEqual(now) + expect(values.syncLockToken).toBe('log-1') + }) }) describe('buildSyncSuccessUpdate', () => { @@ -1392,6 +1435,15 @@ describe('buildSyncSuccessUpdate', () => { expect(buildSyncSuccessUpdate(now, 42, null, null).lastSyncError).toBeNull() }) + it('closes the lock lease alongside the token', async () => { + const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + const update = buildSyncSuccessUpdate(now, 42, null, null) + + expect(update.syncLockToken).toBeNull() + expect(update.syncLockLeaseAt).toBeNull() + }) + it('does not treat a held pass as a broken connector', async () => { const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') @@ -1733,12 +1785,20 @@ describe('heartbeatSyncLock', () => { resetDbChainMock() }) - it('refreshes updatedAt under the run own lock guard', async () => { + it('extends the lock lease alone, under the run own lock guard', async () => { const { heartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') await heartbeatSyncLock('c-1', 'run-a') - expect(dbChainMockFns.set.mock.calls[0][0]).toEqual({ updatedAt: expect.any(Date) }) + /** + * Asserted whole, and the absence of `updatedAt` is the point. While the + * beat wrote the row mtime, every unrelated write to the row — a config + * edit, a status flip — was indistinguishable from a heartbeat and renewed + * a wedged run's lease, pushing its recovery out by another full TTL. + */ + expect(dbChainMockFns.set.mock.calls[0][0]).toEqual({ + syncLockLeaseAt: expect.any(Date), + }) // Guarded, so a beat doubles as an ownership probe rather than a blind touch. const where = dbChainMockFns.where.mock.calls[0][0] @@ -1916,3 +1976,159 @@ describe('MAX_PROCESSING_ATTEMPTS', () => { expect(MAX_PROCESSING_ATTEMPTS).toBeLessThanOrEqual(10) }) }) + +describe('executeSync hard-delete reconciliation', () => { + const OWNED_DOC_COUNT = 100 + const LISTED_DOC_COUNT = 60 + + const CONNECTOR = { + id: 'c-1', + knowledgeBaseId: 'kb-1', + connectorType: 'paged', + credentialId: null, + encryptedApiKey: null, + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncDocCount: OWNED_DOC_COUNT, + consecutiveFailures: 0, + syncLockToken: null, + } + + /** Owned documents, all with the same hash so the listing reads as unchanged. */ + const ownedDocs = Array.from({ length: OWNED_DOC_COUNT }, (_, i) => ({ + id: `doc-${i}`, + externalId: `ext-${i}`, + contentHash: 'h', + deletedAt: null, + userExcluded: false, + })) + const missingIds = ownedDocs.slice(LISTED_DOC_COUNT).map((d) => d.id) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterEach(() => { + resetDbChainMock() + vi.useRealTimers() + }) + + /** Primes every read the reconciliation path makes, in the order it makes them. */ + function primeReconciliation() { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) + // hasTombstonedDocs, then existingDocs / tombstonedDocs / excludedDocs. + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, ownedDocs) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + // The ownership re-check inside the reconciliation transaction. + queueTableRows( + schemaMock.document, + missingIds.map((id) => ({ id })) + ) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + + mockListDocuments.mockResolvedValue({ + documents: ownedDocs.slice(0, LISTED_DOC_COUNT).map((d) => ({ + externalId: d.externalId, + title: d.externalId, + content: 'body', + contentHash: 'h', + mimeType: 'text/plain', + metadata: {}, + })), + hasMore: false, + }) + } + + it('hard-deletes in heartbeat-separated chunks instead of one unbeaten call', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') + + primeReconciliation() + vi.mocked(hardDeleteDocuments).mockResolvedValue(0) + + await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + fullSync: true, + }) + + const calls = vi.mocked(hardDeleteDocuments).mock.calls + expect(calls.length).toBeGreaterThan(1) + + /** + * `hardDeleteDocuments` deletes storage objects, embeddings, and rows in + * serialized transactions, and a forced `fullSync` overriding a listing cap + * can hand it tens of thousands of ids. Passing the whole set was one await + * spanning the widest gap between heartbeats in the sync, so the reaper saw + * a working purge as a dead run. + */ + for (const call of calls) { + expect((call[0] as string[]).length).toBeLessThanOrEqual(25) + } + expect(calls.flatMap((call) => call[0] as string[])).toEqual(missingIds) + }) + + it('releases the lock when it errors a connector whose knowledge base is gone', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(schemaMock.knowledgeBase, []) + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + + /** + * This write runs before the lock is taken but is unconditional on status, + * so it can land on a row a previous run left `syncing`. Flipping status + * without releasing the token and lease left a row that was neither locked + * nor reclaimable — the reaper only looks at `syncing` rows. + */ + expect(result.error).toBe('knowledge_base_deleted') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + syncLockToken: null, + syncLockLeaseAt: null, + }) + ) + }) + + it('lets a heartbeat run between chunks of a long purge', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') + const { SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import( + '@/lib/knowledge/connectors/sync-limits' + ) + + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-20T00:00:00.000Z')) + primeReconciliation() + + // Each chunk takes longer than the heartbeat interval, which is the case + // chunking exists for: without a beat between them the reaper reclaims a + // connector whose purge is still running. + vi.mocked(hardDeleteDocuments).mockImplementation(async () => { + vi.setSystemTime(new Date(Date.now() + SYNC_LOCK_HEARTBEAT_INTERVAL_MS + 1_000)) + return 0 + }) + dbChainMockFns.returning.mockResolvedValue([{ id: 'c-1' }]) + + await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + fullSync: true, + }) + + const beats = dbChainMockFns.set.mock.calls.filter( + (call) => (call[0] as Record | undefined)?.syncLockLeaseAt instanceof Date + ) + // One for the lock acquisition, then at least one more from inside the loop. + expect(beats.length).toBeGreaterThan(1) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 2133cf7a5ce..f66ae62da75 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -20,6 +20,7 @@ import { env, envNumber } from '@/lib/core/config/env' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' import { + CONNECTOR_AUTO_DISABLED_ERROR, connectorFailureBackoffMinutes, MAX_CONSECUTIVE_FAILURES, SYNC_LOCK_HEARTBEAT_INTERVAL_MS, @@ -30,6 +31,7 @@ import { type DocumentProcessingStatus, isDocumentProcessingStatus, MAX_PROCESSING_ATTEMPTS, + QUEUED_DISPATCH_GRACE_MS, } from '@/lib/knowledge/documents/types' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { StorageService } from '@/lib/uploads' @@ -91,6 +93,20 @@ const MAX_SAFE_TITLE_LENGTH = 200 * await no heartbeat could interrupt; chunking gives the beat somewhere to run. */ const STUCK_RETRY_DISPATCH_CHUNK_SIZE = 25 + +/** + * How many documents reconciliation hard-deletes per call. + * + * `hardDeleteDocuments` deletes storage objects, embeddings, and rows for its + * whole argument in serialized transactions, and a forced `fullSync` overriding + * a connector's listing cap can hand it tens of thousands of ids — one await + * spanning the widest gap between heartbeats in the sync, with the deletes + * themselves the slowest work in it. Chunking gives the beat somewhere to run, + * so a long purge stops looking dead to the reaper. Sized like the dispatch + * chunk above: small enough that a chunk cannot outlast the heartbeat interval, + * large enough that the per-call overhead stays negligible. + */ +const HARD_DELETE_CHUNK_SIZE = 25 /** * Concurrent `knowledge-process-document` runs, shared by every workspace. * @@ -151,26 +167,6 @@ const STALE_PROCESSING_MINUTES = resolveStaleProcessingMinutes( envNumber(env.KB_CONFIG_MAX_DURATION, 600), envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3) ) -/** - * Grace period a document waiting on the processing queue gets before the - * stuck-document sweep may reclaim it. - * - * {@link STALE_PROCESSING_MINUTES} bounds a run that has already begun, derived - * from the task's own duration and retry budget. Queue *wait* is a different - * quantity: it is backlog / concurrency, not run duration. - * `document-processing-queue` has a global concurrency of - * {@link PROCESSING_QUEUE_CONCURRENCY} shared by every workspace, so a corpus large - * enough to approach `CONNECTOR_SYNC_MAX_DURATION_SECONDS` enqueues thousands of - * documents that drain in waves of that width — at roughly a minute of occupancy each, - * a few hours, and longer while other workspaces hold slots. - * - * Four hours is chosen against three bounds that are all constants in this - * repository rather than any one deployment's corpus: it is well above that - * drain estimate, an order of magnitude above the one-hour sync ceiling, and - * still well under the 1,440-minute default sync interval — so a - * default-configured connector waits no longer for recovery than it already did. - */ -const QUEUED_DISPATCH_GRACE_MINUTES = 240 const RETRY_WINDOW_DAYS = 7 /** @@ -202,8 +198,9 @@ export interface StuckDocumentSweepCandidate { * a worker claims it; `processing` is only written once a worker has actually * started. Reclaiming a `pending` document therefore risks racing a run that is * still queued, which both duplicates its work and bills a second indexing - * pass, so queued documents get {@link QUEUED_DISPATCH_GRACE_MINUTES} before - * they are considered lost. + * pass, so queued documents get {@link QUEUED_DISPATCH_GRACE_MS} before they + * are considered lost — the same grace the user-facing retry waits out before + * it will admit a `pending` document. * * Queue wait is measured from `processingQueuedAt`, stamped in one place — * `markDocumentsQueued`, which every dispatch funnels through, so the column @@ -250,13 +247,11 @@ export function isStuckDocumentSweepEligible(doc: StuckDocumentSweepCandidate, n case 'failed': { const lastAttemptEndedAt = doc.processingCompletedAt ?? doc.processingQueuedAt ?? doc.uploadedAt - return ( - now.getTime() - lastAttemptEndedAt.getTime() > QUEUED_DISPATCH_GRACE_MINUTES * 60 * 1000 - ) + return now.getTime() - lastAttemptEndedAt.getTime() > QUEUED_DISPATCH_GRACE_MS } case 'pending': { const queuedAt = doc.processingQueuedAt ?? doc.uploadedAt - return now.getTime() - queuedAt.getTime() > QUEUED_DISPATCH_GRACE_MINUTES * 60 * 1000 + return now.getTime() - queuedAt.getTime() > QUEUED_DISPATCH_GRACE_MS } case 'processing': { if (!doc.processingStartedAt) return true @@ -603,11 +598,17 @@ export function holdsSyncLockToken(connectorId: string, syncLockToken: string) { * `syncLockToken` is set here, in the same statement as `status`, so ownership * and the lock are established atomically — a token written afterwards would * leave a window where a terminal write could not identify its own run. + * + * `syncLockLeaseAt` opens the lease at the same instant. It is deliberately not + * `updatedAt`: the reaper reads the lease, and `updatedAt` moves on every + * unrelated write to the row, so a config edit on a wedged connector used to + * renew the lock it was meant to recover. */ export function buildSyncLockAcquisition(syncLogId: string, now: Date) { return { status: 'syncing' as const, syncLockToken: syncLogId, + syncLockLeaseAt: now, updatedAt: now, } } @@ -628,8 +629,12 @@ export function shouldHeartbeatSyncLock( } /** - * Refreshes the connector's `updatedAt` to prove this run is still working, so - * the scheduler's stale-lock reclaim does not treat a slow-but-live sync as dead. + * Extends the connector's lock lease to prove this run is still working, so the + * scheduler's stale-lock reclaim does not treat a slow-but-live sync as dead. + * + * Writes `syncLockLeaseAt` alone and deliberately leaves `updatedAt` untouched: + * a beat says nothing about the row's contents, and the two columns had to be + * separated so an unrelated write could stop passing for a heartbeat. * * Guarded on the run's own lock, so it doubles as an ownership probe: a false * return means the lock was reclaimed and this run must stop rather than keep @@ -641,7 +646,7 @@ export async function heartbeatSyncLock( ): Promise { const beat = await db .update(knowledgeConnector) - .set({ updatedAt: new Date() }) + .set({ syncLockLeaseAt: new Date() }) .where(holdsSyncLockToken(connectorId, syncLockToken)) .returning({ id: knowledgeConnector.id }) @@ -909,15 +914,15 @@ export function buildSyncFailureUpdate( return { status: (disabled ? 'disabled' : 'error') as 'disabled' | 'error', - lastSyncError: disabled - ? 'Connector disabled after repeated sync failures. Please reconnect.' - : errorMessage, + lastSyncError: disabled ? CONNECTOR_AUTO_DISABLED_ERROR : errorMessage, nextSyncAt: disabled ? null : new Date(now.getTime() + connectorFailureBackoffMinutes(failures) * 60 * 1000), consecutiveFailures: failures, - // Releases the lock so a stale token can never match a later run. + // Releases the lock so a stale token can never match a later run, and closes + // its lease so the reaper is not left waiting out a TTL on a finished run. syncLockToken: null, + syncLockLeaseAt: null, updatedAt: now, } } @@ -944,8 +949,10 @@ export function buildSyncSuccessUpdate( lastSyncDocCount: actualDocCount, nextSyncAt, consecutiveFailures: 0, - // Releases the lock so a stale token can never match a later run. + // Releases the lock so a stale token can never match a later run, and closes + // its lease so the reaper is not left waiting out a TTL on a finished run. syncLockToken: null, + syncLockLeaseAt: null, updatedAt: now, } } @@ -1385,6 +1392,19 @@ export async function executeSync( status: 'error', nextSyncAt: null, lastSyncError: 'Knowledge base deleted', + /** + * Clears the lock alongside the status. + * + * This write runs BEFORE the lock is taken, but it is unconditional on + * status, so it can land on a row a previous run left `syncing` — a run + * that may still be alive. Flipping status without releasing the token + * left a row that was neither locked nor reclaimable: the reaper only + * looks at `syncing` rows, and the old run's terminal write could still + * match its own token and resurrect a state for a knowledge base that no + * longer exists. Releasing both makes the transition terminal. + */ + syncLockToken: null, + syncLockLeaseAt: null, updatedAt: new Date(), }) .where(eq(knowledgeConnector.id, connectorId)) @@ -2134,12 +2154,18 @@ export async function executeSync( { connectorId } ) } - if (safeHardDeleteIds.length > 0) { + for (let i = 0; i < safeHardDeleteIds.length; i += HARD_DELETE_CHUNK_SIZE) { + await beatIfDue() + // Re-verifies connectorId once more at the moment of the actual delete // query — the FOR UPDATE lock above only covers the window up to its // own commit; this closes the remaining gap between that commit and // this call. - result.docsDeleted += await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId) + result.docsDeleted += await hardDeleteDocuments( + safeHardDeleteIds.slice(i, i + HARD_DELETE_CHUNK_SIZE), + syncLogId, + connectorId + ) } const postBatchPresence = await checkSyncTargetPresence(connectorId, connector.knowledgeBaseId) @@ -2275,7 +2301,8 @@ export async function executeSync( if (resetIds.length > 0) { await tx.delete(embedding).where(inArray(embedding.documentId, resetIds)) } - retryDocs = retryDocs.filter((doc) => resetIds.includes(doc.id)) + const resetIdSet = new Set(resetIds) + retryDocs = retryDocs.filter((doc) => resetIdSet.has(doc.id)) } }) diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index edf72229f90..93882bec9ae 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -47,6 +47,16 @@ export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECO */ export const MAX_CONSECUTIVE_FAILURES = 10 +/** + * The error a connector carries once {@link MAX_CONSECUTIVE_FAILURES} disables it. + * + * Shared by the same two writers as the threshold itself. Reporting a timeout on + * a run that was actually auto-disabled tells the operator to wait for a retry + * that {@link MAX_CONSECUTIVE_FAILURES} has already cancelled. + */ +export const CONNECTOR_AUTO_DISABLED_ERROR = + 'Connector disabled after repeated sync failures. Please reconnect.' + /** Minutes of backoff added per consecutive failure. */ export const CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES = 30 diff --git a/apps/sim/lib/knowledge/documents/processing-claim.ts b/apps/sim/lib/knowledge/documents/processing-claim.ts index 3a720bb9328..0d6290b680b 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.ts @@ -1,7 +1,12 @@ import { db } from '@sim/db' import { document } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' +const logger = createLogger('KnowledgeDocumentProcessingClaim') + export const KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS = 10 * 60 * 1000 interface ReclaimStaleDocumentProcessingClaimParams { @@ -142,3 +147,55 @@ export async function failUndispatchedDocumentProcessing({ return Boolean(failed) } + +/** Log line every dispatch-failure unwind shares, so one query finds them all. */ +export const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' + +/** `processing_error` is displayed verbatim, so a provider stack trace is trimmed. */ +const DISPATCH_FAILURE_MESSAGE_MAX_LENGTH = 500 + +interface RecordUndispatchedDocumentFailureParams { + documentId: string + knowledgeBaseId: string + failureMessage: string + requestId: string +} + +/** + * Records a failed dispatch against the document it stranded. + * + * The one place every caller that dispatches processing unwinds through, so a + * document whose dispatch threw is never left silently `pending`: nothing sweeps + * upload documents (their `connector_id` is NULL, and the stuck-document sweep + * is connector-scoped), so without this the row is invisible and unrecoverable. + * + * Never throws. It runs on a path that is already handling a failure, and a + * second one must not displace the first. + */ +export async function recordUndispatchedDocumentFailure({ + documentId, + knowledgeBaseId, + failureMessage, + requestId, +}: RecordUndispatchedDocumentFailureParams): Promise { + logger.error(PROCESSING_DISPATCH_FAILURE_MESSAGE, { + requestId, + documentId, + knowledgeBaseId, + error: failureMessage, + }) + try { + await failUndispatchedDocumentProcessing({ + documentId, + knowledgeBaseId, + error: truncate(failureMessage, DISPATCH_FAILURE_MESSAGE_MAX_LENGTH), + }) + } catch (markError) { + logger.error('Failed to record a knowledge document dispatch failure', { + requestId, + documentId, + knowledgeBaseId, + error: getErrorMessage(markError), + }) + } +} diff --git a/apps/sim/lib/knowledge/documents/processing-dispatch.ts b/apps/sim/lib/knowledge/documents/processing-dispatch.ts new file mode 100644 index 00000000000..ca65ab815c9 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-dispatch.ts @@ -0,0 +1,59 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { recordUndispatchedDocumentFailure } from '@/lib/knowledge/documents/processing-claim' +import { + type DocumentData, + type ProcessingOptions, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' + +interface DispatchDocumentProcessingParams { + documents: DocumentData[] + knowledgeBaseId: string + processingOptions: ProcessingOptions + requestId: string + billingAttribution: BillingAttributionSnapshot | undefined +} + +/** + * Dispatches document processing and records the failure against every document + * it stranded, rather than only logging it. + * + * The wrapper lives beside the callers rather than inside + * `processDocumentsWithQueue` deliberately. Two of that function's callers must + * NOT unwind: the connector sweep swallows dispatch failure so its documents + * stay reclaimable by the next sync, and the outbox handler lets the throw + * propagate so the relay retries it. A write inside the funnel would break both. + * + * Never throws. Every caller here dispatches fire-and-forget after its own + * response has been decided, so there is no one left to handle a rejection. + */ +export async function dispatchDocumentProcessing({ + documents, + knowledgeBaseId, + processingOptions, + requestId, + billingAttribution, +}: DispatchDocumentProcessingParams): Promise { + if (documents.length === 0) return + + try { + await processDocumentsWithQueue( + documents, + knowledgeBaseId, + processingOptions, + requestId, + billingAttribution + ) + } catch (error) { + const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') + for (const doc of documents) { + await recordUndispatchedDocumentFailure({ + documentId: doc.documentId, + knowledgeBaseId, + failureMessage, + requestId, + }) + } + } +} diff --git a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts index b5779246534..3cfda46389c 100644 --- a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts +++ b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts @@ -4,7 +4,7 @@ import { dbChainMock, dbChainMockFns, - hasMockCondition, + flattenMockConditions, type MockCondition, resetDbChainMock, schemaMock, @@ -23,6 +23,7 @@ import { processDocumentsWithQueue, retryDocumentProcessing, } from '@/lib/knowledge/documents/service' +import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' const DOC_DATA = { filename: 'report.pdf', @@ -149,13 +150,46 @@ describe('processDocumentsWithQueue dispatch stamp', () => { }) }) -describe('retryDocumentProcessing double-click guard', () => { +describe('retryDocumentProcessing requeue guard', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() }) - it('only requeues a document in a terminal state', async () => { + /** + * Every node under `condition`, descending through BOTH `and` and `or`. The + * shared `flattenMockConditions` stops at `or`, which is the node this guard + * is built from — a predicate run through it silently reports `false`. + */ + function flattenBranches(condition: unknown): MockCondition[] { + if (!condition || typeof condition !== 'object') return [] + const node = condition as MockCondition + if ((node.type === 'and' || node.type === 'or') && Array.isArray(node.conditions)) { + return [node, ...node.conditions.flatMap(flattenBranches)] + } + return [node] + } + + function hasBranch(condition: unknown, predicate: (node: MockCondition) => boolean): boolean { + return flattenBranches(condition).some(predicate) + } + + /** The `or(...)` node the requeue's WHERE narrows the eligible statuses with. */ + function statusGuard(): MockCondition { + const call = dbChainMockFns.where.mock.calls.find((c) => + hasBranch( + c[0], + (node: MockCondition) => + node.type === 'inArray' && node.column === schemaMock.document.processingStatus + ) + ) + expect(call).toBeDefined() + const guard = flattenMockConditions(call?.[0]).find((node: MockCondition) => node.type === 'or') + expect(guard).toBeDefined() + return guard as MockCondition + } + + it('requeues from a terminal state', async () => { dbChainMockFns.returning.mockResolvedValue([{ id: 'doc-1' }]) await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined).catch(() => {}) @@ -164,25 +198,90 @@ describe('retryDocumentProcessing double-click guard', () => { * Unguarded, a second click reset a document the first had already queued, * so both dispatches ran, both indexed, and both billed. */ - const guard = dbChainMockFns.where.mock.calls.find((call) => - hasMockCondition( - call[0], - (node: MockCondition) => - node.type === 'inArray' && node.column === schemaMock.document.processingStatus - ) - ) - expect(guard).toBeDefined() expect( - hasMockCondition( - guard?.[0], + hasBranch( + statusGuard(), (node: MockCondition) => node.type === 'inArray' && + node.column === schemaMock.document.processingStatus && Array.isArray(node.values) && node.values.join(',') === 'completed,failed' ) ).toBe(true) }) + it('also requeues a pending document whose dispatch is certainly lost', async () => { + dbChainMockFns.returning.mockResolvedValue([{ id: 'doc-1' }]) + + await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined).catch(() => {}) + + /** + * A terminal-only guard strands a document that never left `pending`: a + * worker killed before its claim UPDATE burns an attempt without changing + * status, and past the attempt budget the connector sweep drops it too. + */ + expect( + hasBranch( + statusGuard(), + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'pending' + ) + ).toBe(true) + }) + + it('ages the pending arm from the dispatch stamp on the shared grace', async () => { + vi.useFakeTimers() + const now = new Date('2026-08-20T12:00:00.000Z') + vi.setSystemTime(now) + try { + dbChainMockFns.returning.mockResolvedValue([{ id: 'doc-1' }]) + await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined).catch(() => {}) + + const fragment = flattenBranches(statusGuard()).find( + (node: MockCondition) => typeof node.toSQL === 'function' + ) as unknown as { values: unknown[]; toSQL: () => { sql: string } } + expect(fragment).toBeDefined() + + /** + * Pinned whole: an inverted comparison, or one that drops the COALESCE, + * admits a document dispatched seconds ago and bills a duplicate pass + * alongside the run still waiting in the queue. + */ + expect(fragment.toSQL().sql).toBe('COALESCE(?, ?) < ?') + expect(fragment.values[0]).toBe(schemaMock.document.processingQueuedAt) + // NULL means no dispatch ever stamped the row; `uploadedAt` is the same + // fallback `isStuckDocumentSweepEligible` ages such a document from. + expect(fragment.values[1]).toBe(schemaMock.document.uploadedAt) + expect((fragment.values[2] as { value: Date }).value).toEqual( + new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS) + ) + } finally { + vi.useRealTimers() + } + }) + + it('waits out the same grace the connector sweep waits out', async () => { + /** + * The two recovery paths must agree on when a queued dispatch is lost. A + * retry that admitted `pending` sooner would re-dispatch a document the + * sweep still considers live. + */ + const uploadedAt = new Date('2026-08-20T00:00:00.000Z') + const justInsideGrace = new Date(uploadedAt.getTime() + QUEUED_DISPATCH_GRACE_MS) + const justOutsideGrace = new Date(justInsideGrace.getTime() + 1) + const candidate = { + processingStatus: 'pending' as const, + processingQueuedAt: null, + processingStartedAt: null, + uploadedAt, + } + + expect(isStuckDocumentSweepEligible(candidate, justInsideGrace)).toBe(false) + expect(isStuckDocumentSweepEligible(candidate, justOutsideGrace)).toBe(true) + }) + it('does not dispatch or drop embeddings when it claimed nothing', async () => { // The guarded reset matched no rows: another click already queued this doc. dbChainMockFns.returning.mockResolvedValue([]) @@ -234,3 +333,44 @@ describe('processing attempt budget', () => { ) }) }) + +describe('retryDocumentProcessing dispatch unwind', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The reset commits in its own transaction, so a throwing dispatch leaves the + * row `pending` with nothing queued behind it — and the grace window means the + * same click cannot recover it for hours. Recording the failure returns it to + * `failed`, which is immediately retryable. + */ + it('records the failure on the row it reset when the dispatch throws', async () => { + // The reset claims the document; the dispatch then fails for want of a + // billing context, which this suite deliberately does not stand up. + dbChainMockFns.returning.mockResolvedValue([{ id: 'doc-1' }]) + + const result = await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined) + + expect(result.success).toBe(false) + const failedWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'failed' + ) + expect(failedWrite).toBeDefined() + expect((failedWrite?.[0] as Record).processingError).toEqual( + expect.any(String) + ) + }) + + it('does not report a dead document as a started retry', async () => { + dbChainMockFns.returning.mockResolvedValue([{ id: 'doc-1' }]) + + const result = await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined) + + // Reporting success here paints the UI green over a document that will + // never be indexed. + expect(result.message).not.toContain('retry processing started') + expect(result.status).toBe('failed') + }) +}) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 5e452ba048e..df6951581ce 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -24,6 +24,7 @@ import { isNotNull, isNull, ne, + or, type SQL, sql, } from 'drizzle-orm' @@ -67,7 +68,10 @@ import { mergeDurableSecretProvenance, } from '@/lib/execution/durable-secret-provenance' import { processDocument } from '@/lib/knowledge/documents/document-processor' -import { failStaleDocumentProcessingClaim } from '@/lib/knowledge/documents/processing-claim' +import { + failStaleDocumentProcessingClaim, + recordUndispatchedDocumentFailure, +} from '@/lib/knowledge/documents/processing-claim' import { enqueueKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-outbox-event' import { assertDocumentProcessingBillingContext, @@ -82,7 +86,11 @@ import { buildTagFilterCondition, type TagFilterCondition, } from '@/lib/knowledge/documents/tag-filter' -import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { + type DocumentSortField, + QUEUED_DISPATCH_GRACE_MS, + type SortOrder, +} from '@/lib/knowledge/documents/types' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' import { generateEmbeddings } from '@/lib/knowledge/embeddings' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' @@ -2615,14 +2623,29 @@ export async function retryDocumentProcessing( billingAttribution: BillingAttributionSnapshot | undefined ): Promise<{ success: boolean; status: string; message: string }> { /** - * Only a document in a terminal state may be retried. + * A document may be retried from a terminal state, or from a `pending` state + * old enough that its dispatch is certainly lost. * * Unguarded, a double-click issued two full passes: the second reset a * document that the first had already queued, so both dispatches ran, both - * indexed, and both billed. Restricting the transition to `completed` or - * `failed` makes the second click match no rows, and the empty `returning` - * below stops it dispatching. + * indexed, and both billed. A terminal-only guard closes that, but it also + * strands a document that never left `pending` — a worker killed before its + * claim UPDATE burns an attempt without changing status, and once the + * processing-attempt budget is spent the connector sweep drops it too. The row + * then matches nothing anywhere. + * + * The `pending` arm is admitted only past {@link QUEUED_DISPATCH_GRACE_MS}, + * which is the same grace the connector sweep waits out, so a second click + * still lands inside a live dispatch's window and still matches no rows. + * + * Age is measured from `COALESCE(processingQueuedAt, uploadedAt)`, exactly as + * `isStuckDocumentSweepEligible` measures it. `processingQueuedAt` is NULL + * only for a document no dispatch has ever stamped, and falling back to + * `uploadedAt` — rather than treating NULL as retryable — keeps the grace + * window closed for a document created moments ago whose first dispatch is + * still in flight. */ + const queuedGraceCutoff = new Date(Date.now() - QUEUED_DISPATCH_GRACE_MS) const requeued = await db.transaction(async (tx) => { const reset = await tx .update(document) @@ -2640,7 +2663,13 @@ export async function retryDocumentProcessing( .where( and( eq(document.id, documentId), - inArray(document.processingStatus, ['completed', 'failed']), + or( + inArray(document.processingStatus, ['completed', 'failed']), + and( + eq(document.processingStatus, 'pending'), + sql`COALESCE(${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingQueuedAt)}` + ) + ), isNull(document.archivedAt), isNull(document.deletedAt) ) @@ -2664,21 +2693,44 @@ export async function retryDocumentProcessing( } } - await processDocumentsWithQueue( - [ - { - documentId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - }, - ], - knowledgeBaseId, - {}, - requestId, - billingAttribution - ) + /** + * The reset committed in its own transaction above, so a throwing dispatch + * would leave the row at `pending` with nothing queued behind it — and the + * grace window means the same click cannot recover it until that window + * elapses again. + * Recording the failure returns it to `failed`, which is immediately + * retryable and visible in the document list with its reason. + */ + try { + await processDocumentsWithQueue( + [ + { + documentId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + }, + ], + knowledgeBaseId, + {}, + requestId, + billingAttribution + ) + } catch (error) { + const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') + await recordUndispatchedDocumentFailure({ + documentId, + knowledgeBaseId, + failureMessage, + requestId, + }) + return { + success: false, + status: 'failed', + message: failureMessage, + } + } logger.info(`[${requestId}] Document retry initiated: ${documentId}`) diff --git a/apps/sim/lib/knowledge/documents/types.ts b/apps/sim/lib/knowledge/documents/types.ts index 1d23f5631e3..aeb6477e6ec 100644 --- a/apps/sim/lib/knowledge/documents/types.ts +++ b/apps/sim/lib/knowledge/documents/types.ts @@ -16,6 +16,33 @@ */ export const MAX_PROCESSING_ATTEMPTS = 5 +/** + * Grace period a document that is merely *queued* gets before either recovery + * path may take it: the connector sweep's reclaim, and the user-facing retry. + * + * `STALE_PROCESSING_MINUTES` bounds a run that has already begun, derived from + * the task's own duration and retry budget. Queue *wait* is a different + * quantity: it is backlog / concurrency, not run duration. + * `document-processing-queue` has a global concurrency shared by every + * workspace, so a corpus large enough to approach + * `CONNECTOR_SYNC_MAX_DURATION_SECONDS` enqueues thousands of documents that + * drain in waves of that width — at roughly a minute of occupancy each, a few + * hours, and longer while other workspaces hold slots. + * + * Four hours is chosen against three bounds that are all constants in this + * repository rather than any one deployment's corpus: it is well above that + * drain estimate, an order of magnitude above the one-hour sync ceiling, and + * still well under the 1,440-minute default sync interval — so a + * default-configured connector waits no longer for recovery than it already did. + * + * Shared rather than owned by the sweep because both recovery paths have to + * agree on when a queued dispatch is certainly lost. A retry that admitted a + * `pending` document sooner would re-dispatch one still waiting its turn and + * bill a second indexing pass — the double-billing the terminal-only guard was + * added to close. + */ +export const QUEUED_DISPATCH_GRACE_MS = 240 * 60 * 1000 + /** * Every value `document.processing_status` may hold. * diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index 6131c462c93..4f799a85b83 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -226,6 +226,52 @@ describe('performUpdateKnowledgeConnector', () => { ) }) + it('refuses to flip the status of a connector that is mid-sync', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'syncing' }, + ]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { status: 'active' }, + }) + + /** + * `status: 'active'` also writes `nextSyncAt = now`, which summons a second + * run alongside the one already holding the lock. `performSyncKnowledgeConnector` + * already refuses on the same condition; this is the other half. + */ + expect(outcome).toMatchObject({ + success: false, + errorCode: 'conflict', + error: 'Sync already in progress', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses a non-status edit mid-sync too', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'syncing' }, + ]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'other' } }, + }) + + /** + * `sourceConfig` is read once at the start of a run and threaded through it, + * so an edit mid-flight yields a pass that lists against one config and + * reconciles against another — and reconciliation hard-deletes. + */ + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('leaves semantic audit to an authorized application caller when requested', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) dbChainMockFns.returning.mockResolvedValueOnce([ diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 3071f5287c7..1dc6dee58e9 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -426,6 +426,27 @@ export async function performUpdateKnowledgeConnector( if (!existing) { return fail('Connector not found', 'not_found') } + /** + * A running sync owns the row, so no edit is applied while it holds the lock. + * + * `performSyncKnowledgeConnector` already refuses on the same condition; this + * is the other half. `status: 'active'` sets `nextSyncAt = new Date()`, which + * summons a second run alongside the first, and every write here moves + * `updatedAt` — which the stale-lock reaper read as the lock's lease, so the + * only two controls the UI leaves enabled on a wedged connector both pushed + * its recovery out by another full TTL. + * + * A non-status edit is refused too, not just a status flip. `sourceConfig` is + * read once at the start of a run and threaded through it, so changing it + * mid-flight yields a pass that lists against one config and reconciles + * against another — and reconciliation hard-deletes. `syncIntervalMinutes` + * writes a `nextSyncAt` the run's own terminal write overwrites moments later, + * so allowing it would silently discard the change. Refusing is the only + * answer that is honest about either. + */ + if (existing.status === 'syncing') { + return fail('Sync already in progress', 'conflict') + } if (updates.syncIntervalMinutes !== undefined) { if (!kb.workspaceId && updates.syncIntervalMinutes > 0 && updates.syncIntervalMinutes < 60) { diff --git a/apps/sim/lib/knowledge/orchestration/documents.test.ts b/apps/sim/lib/knowledge/orchestration/documents.test.ts index aef8d2eafca..78cfbcbb14f 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.test.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -75,6 +76,23 @@ const FILE = { } const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } +/** + * Lets the fire-and-forget dispatch settle. Both upload paths queue indexing + * after their response is decided, so the unwind runs on a later microtask. + */ +async function settleDispatch(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +/** The `document` write that records a dispatch that never got off the ground. */ +function undispatchedFailureWrites(): Record[] { + return dbChainMockFns.set.mock.calls + .map((call) => call[0] as Record) + .filter((values) => values?.processingStatus === 'failed') +} + describe('performUploadKnowledgeDocument', () => { beforeEach(() => { vi.clearAllMocks() @@ -82,6 +100,31 @@ describe('performUploadKnowledgeDocument', () => { mockGetDocumentByUploadId.mockResolvedValue(null) mockProcessDocumentsWithQueue.mockResolvedValue(undefined) mockProcessDocumentAsync.mockResolvedValue(undefined) + resetDbChainMock() + }) + + /** + * Upload documents carry no `connector_id`, so the connector-scoped + * stuck-document sweep never sees them. Logging the failure and walking away + * leaves the row at `pending`, where nothing finds it again. + */ + it('marks the document failed when its queued dispatch never got off the ground', async () => { + mockProcessDocumentsWithQueue.mockRejectedValue(new Error('queue unavailable')) + + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + startProcessing: 'queue', + }) + await settleDispatch() + + expect(undispatchedFailureWrites()).toEqual([ + expect.objectContaining({ + processingStatus: 'failed', + processingError: 'queue unavailable', + }), + ]) }) it('audits an agent upload, which the copilot path never did', async () => { @@ -259,6 +302,21 @@ describe('performUploadKnowledgeDocuments', () => { { documentId: 'doc-2', filename: 'b.pdf' }, ]) mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + resetDbChainMock() + }) + + /** Every document in the batch is stranded by one failed dispatch, not just the first. */ + it('marks every document in the batch failed when its dispatch never got off the ground', async () => { + mockProcessDocumentsWithQueue.mockRejectedValue(new Error('queue unavailable')) + + await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [FILE, { ...FILE, filename: 'b.pdf' }], + }) + await settleDispatch() + + expect(undispatchedFailureWrites()).toHaveLength(2) }) it('admits the whole batch in one call and queues it', async () => { @@ -428,16 +486,60 @@ describe('document processing state changes', () => { expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) }) - it('refuses to retry a document that has not failed', async () => { + it('refuses to retry a document in a state no retry applies to', async () => { const outcome = await performRetryKnowledgeDocumentProcessing({ knowledgeBaseId: 'kb-1', - document: { ...FILE, id: 'doc-1', processingStatus: 'completed' }, + document: { ...FILE, id: 'doc-1', processingStatus: 'processing' }, }) expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) expect(mockRetryDocumentProcessing).not.toHaveBeenCalled() }) + it('lets a stranded pending document reach the guarded requeue', async () => { + mockRetryDocumentProcessing.mockResolvedValue({ + success: true, + status: 'pending', + message: 'Document retry processing started', + }) + + /** + * A worker killed before its claim UPDATE burns a processing attempt without + * moving the document off `pending`, and once its budget is spent the + * connector sweep stops taking it too. Rejecting `pending` here made that + * row unrecoverable from every surface — the widened SQL guard below it is + * unreachable while this check refuses to call it. + */ + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'pending' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRetryDocumentProcessing).toHaveBeenCalled() + }) + + it('reports a retry whose dispatch never got off the ground as a failure', async () => { + mockRetryDocumentProcessing.mockResolvedValue({ + success: false, + status: 'failed', + message: 'queue unavailable', + }) + + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'failed' }, + }) + + // Hard-coding `success: true` here painted the UI green over a document + // that will never be indexed. + expect(outcome).toMatchObject({ + success: false, + errorCode: 'internal', + error: 'queue unavailable', + }) + }) + it('re-queues a failed document and never audits it', async () => { mockRetryDocumentProcessing.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index 29947a14da1..4e856a729ea 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -5,6 +5,7 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attr import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { dispatchDocumentProcessing } from '@/lib/knowledge/documents/processing-dispatch' import { createDocumentRecords, createSingleDocument, @@ -14,7 +15,6 @@ import { markDocumentAsFailedTimeout, type ProcessingOptions, processDocumentAsync, - processDocumentsWithQueue, retryDocumentProcessing, updateDocument, } from '@/lib/knowledge/documents/service' @@ -249,14 +249,12 @@ export async function performUploadKnowledgeDocument( } if (startProcessing === 'queue') { - processDocumentsWithQueue( - [documentData], - knowledgeBase.id, - processingOptions ?? {}, + void dispatchDocumentProcessing({ + documents: [documentData], + knowledgeBaseId: knowledgeBase.id, + processingOptions: processingOptions ?? {}, requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error(`[${requestId}] Document processing pipeline failed`, { error }) + billingAttribution, }) } else if (startProcessing === 'async') { processDocumentAsync( @@ -354,14 +352,12 @@ export async function performUploadKnowledgeDocuments( logger.info(`[${requestId}] Starting controlled async processing of ${created.length} documents`) - processDocumentsWithQueue( - created, - knowledgeBase.id, - processingOptions ?? {}, + void dispatchDocumentProcessing({ + documents: created, + knowledgeBaseId: knowledgeBase.id, + processingOptions: processingOptions ?? {}, requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline`, { error }) + billingAttribution, }) if (params.recordProductAnalytics !== false) { @@ -574,8 +570,23 @@ export async function performRetryKnowledgeDocumentProcessing( const { knowledgeBaseId, document, billingAttribution } = params const requestId = params.requestId ?? generateRequestId() - if (document.processingStatus !== 'failed') { - return fail('Document is not in failed state', 'validation') + /** + * `pending` is admitted alongside `failed`, and the decision is left to the + * guarded requeue itself. + * + * A worker killed before its claim UPDATE burns a processing attempt without + * moving the document off `pending`, and once its attempt budget is spent the + * connector sweep stops taking it too. Rejecting `pending` here made that row + * unrecoverable from every surface. Whether it is old enough to be certainly + * abandoned is a race-sensitive question the requeue answers in SQL against + * the same grace the sweep uses; this check only rejects the states no retry + * can ever apply to. + */ + if (document.processingStatus !== 'failed' && document.processingStatus !== 'pending') { + return fail( + `Document is not in a retryable state (current: ${document.processingStatus})`, + 'validation' + ) } try { @@ -591,6 +602,12 @@ export async function performRetryKnowledgeDocumentProcessing( requestId, billingAttribution ) + // Forwarded rather than hard-coded to `true`: a retry whose dispatch never + // got off the ground leaves a dead document, and reporting that as success + // paints the UI green over it. + if (!result.success) { + return fail(result.message, 'internal') + } return { success: true, status: result.status, message: result.message } } catch (error) { return classifyKnowledgeFailure(error, requestId, `Retry document ${document.id}`) diff --git a/packages/db/migrations/0298_nosy_ken_ellis.sql b/packages/db/migrations/0298_nosy_ken_ellis.sql new file mode 100644 index 00000000000..b3a89558f8d --- /dev/null +++ b/packages/db/migrations/0298_nosy_ken_ellis.sql @@ -0,0 +1,15 @@ +ALTER TABLE "document" ADD COLUMN "processing_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "knowledge_connector" ADD COLUMN "sync_lock_token" text;--> statement-breakpoint +ALTER TABLE "knowledge_connector" ADD COLUMN "sync_lock_lease_at" timestamp;--> statement-breakpoint +-- knowledge_connector_sync_log is an append-only, never-pruned history that the +-- five-minute scheduler tick now scans for orphaned `started` rows. Build the +-- partial index without taking a table-wide write lock: the runner opens a +-- transaction per pending batch, so end it before CONCURRENTLY. Everything below +-- is replayable even when a failed concurrent build left an INVALID same-named +-- index behind. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- migration-safe: replay cleanup for the index introduced by this same migration; CONCURRENTLY preserves sync-log writes +DROP INDEX CONCURRENTLY IF EXISTS "kcsl_started_at_partial_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "kcsl_started_at_partial_idx" ON "knowledge_connector_sync_log" USING btree ("started_at") WHERE "knowledge_connector_sync_log"."status" = 'started';--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/0298_shallow_silver_sable.sql b/packages/db/migrations/0298_shallow_silver_sable.sql deleted file mode 100644 index 2cd7e9778d0..00000000000 --- a/packages/db/migrations/0298_shallow_silver_sable.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "document" ADD COLUMN "processing_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint -ALTER TABLE "knowledge_connector" ADD COLUMN "sync_lock_token" text; \ No newline at end of file diff --git a/packages/db/migrations/meta/0298_snapshot.json b/packages/db/migrations/meta/0298_snapshot.json index 6ffd7f840d6..41ea970bbbd 100644 --- a/packages/db/migrations/meta/0298_snapshot.json +++ b/packages/db/migrations/meta/0298_snapshot.json @@ -1,5 +1,5 @@ { - "id": "7d6f83e8-3845-40a9-b29d-81764850f548", + "id": "ae56869c-b65a-475c-bec9-6e081bddc59e", "prevId": "c9c21e6c-7324-484b-b303-ab7b4fd9ab6d", "version": "7", "dialect": "postgresql", @@ -7880,6 +7880,12 @@ "primaryKey": false, "notNull": false }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -8086,6 +8092,22 @@ "concurrently": false, "method": "btree", "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index be480c8543b..473a811c570 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2084,8 +2084,8 @@ { "idx": 298, "version": "7", - "when": 1787282609732, - "tag": "0298_shallow_silver_sable", + "when": 1787293309837, + "tag": "0298_nosy_ken_ellis", "breakpoints": true } ] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 2785081a9a4..cdc16e7d367 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4349,6 +4349,22 @@ export const knowledgeConnector = pgTable( * match this token so a run can prove the lock is still *its own*. */ syncLockToken: text('sync_lock_token'), + /** + * When the run holding this connector's lock last proved it was alive. + * + * Split off `updated_at`, which the stale-lock reaper used to read as a + * lease. `updated_at` is the row's modification time, so every unrelated + * write — a config edit, a status change — renewed the lease of a wedged + * run and pushed its recovery out by another full TTL. Only lock + * acquisition and the heartbeat write this column; both terminal helpers + * clear it alongside `sync_lock_token`. + * + * NULL on a row locked before this column existed, and on any future writer + * that forgets it, so every reader compares `COALESCE(lease, updated_at)` + * rather than the lease alone — a `lease <= cutoff` test is NULL-false and + * would make such a row permanently unreclaimable. + */ + syncLockLeaseAt: timestamp('sync_lock_lease_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), archivedAt: timestamp('archived_at'), @@ -4388,6 +4404,20 @@ export const knowledgeConnectorSyncLog = pgTable( }, (table) => ({ connectorIdIdx: index('kcsl_connector_id_idx').on(table.connectorId), + /** + * Serves the scheduler's five-minute sweep for orphaned `started` rows. + * + * This table is append-only and never pruned, and `connector_id` does not + * help a scan that filters on status and age, so the sweep was a sequential + * scan of all sync history on every tick. The predicate is partial rather + * than a composite `(status, started_at)`: `started` rows are a vanishing + * fraction of the table and the only ones the sweep ever reads, so indexing + * closed history buys nothing and costs write amplification on every + * completion. + */ + startedPartialIdx: index('kcsl_started_at_partial_idx') + .on(table.startedAt) + .where(sql`${table.status} = 'started'`), }) ) diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index c46d957e212..c86ffcebd92 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1283,6 +1283,7 @@ export const schemaMock = { nextSyncAt: 'knowledgeConnector.nextSyncAt', consecutiveFailures: 'knowledgeConnector.consecutiveFailures', syncLockToken: 'knowledgeConnector.syncLockToken', + syncLockLeaseAt: 'knowledgeConnector.syncLockLeaseAt', createdAt: 'knowledgeConnector.createdAt', updatedAt: 'knowledgeConnector.updatedAt', archivedAt: 'knowledgeConnector.archivedAt', From 712b2c425c9a5cd001c64bae60f8580918c0f4ae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 21 Aug 2026 09:19:04 -0700 Subject: [PATCH 14/14] fix(knowledge): guard the completed sync log and free a deleted run's lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `executeSync`'s success path closed its sync-log row before `writeTerminalConnectorState` ran its ownership check, and the close was guarded only on `status = 'started'`. That guard defers to the scheduler's sweep, but the sweep is not the only writer that strands a live run: the knowledge-base-deleted writers clear the token unconditionally, a user pausing a connector flips it out of `syncing`, and the reaper's reclaim and its log-close are two statements that can commit apart. In each case the run's connector write is refused while its log row is still `started`, so the run published a `completed` row for bookkeeping that was discarded — and `loadPreviousListingObservation` reads exactly those rows as corroboration for the next run's reconciliation. The close now takes the ownership condition itself, reusing `stillHoldsSyncLock` as an EXISTS predicate so the log row and the connector row are written under the same condition and cannot disagree. Swapping the two calls was considered and rejected: a `completeSyncLog` failure would then leave a `started` row on a connector already recorded `active`, the reaper would later mark it `failed`, and a legitimate observation would be lost silently. Only the success path is guarded — a `failed` row is never read back as evidence, and both failure paths legitimately close a run whose lock is already gone. A refused close short-circuits to the superseded result the terminal write would have produced two statements later. The `ConnectorDeletedException` handler hard-deleted leftover documents and closed its log, but wrote nothing to the connector row, leaving it `syncing` with a live token. Nothing else could clear it: the reaper requires `isNull(archivedAt)` and `isNull(deletedAt)`, so the one writer able to recover a stranded lock skips exactly the rows this path creates. It now releases token and lease and makes the transition terminal, matching the two knowledge-base-deleted writers. Guarded on ownership alone rather than `stillHoldsSyncLock`, for the same reason the heartbeat is: the connector being archived is this path's precondition, so a liveness clause would reject every write the release exists to make. A no-op when the row was hard deleted rather than archived — a user-initiated connector delete removes the row outright, leaving nothing to unwedge. --- .../knowledge/connectors/sync-engine.test.ts | 279 ++++++++++++++++++ .../lib/knowledge/connectors/sync-engine.ts | 136 ++++++++- 2 files changed, 405 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 34bcdf45894..474175ad4b4 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -2132,3 +2132,282 @@ describe('executeSync hard-delete reconciliation', () => { expect(beats.length).toBeGreaterThan(1) }) }) + +describe('completeSyncLog ownership guard', () => { + const RESULT = { + docsAdded: 1, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsFailed: 0, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('requires the run to still hold the connector lock when closing as completed', async () => { + const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine') + + await completeSyncLog('log-1', 'completed', RESULT, { requireSyncLockOn: 'c-1' }) + + /** + * `status = 'started'` alone only defers to the sweep. A run stranded by any + * other writer — the knowledge-base-deleted writers, a user pausing the + * connector, a reclaim whose log-close committed separately — still has a + * `started` row, so without this it publishes a `completed` outcome whose + * connector bookkeeping was discarded. + */ + const outerWhere = dbChainMockFns.where.mock.calls[1][0] + expect(hasMockCondition(outerWhere, (node: MockCondition) => node.type === 'exists')).toBe(true) + + // The subquery's own predicate, built before the outer where is assembled. + const subqueryWhere = dbChainMockFns.where.mock.calls[0][0] + expect( + hasMockCondition( + subqueryWhere, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.syncLockToken && + node.right === 'log-1' + ) + ).toBe(true) + expect( + hasMockCondition( + subqueryWhere, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.status && + node.right === 'syncing' + ) + ).toBe(true) + expect( + hasMockCondition( + subqueryWhere, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.id && + node.right === 'c-1' + ) + ).toBe(true) + /** + * Reuses `stillHoldsSyncLock`, not ownership alone, so the log row and the + * connector row are written under exactly the same condition. Ownership-only + * would let a connector archived mid-run publish a `completed` row for a + * terminal write that was refused — the same mismatch, differently triggered. + */ + expect( + hasMockCondition( + subqueryWhere, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + }) + + it('leaves both failure closes unguarded', async () => { + const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine') + + /** + * A `failed` row is never read back as evidence — + * `loadPreviousListingObservation` selects `status = 'completed'` — and both + * failure paths legitimately close a run whose lock is already gone. The + * deleted-connector path in particular runs on an archived row the reaper + * skips, so guarding it would strand the log row instead of closing it. + */ + await completeSyncLog('log-1', 'failed', RESULT, { errorMessage: 'boom' }) + + const where = dbChainMockFns.where.mock.calls[0][0] + expect(hasMockCondition(where, (node: MockCondition) => node.type === 'exists')).toBe(false) + }) + + it('reports whether the close landed', async () => { + const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine') + + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'log-1' }]) + await expect( + completeSyncLog('log-1', 'completed', RESULT, { requireSyncLockOn: 'c-1' }) + ).resolves.toBe(true) + + dbChainMockFns.returning.mockResolvedValueOnce([]) + await expect( + completeSyncLog('log-1', 'completed', RESULT, { requireSyncLockOn: 'c-1' }) + ).resolves.toBe(false) + }) +}) + +describe('executeSync terminal exits under a lost lock', () => { + const CONNECTOR = { + id: 'c-1', + knowledgeBaseId: 'kb-1', + connectorType: 'paged', + credentialId: null, + encryptedApiKey: null, + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncDocCount: 0, + consecutiveFailures: 0, + syncLockToken: null, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterEach(() => { + resetDbChainMock() + }) + + /** Queues the connector, its knowledge base, and the lock CAS. */ + function primeLockedRun() { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + } + + it('skips the success state write when its guarded log close is refused', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + + primeLockedRun() + // hasTombstonedDocs, existingDocs, tombstonedDocs, excludedDocs. + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + // The post-batch presence check: both targets are healthy, so the run + // reaches its success path rather than a deletion exit. + queueTableRows(schemaMock.knowledgeConnector, [ + { connectorArchivedAt: null, connectorDeletedAt: null, kbDeletedAt: null }, + ]) + // Every later `.returning()` falls through to the empty default, so the + // guarded log close matches no row — the run no longer owns its outcome. + mockListDocuments.mockResolvedValue({ documents: [], hasMore: false }) + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + + expect(result.error).toBe('sync_superseded') + + /** + * A refused close means the run no longer owns the outcome it was about to + * publish, which is exactly what the terminal connector write would have + * rejected two statements later. Short-circuiting there keeps the reported + * outcome identical while skipping the intervening document count. + */ + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'active', consecutiveFailures: 0 }) + ) + + // The success call site is the one that must ask for the guard. + expect( + dbChainMockFns.where.mock.calls.some((call) => + hasMockCondition(call[0], (node: MockCondition) => node.type === 'exists') + ) + ).toBe(true) + }) + + it('releases the lock on a connector archived out from under the run', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') + + primeLockedRun() + // hasTombstonedDocs, existingDocs, tombstonedDocs, excludedDocs. + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + // The per-batch presence check: the connector row is archived. + queueTableRows(schemaMock.knowledgeConnector, [ + { connectorArchivedAt: new Date(), connectorDeletedAt: null, kbDeletedAt: null }, + ]) + // The leftover-document cleanup this path performs. + queueTableRows(schemaMock.document, []) + vi.mocked(hardDeleteDocuments).mockResolvedValue(0) + + mockListDocuments.mockResolvedValue({ + documents: [ + { + externalId: 'ext-1', + title: 'ext-1', + content: 'body', + contentHash: 'h', + mimeType: 'text/plain', + metadata: {}, + }, + ], + hasMore: false, + }) + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + + expect(result.error).toBe('Connector deleted during sync') + + /** + * This exit wrote nothing to the connector row, leaving it `syncing` with a + * live token. The reaper requires `isNull(archivedAt)` and `isNull(deletedAt)`, + * so the one writer that could clear a stranded lock skips exactly the rows + * this path creates. Matches the two knowledge-base-deleted writers: release + * token and lease, and make the transition terminal. + */ + const release = dbChainMockFns.set.mock.calls.find( + (call) => + (call[0] as Record | undefined)?.lastSyncError === + 'Connector deleted during sync' + ) + expect(release?.[0]).toEqual( + expect.objectContaining({ + status: 'error', + nextSyncAt: null, + syncLockToken: null, + syncLockLeaseAt: null, + }) + ) + + /** + * Guarded on ownership alone, never on {@link stillHoldsSyncLock}: the + * connector being archived is this path's precondition, so a liveness clause + * would reject every write the release exists to make. + */ + const releaseOrder = + dbChainMockFns.set.mock.invocationCallOrder[ + dbChainMockFns.set.mock.calls.indexOf(release as never) + ] + const releaseWhereIndex = dbChainMockFns.where.mock.invocationCallOrder.findIndex( + (order) => order > releaseOrder + ) + const releaseWhere = dbChainMockFns.where.mock.calls[releaseWhereIndex][0] + expect( + hasMockCondition( + releaseWhere, + (node: MockCondition) => + node.type === 'eq' && node.left === schemaMock.knowledgeConnector.syncLockToken + ) + ).toBe(true) + expect( + hasMockCondition( + releaseWhere, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(false) + + /** + * And this path's log close stays unguarded. Its connector is archived, so an + * ownership-guarded close would match nothing and leave the row `started` + * until the sync-log sweep mislabelled it. + */ + expect( + dbChainMockFns.where.mock.calls.some((call) => + hasMockCondition(call[0], (node: MockCondition) => node.type === 'exists') + ) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index f66ae62da75..905f9fdfa9a 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -10,7 +10,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { randomInt } from '@sim/utils/random' -import { and, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm' +import { and, desc, eq, exists, gt, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' import { assertBillingAttributionSnapshot, @@ -504,6 +504,48 @@ function calculateNextSyncTime(syncIntervalMinutes: number): Date | null { return new Date(now + syncIntervalMinutes * 60_000 + jitterMs) } +/** Options for a sync-log close. */ +interface CompleteSyncLogOptions { + /** Recorded on the row when the run is being closed as `failed`. */ + errorMessage?: string + /** + * Connector whose sync lock this run must still hold for the close to land. + * + * Only the success path passes it. A `completed` row is the one sync-log + * state that is read back as evidence — {@link loadPreviousListingObservation} + * selects `status = 'completed'` — so it must not outlive the connector + * bookkeeping it corroborates. `failed` rows are never read that way, and both + * failure paths legitimately close a run whose lock is already gone. + */ + requireSyncLockOn?: string +} + +/** + * Matches the log row only while its run still holds the connector's sync lock. + * + * The row's own `status = 'started'` guard defers to the scheduler's sweep, but + * the sweep is not the only writer that can strand a live run. The + * knowledge-base-deleted writers clear the token unconditionally, a user pausing + * a connector flips it out of `syncing`, and the reaper's reclaim and its + * log-close are two statements that can commit apart. In each case the run's + * terminal connector write is refused while its log row is still `started`, so an + * unguarded close publishes a `completed` row for bookkeeping that was discarded. + * + * Reuses {@link stillHoldsSyncLock} rather than restating the predicate, so the + * log row and the connector row are written under exactly the same condition and + * cannot disagree. A refused close leaves the row `started`; the scheduler's + * sync-log sweep drains it, and that sweep is deliberately not connector-scoped, + * so it still closes the row on an archived connector the reclaim skips. + */ +function syncLogRunStillHoldsLock(connectorId: string, syncLogId: string) { + return exists( + db + .select({ held: sql`1` }) + .from(knowledgeConnector) + .where(stillHoldsSyncLock(connectorId, syncLogId)) + ) +} + /** * Records a sync run's outcome on its log row. * @@ -513,17 +555,24 @@ function calculateNextSyncTime(syncIntervalMinutes: number): Date | null { * race and produce contradictory history: the sweep marks the row `failed`, * then the still-running sync reports `completed` on the same row. * - * A no-op on the normal path — nothing else touches the row between its - * `started` insert and this call, so the guard only ever bites once the sweep + * That guard alone is a no-op on the normal path — nothing else touches the row + * between its `started` insert and this call — so it only bites once the sweep * has declared the run dead, and the sweep's verdict is the one that stands. + * {@link CompleteSyncLogOptions.requireSyncLockOn} covers the writers that strand + * a run without going through the sweep. + * + * Returns whether the close landed. False means this run no longer owns the + * outcome it was about to publish. */ export async function completeSyncLog( syncLogId: string, status: 'completed' | 'failed', result: SyncResult, - errorMessage?: string -): Promise { - await db + options: CompleteSyncLogOptions = {} +): Promise { + const { errorMessage, requireSyncLockOn } = options + + const closed = await db .update(knowledgeConnectorSyncLog) .set({ status, @@ -538,9 +587,15 @@ export async function completeSyncLog( .where( and( eq(knowledgeConnectorSyncLog.id, syncLogId), - eq(knowledgeConnectorSyncLog.status, 'started') + eq(knowledgeConnectorSyncLog.status, 'started'), + ...(requireSyncLockOn != null + ? [syncLogRunStillHoldsLock(requireSyncLockOn, syncLogId)] + : []) ) ) + .returning({ id: knowledgeConnectorSyncLog.id }) + + return closed.length > 0 } /** @@ -680,6 +735,45 @@ export async function writeTerminalConnectorState( return written.length > 0 } +/** + * Releases the sync lock on a connector that was archived out from under a + * running sync. + * + * `ConnectorDeletedException`'s handler is a terminal exit that wrote nothing to + * the connector row, leaving it `status = 'syncing'` with this run's token still + * on it. Nothing else can clear that: the scheduler's reclaim requires + * `isNull(archivedAt)` and `isNull(deletedAt)`, so the one writer able to correct + * a stranded lock skips exactly the rows this path creates. Both other "the + * target is gone" exits — the knowledge-base-deleted writers here and in the + * dispatch queue — already release token and lease and make the transition + * terminal; this makes the third behave the same way. + * + * Guarded on {@link holdsSyncLockToken} rather than {@link stillHoldsSyncLock} + * for the same reason the heartbeat is: the connector being archived is the + * precondition of this path, so requiring it to still be live would reject every + * write this function exists to make. Ownership alone is enough — the token + * proves the lock is this run's, so a replacement's lock can never be released. + * + * A no-op when the connector row was hard-deleted rather than archived, which is + * what a user-initiated connector delete does: there is no row left to unwedge. + */ +async function releaseSyncLockOnDeletedConnector( + connectorId: string, + syncLogId: string +): Promise { + await db + .update(knowledgeConnector) + .set({ + status: 'error', + nextSyncAt: null, + lastSyncError: 'Connector deleted during sync', + syncLockToken: null, + syncLockLeaseAt: null, + updatedAt: new Date(), + }) + .where(holdsSyncLockToken(connectorId, syncLogId)) +} + /** * Reported when a run loses its connector's lock mid-flight — either because a * heartbeat found the lock reclaimed, or because its terminal write matched no @@ -2340,7 +2434,25 @@ export async function executeSync( } } - await completeSyncLog(syncLogId, 'completed', result) + const logClosed = await completeSyncLog(syncLogId, 'completed', result, { + requireSyncLockOn: connectorId, + }) + + /** + * Short-circuits on exactly the condition {@link writeTerminalConnectorState} + * would have rejected two statements later, so the outcome is unchanged and + * the intervening document count is skipped. Returning here is what keeps a + * discarded run from publishing the `completed` row that + * {@link loadPreviousListingObservation} reads as corroboration. + */ + if (!logClosed) { + logger.warn('Sync result discarded — connector was reclaimed while this run was executing', { + connectorId, + syncLogId, + ...result, + }) + return markSyncSuperseded(result) + } const [{ count: actualDocCount }] = await db .select({ count: sql`count(*)::int` }) @@ -2396,6 +2508,8 @@ export async function executeSync( logger.info('Connector deleted during sync, cleaning up', { connectorId }) try { + await releaseSyncLockOnDeletedConnector(connectorId, syncLogId) + // Includes pending-removal (tombstoned) docs — the connector is gone, so // there's no future sync left to confirm or resurrect them. const connectorDocs = await db @@ -2409,7 +2523,9 @@ export async function executeSync( connectorId ) - await completeSyncLog(syncLogId, 'failed', result, 'Connector deleted during sync') + await completeSyncLog(syncLogId, 'failed', result, { + errorMessage: 'Connector deleted during sync', + }) } catch (cleanupError) { logger.error('Failed to clean up after connector deletion', { connectorId, @@ -2425,7 +2541,7 @@ export async function executeSync( logger.error('Sync failed', { connectorId, error: errorMessage }) try { - await completeSyncLog(syncLogId, 'failed', result, errorMessage) + await completeSyncLog(syncLogId, 'failed', result, { errorMessage }) const failureUpdate = buildSyncFailureUpdate( new Date(),