From 9261c4a1f3b7c6b6196ab1fde59f9fe0b3c5b4c1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 13:54:45 -0700 Subject: [PATCH 1/7] fix(confluence): index mirrored/included page content via rendered view format The KB connector fetched page bodies as body-format=storage, which only carries unexpanded macro references (Include Page / Excerpt Include). Those 'mirrored' articles were stripped to empty content by htmlToPlainText and never synced. Switch getDocument to body-format=view (supported on the v2 single-item page/blogpost GET) so built-in include/excerpt macros render inline and the included text is indexed. --- apps/sim/connectors/confluence/confluence.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index af8e7941262..c216cb4cc47 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -233,10 +233,18 @@ export const confluenceConnector: ConnectorConfig = { if (syncContext) syncContext.cloudId = cloudId } - // Try pages first, fall back to blogposts if not found + /** + * Fetch the `view` representation rather than `storage`. Storage format only + * carries unexpanded macro references (e.g. Include Page / Excerpt Include), + * so "mirrored" content that pulls in another page's body is stripped to + * nothing by `htmlToPlainText`. The `view` representation is server-rendered + * HTML with those macros expanded inline, so included content is indexed too. + * The v2 single-item GET (`/pages/{id}`, `/blogposts/{id}`) supports + * `body-format=view`; only the bulk list endpoints are limited to storage/adf. + */ let page: Record | null = null for (const endpoint of ['pages', 'blogposts']) { - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${externalId}?body-format=storage` + const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${externalId}?body-format=view` const response = await fetchWithRetry(url, { method: 'GET', headers: { @@ -256,8 +264,8 @@ export const confluenceConnector: ConnectorConfig = { if (!page) return null const body = page.body as Record | undefined - const storage = body?.storage as Record | undefined - const rawContent = (storage?.value as string) || '' + const view = body?.view as Record | undefined + const rawContent = (view?.value as string) || '' const plainText = htmlToPlainText(rawContent) const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)]) From 0980add2b91406a478ed9712baecf07122dcf719 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 14:02:15 -0700 Subject: [PATCH 2/7] fix(confluence): invalidate existing doc hashes on representation change The version-based contentHash meant already-synced mirrored documents (with stale empty content) classified as 'unchanged' and never re-hydrated with the new rendered view content. Embed a body-representation marker in the hash so a representation change invalidates every previously-synced Confluence document, forcing a one-time re-hydration that picks up the expanded include/excerpt text. --- apps/sim/connectors/confluence/confluence.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index c216cb4cc47..4cc5e81f705 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -91,6 +91,16 @@ async function fetchLabelsForPages( return labelsByPageId } +/** + * Body representation marker embedded in the contentHash. Bumping this + * invalidates every previously-synced Confluence document so a one-time + * re-hydration picks up content newly reachable by the current extraction + * (e.g. the switch from `storage` to rendered `view`, which expands Include + * Page / Excerpt macros). Without it, already-indexed pages whose version is + * unchanged classify as `unchanged` and keep their stale (empty) content. + */ +const CONTENT_REPRESENTATION = 'view' + /** * Produces a canonical metadata stub with a deterministic contentHash that * does not depend on which API surface (v1 CQL or v2) returned the page. @@ -115,7 +125,7 @@ function pageToStub( contentDeferred: true, mimeType: 'text/plain', sourceUrl: options.sourceUrl, - contentHash: `confluence:${page.id}:${versionKey}`, + contentHash: `confluence:${CONTENT_REPRESENTATION}:${page.id}:${versionKey}`, metadata: { spaceId: options.spaceId, status: page.status, From 5789a6a4b873f735d21397c892e264b36ccd0fa2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 14:34:04 -0700 Subject: [PATCH 3/7] feat(connectors): full resync re-hydrates rendered content (transclusions) Version-based change detection can't see when a Confluence page's rendered view changes because an *included* page was edited (the container's version doesn't bump). Add a 'Full resync' path so that drift can be recovered: - ConnectorMeta.rehydrateOnFullSync flag (set for Confluence) - on fullSync, classifyExternalDoc promotes unchanged deferred docs to update and the hydration guard re-indexes unconditionally, so rendered content is refreshed - fullSync threaded through the manual sync contract (query param), route, and hook - 'Sync now' / 'Full resync' dropdown on the connector card Incremental syncs stay hash-gated and cheap; only the deliberate full resync pays the re-index cost. --- .../[connectorId]/sync/route.test.ts | 44 +++++++++++ .../connectors/[connectorId]/sync/route.ts | 9 ++- .../connectors-section/connectors-section.tsx | 77 ++++++++++++------- apps/sim/connectors/confluence/confluence.ts | 8 ++ apps/sim/connectors/types.ts | 14 ++++ apps/sim/hooks/queries/kb/connectors.ts | 9 ++- .../lib/api/contracts/knowledge/connectors.ts | 12 +++ .../knowledge/connectors/sync-engine.test.ts | 18 +++++ .../lib/knowledge/connectors/sync-engine.ts | 28 ++++++- 9 files changed, 186 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index ed4104cfb6c..0276b138211 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -138,6 +138,50 @@ describe('Connector Manual Sync API Route', () => { expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { billingAttribution, requestId: 'test-req-id', + fullSync: false, + }) + }) + + it('dispatches a full resync when fullSync=true is set', async () => { + const billingAttribution = { + actorUserId: 'external-admin', + workspaceId: 'ws-1', + organizationId: null, + billedAccountUserId: 'owner-1', + billingEntity: { type: 'user' as const, id: 'owner-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, + } + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + authType: 'session', + userId: 'external-admin', + userName: 'Test', + userEmail: 'test@test.com', + }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) + mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) + mockResolveBillingAttribution.mockResolvedValue(billingAttribution) + + const req = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/knowledge/kb-123/connectors/conn-456/sync?fullSync=true' + ) + const response = await POST(req as never, { params: mockParams }) + + expect(response.status).toBe(200) + expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { + billingAttribution, + requestId: 'test-req-id', + fullSync: true, }) }) }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 9ef9cae6099..355128402be 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -29,6 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context) if (!parsed.success) return parsed.response const { id: knowledgeBaseId, connectorId } = parsed.data.params + const fullSync = parsed.data.query?.fullSync === 'true' try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -81,7 +82,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route workspaceId: kbWorkspaceId, }) - logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`) + logger.info( + `[${requestId}] Manual ${fullSync ? 'full ' : ''}sync triggered for connector ${connectorId}` + ) captureServerEvent( auth.userId, @@ -109,12 +112,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route knowledgeBaseName: writeCheck.knowledgeBase.name, connectorType: connectorRows[0].connectorType, connectorStatus: connectorRows[0].status, - syncType: 'manual', + syncType: fullSync ? 'manual-full' : 'manual', }, request, }) - dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { + dispatchSync(connectorId, { billingAttribution, requestId, fullSync }).catch((error) => { logger.error( `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, error diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index b6e01e3c026..effbb071f17 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -1,7 +1,19 @@ 'use client' import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' -import { Badge, Button, Checkbox, ChipConfirmModal, cn, Loader, Tooltip } from '@sim/emcn' +import { + Badge, + Button, + Checkbox, + ChipConfirmModal, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Loader, + Tooltip, +} from '@sim/emcn' import { createLogger } from '@sim/logger' import { format, formatDistanceToNow, isPast } from 'date-fns' import { @@ -115,14 +127,14 @@ export function ConnectorsSection({ }, []) const handleSync = useCallback( - (connectorId: string) => { + (connectorId: string, fullSync = false) => { if (isSyncOnCooldown(connectorId)) return syncTriggeredAt.current[connectorId] = Date.now() addToSet(setSyncingIds, connectorId) triggerSync( - { knowledgeBaseId, connectorId }, + { knowledgeBaseId, connectorId, fullSync }, { onSuccess: () => { setError(null) @@ -214,7 +226,7 @@ export function ConnectorsSection({ isSyncPending={syncingIds.has(connector.id)} isUpdating={updatingIds.has(connector.id)} syncCooldown={isSyncOnCooldown(connector.id)} - onSync={() => handleSync(connector.id)} + onSync={(fullSync) => handleSync(connector.id, fullSync)} onTogglePause={() => handleTogglePause(connector)} onEdit={() => setEditingConnector(connector)} onDelete={() => setDeleteTarget(connector.id)} @@ -273,7 +285,7 @@ interface ConnectorCardProps { isSyncPending: boolean isUpdating: boolean syncCooldown: boolean - onSync: () => void + onSync: (fullSync?: boolean) => void onEdit: () => void onTogglePause: () => void onDelete: () => void @@ -409,28 +421,39 @@ function ConnectorCard({
{canEdit && ( <> - - - - - - {syncCooldown ? 'Sync recently triggered' : 'Sync now'} - - + + + + + + + + + {syncCooldown ? 'Sync recently triggered' : 'Sync'} + + + + onSync(false)}>Sync now + onSync(true)}>Full resync + + diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 4cc5e81f705..454915740b2 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -156,6 +156,14 @@ function cqlResultToStub(item: Record, domain: string): Externa export const confluenceConnector: ConnectorConfig = { ...confluenceConnectorMeta, + /** + * Confluence pages can transclude other pages (Include Page / Excerpt macros). + * Editing an included page changes a container page's rendered `view` without + * bumping the container's version, so its version-based hash can't detect the + * change. A full resync re-hydrates and re-indexes to pick up that drift. + */ + rehydrateOnFullSync: true, + listDocuments: async ( accessToken: string, sourceConfig: Record, diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 2782fc588fa..71ad9ad6926 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -127,6 +127,20 @@ export interface ConnectorMeta { */ supportsIncrementalSync?: boolean + /** + * Whether this connector's extracted content can change without the source item's + * own change-detection hash changing — e.g. a Confluence page that transcludes + * another page via the Include Page / Excerpt macro: editing the included page + * changes the container's rendered `view` output without bumping the container's + * version, so its version-based `contentHash` stays identical. + * + * Incremental syncs remain hash-gated (cheap). On an explicit **full resync** + * (`fullSync`), the engine re-hydrates and re-indexes these connectors' documents + * even when their hash is unchanged, so transcluded/rendered-dependency changes are + * picked up. Only the deliberate full resync pays this re-index cost. + */ + rehydrateOnFullSync?: boolean + /** * Tag definitions this connector populates. Shown in the add-connector modal * as opt-out checkboxes. On connector creation, tag definitions are auto-created diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 5e86d8549f4..44761a3b261 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -204,11 +204,18 @@ export function useDeleteConnector() { interface TriggerSyncParams { knowledgeBaseId: string connectorId: string + /** Force a full resync (re-hydrate + re-index rendered content). Defaults to incremental. */ + fullSync?: boolean } -async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise { +async function triggerSync({ + knowledgeBaseId, + connectorId, + fullSync, +}: TriggerSyncParams): Promise { await requestJson(triggerKnowledgeConnectorSyncContract, { params: { id: knowledgeBaseId, connectorId }, + query: fullSync ? { fullSync: 'true' } : {}, }) } diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index eb776ee9220..e1e5ac7831a 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -150,10 +150,22 @@ export const deleteKnowledgeConnectorContract = defineRouteContract({ }, }) +export const triggerKnowledgeConnectorSyncQuerySchema = z.object({ + /** + * Force a full resync: re-list everything and, for connectors whose rendered + * content can drift without a hash change (e.g. Confluence transclusions), + * re-hydrate and re-index every document rather than only hash-changed ones. + * Omitted performs the normal incremental, hash-gated sync. Modeled as a string + * literal (not a boolean) so it round-trips cleanly through the query string. + */ + fullSync: z.enum(['true', 'false']).optional(), +}) + export const triggerKnowledgeConnectorSyncContract = defineRouteContract({ method: 'POST', path: '/api/knowledge/[id]/connectors/[connectorId]/sync', params: knowledgeConnectorParamsSchema, + query: triggerKnowledgeConnectorSyncQuerySchema, response: { mode: 'json', schema: z.object({ diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 9cf0bcd3dc0..5d30ce45dde 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -227,6 +227,24 @@ describe('classifyExternalDoc', () => { type: 'unchanged', }) }) + + it('forces re-hydration of an unchanged deferred doc when forceRehydrate is set', async () => { + const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine') + const deferred = { ...base, content: '', contentDeferred: true } + // Same hash → normally unchanged, but forceRehydrate promotes it to update. + expect(classifyExternalDoc(deferred, { id: 'doc-1', contentHash: 'h1' }, true)).toEqual({ + type: 'update', + existingId: 'doc-1', + }) + }) + + it('does not force re-hydration of a non-deferred doc (content already final)', async () => { + const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine') + // Ready (non-deferred) content with an unchanged hash stays unchanged even under forceRehydrate. + expect(classifyExternalDoc(base, { id: 'doc-1', contentHash: 'h1' }, true)).toEqual({ + type: 'unchanged', + }) + }) }) describe('chunkOpsByByteBudget', () => { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 0650202f50d..219f62eebfa 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -84,10 +84,17 @@ type DocClassification = * is already indexed is kept as-is (last-known-good) rather than downgraded. * - `drop`: empty, non-deferred content that cannot be indexed. * - `add` / `update` / `unchanged`: normal content reconciliation by content hash. + * + * `forceRehydrate` (set on a full resync of a `rehydrateOnFullSync` connector) promotes + * an otherwise-`unchanged` deferred document to `update` so its content is re-fetched — + * needed when rendered content can drift without the hash changing (e.g. Confluence + * transclusions). Non-deferred docs already carry final content from listing, so they + * are left `unchanged` (re-indexing identical content would be pointless). */ export function classifyExternalDoc( extDoc: Pick, - existing: { id: string; contentHash: string | null } | undefined + existing: { id: string; contentHash: string | null } | undefined, + forceRehydrate = false ): DocClassification { if (extDoc.skippedReason) { return existing ? { type: 'unchanged' } : { type: 'skip' } @@ -101,6 +108,9 @@ export function classifyExternalDoc( if (existing.contentHash !== extDoc.contentHash) { return { type: 'update', existingId: existing.id } } + if (forceRehydrate && extDoc.contentDeferred) { + return { type: 'update', existingId: existing.id } + } return { type: 'unchanged' } } @@ -427,6 +437,13 @@ export async function executeSync( const lastSyncAt = isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined + /** + * On an explicit full resync, re-hydrate and re-index connectors whose rendered + * content can drift without a hash change (transclusions) — see + * `ConnectorMeta.rehydrateOnFullSync`. Incremental syncs stay hash-gated. + */ + const forceRehydrate = Boolean(options?.fullSync && connectorConfig.rehydrateOnFullSync) + for (let pageNum = 0; hasMore && pageNum < MAX_PAGES; pageNum++) { if (pageNum > 0 && connectorConfig.auth.mode === 'oauth') { accessToken = await resolveAccessToken(connector, connectorConfig, userId) @@ -551,7 +568,7 @@ export async function executeSync( } const existing = existingByExternalId.get(extDoc.externalId) - const classification = classifyExternalDoc(extDoc, existing) + const classification = classifyExternalDoc(extDoc, existing, forceRehydrate) switch (classification.type) { case 'skip': @@ -635,8 +652,15 @@ export async function executeSync( return null } const hydratedHash = fullDoc.contentHash ?? op.extDoc.contentHash + /** + * Normally an update whose hydrated hash matches the stored hash is a + * no-op (content unchanged). On a forced re-hydration the hash is + * version-based and cannot reflect the rendered-dependency change we are + * refreshing for, so re-index unconditionally instead of skipping. + */ if ( op.type === 'update' && + !forceRehydrate && existingByExternalId.get(op.extDoc.externalId)?.contentHash === hydratedHash ) { result.docsUnchanged++ From fe48d7419df69bf83b27bd7302aea598c7945014 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 14:49:58 -0700 Subject: [PATCH 4/7] refactor(connectors): decouple rehydrate from fullSync deletion semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transclusion refresh only needs re-hydration, but reusing the fullSync flag also activated its deletion-cleanup semantics — which bypass three previously unreachable safety guards (empty-listing wipe, listingCapped, and the >50% mass-deletion threshold). Since fullSync had no caller before this PR, the new 'Full resync' button would have exposed all three to any KB editor. Introduce a dedicated 'rehydrate' request that ONLY forces re-hydration + re-index of already-synced docs. Listing and deletion reconciliation are identical to a normal sync (all safety guards stay armed). fullSync's cleanup semantics remain dormant and untouched. --- .../[connectorId]/sync/route.test.ts | 8 ++++---- .../connectors/[connectorId]/sync/route.ts | 8 ++++---- .../connectors-section/connectors-section.tsx | 8 ++++---- apps/sim/hooks/queries/kb/connectors.ts | 8 ++++---- .../lib/api/contracts/knowledge/connectors.ts | 13 +++++++------ apps/sim/lib/knowledge/connectors/queue.ts | 14 ++++++++++++++ .../lib/knowledge/connectors/sync-engine.ts | 19 ++++++++++++++----- 7 files changed, 51 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index 0276b138211..e84f5cd4da6 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -138,11 +138,11 @@ describe('Connector Manual Sync API Route', () => { expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { billingAttribution, requestId: 'test-req-id', - fullSync: false, + rehydrate: false, }) }) - it('dispatches a full resync when fullSync=true is set', async () => { + it('dispatches a full resync when rehydrate=true is set', async () => { const billingAttribution = { actorUserId: 'external-admin', workspaceId: 'ws-1', @@ -173,7 +173,7 @@ describe('Connector Manual Sync API Route', () => { 'POST', undefined, {}, - 'http://localhost:3000/api/knowledge/kb-123/connectors/conn-456/sync?fullSync=true' + 'http://localhost:3000/api/knowledge/kb-123/connectors/conn-456/sync?rehydrate=true' ) const response = await POST(req as never, { params: mockParams }) @@ -181,7 +181,7 @@ describe('Connector Manual Sync API Route', () => { expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { billingAttribution, requestId: 'test-req-id', - fullSync: true, + rehydrate: true, }) }) }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 355128402be..a983e61e6d7 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -29,7 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context) if (!parsed.success) return parsed.response const { id: knowledgeBaseId, connectorId } = parsed.data.params - const fullSync = parsed.data.query?.fullSync === 'true' + const rehydrate = parsed.data.query?.rehydrate === 'true' try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -83,7 +83,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route }) logger.info( - `[${requestId}] Manual ${fullSync ? 'full ' : ''}sync triggered for connector ${connectorId}` + `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` ) captureServerEvent( @@ -112,12 +112,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route knowledgeBaseName: writeCheck.knowledgeBase.name, connectorType: connectorRows[0].connectorType, connectorStatus: connectorRows[0].status, - syncType: fullSync ? 'manual-full' : 'manual', + syncType: rehydrate ? 'manual-rehydrate' : 'manual', }, request, }) - dispatchSync(connectorId, { billingAttribution, requestId, fullSync }).catch((error) => { + dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { logger.error( `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, error diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index effbb071f17..6be5636dd0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -127,14 +127,14 @@ export function ConnectorsSection({ }, []) const handleSync = useCallback( - (connectorId: string, fullSync = false) => { + (connectorId: string, rehydrate = false) => { if (isSyncOnCooldown(connectorId)) return syncTriggeredAt.current[connectorId] = Date.now() addToSet(setSyncingIds, connectorId) triggerSync( - { knowledgeBaseId, connectorId, fullSync }, + { knowledgeBaseId, connectorId, rehydrate }, { onSuccess: () => { setError(null) @@ -226,7 +226,7 @@ export function ConnectorsSection({ isSyncPending={syncingIds.has(connector.id)} isUpdating={updatingIds.has(connector.id)} syncCooldown={isSyncOnCooldown(connector.id)} - onSync={(fullSync) => handleSync(connector.id, fullSync)} + onSync={(rehydrate) => handleSync(connector.id, rehydrate)} onTogglePause={() => handleTogglePause(connector)} onEdit={() => setEditingConnector(connector)} onDelete={() => setDeleteTarget(connector.id)} @@ -285,7 +285,7 @@ interface ConnectorCardProps { isSyncPending: boolean isUpdating: boolean syncCooldown: boolean - onSync: (fullSync?: boolean) => void + onSync: (rehydrate?: boolean) => void onEdit: () => void onTogglePause: () => void onDelete: () => void diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 44761a3b261..0269cd12b8a 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -204,18 +204,18 @@ export function useDeleteConnector() { interface TriggerSyncParams { knowledgeBaseId: string connectorId: string - /** Force a full resync (re-hydrate + re-index rendered content). Defaults to incremental. */ - fullSync?: boolean + /** Force re-hydration + re-index of rendered content (the "Full resync" action). */ + rehydrate?: boolean } async function triggerSync({ knowledgeBaseId, connectorId, - fullSync, + rehydrate, }: TriggerSyncParams): Promise { await requestJson(triggerKnowledgeConnectorSyncContract, { params: { id: knowledgeBaseId, connectorId }, - query: fullSync ? { fullSync: 'true' } : {}, + query: rehydrate ? { rehydrate: 'true' } : {}, }) } diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index e1e5ac7831a..c2f09e0c377 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -152,13 +152,14 @@ export const deleteKnowledgeConnectorContract = defineRouteContract({ export const triggerKnowledgeConnectorSyncQuerySchema = z.object({ /** - * Force a full resync: re-list everything and, for connectors whose rendered - * content can drift without a hash change (e.g. Confluence transclusions), - * re-hydrate and re-index every document rather than only hash-changed ones. - * Omitted performs the normal incremental, hash-gated sync. Modeled as a string - * literal (not a boolean) so it round-trips cleanly through the query string. + * Force re-hydration: for connectors whose rendered content can drift without a + * hash change (e.g. Confluence transclusions), re-fetch and re-index every + * already-synced document rather than only hash-changed ones. Listing and + * deletion reconciliation are unchanged from a normal sync. Omitted performs the + * normal hash-gated sync. Modeled as a string literal (not a boolean) so it + * round-trips cleanly through the query string. */ - fullSync: z.enum(['true', 'false']).optional(), + rehydrate: z.enum(['true', 'false']).optional(), }) export const triggerKnowledgeConnectorSyncContract = defineRouteContract({ diff --git a/apps/sim/lib/knowledge/connectors/queue.ts b/apps/sim/lib/knowledge/connectors/queue.ts index a9414c7a737..1a4b3832171 100644 --- a/apps/sim/lib/knowledge/connectors/queue.ts +++ b/apps/sim/lib/knowledge/connectors/queue.ts @@ -19,6 +19,13 @@ const logger = createLogger('ConnectorSyncQueue') export interface ConnectorSyncPayload { connectorId: string fullSync?: boolean + /** + * Force re-hydration + re-indexing of already-synced documents for connectors + * whose rendered content can drift without a hash change (see + * `ConnectorMeta.rehydrateOnFullSync`). Unlike `fullSync`, this does NOT alter + * listing or bypass any deletion-reconciliation safety guard. + */ + rehydrate?: boolean requestId: string billingAttribution: BillingAttributionSnapshot } @@ -26,6 +33,7 @@ export interface ConnectorSyncPayload { export interface DispatchSyncOptions { billingAttribution: BillingAttributionSnapshot fullSync?: boolean + rehydrate?: boolean requestId?: string } @@ -46,6 +54,9 @@ export function assertConnectorSyncPayload(value: unknown): ConnectorSyncPayload if (value.fullSync !== undefined && typeof value.fullSync !== 'boolean') { throw new Error('Connector sync payload fullSync must be a boolean when provided') } + if (value.rehydrate !== undefined && typeof value.rehydrate !== 'boolean') { + throw new Error('Connector sync payload rehydrate must be a boolean when provided') + } if (value.billingAttribution === undefined) { throw new Error('Connector sync payload requires billing attribution') } @@ -53,6 +64,7 @@ export function assertConnectorSyncPayload(value: unknown): ConnectorSyncPayload return { connectorId: value.connectorId, fullSync: value.fullSync as boolean | undefined, + rehydrate: value.rehydrate as boolean | undefined, requestId: value.requestId, billingAttribution: assertBillingAttributionSnapshot(value.billingAttribution), } @@ -74,6 +86,7 @@ export async function dispatchSync( const payload = assertConnectorSyncPayload({ connectorId, fullSync: options?.fullSync, + rehydrate: options?.rehydrate, requestId, billingAttribution: options?.billingAttribution, }) @@ -147,6 +160,7 @@ export async function dispatchSync( executeSync(connectorId, { fullSync: payload.fullSync, + rehydrate: payload.rehydrate, billingAttribution: payload.billingAttribution, }).catch((error) => { logger.error(`Sync failed for connector ${connectorId}`, { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 219f62eebfa..ee47b0d967c 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -318,7 +318,11 @@ async function resolveAccessToken( */ export async function executeSync( connectorId: string, - options: { billingAttribution: BillingAttributionSnapshot; fullSync?: boolean } + options: { + billingAttribution: BillingAttributionSnapshot + fullSync?: boolean + rehydrate?: boolean + } ): Promise { const billingAttribution = assertBillingAttributionSnapshot(options?.billingAttribution) const result: SyncResult = { @@ -438,11 +442,16 @@ export async function executeSync( isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined /** - * On an explicit full resync, re-hydrate and re-index connectors whose rendered - * content can drift without a hash change (transclusions) — see - * `ConnectorMeta.rehydrateOnFullSync`. Incremental syncs stay hash-gated. + * Re-hydrate and re-index connectors whose rendered content can drift without a + * hash change (transclusions) — see `ConnectorMeta.rehydrateOnFullSync`. Driven + * by the dedicated `rehydrate` request (the "Full resync" action) or implied by a + * true `fullSync`. This ONLY affects hydration/indexing — it does not change + * listing or bypass any deletion-reconciliation guard. Incremental syncs of other + * connectors stay hash-gated. */ - const forceRehydrate = Boolean(options?.fullSync && connectorConfig.rehydrateOnFullSync) + const forceRehydrate = Boolean( + (options?.rehydrate || options?.fullSync) && connectorConfig.rehydrateOnFullSync + ) for (let pageNum = 0; hasMore && pageNum < MAX_PAGES; pageNum++) { if (pageNum > 0 && connectorConfig.auth.mode === 'oauth') { From 60d2b80b3e68e9dc560732bf5e7fff62d95612f5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 15:04:26 -0700 Subject: [PATCH 5/7] refactor(connectors): tidy rehydrate flag + gate Full resync to supported connectors Cleanup/simplify pass over the connector changes: - use shared booleanQueryFlagSchema for the rehydrate query param (typed boolean at the boundary instead of a hand-rolled 'true'/'false' string enum) - move rehydrateOnFullSync onto the client-safe ConnectorMeta so the UI can gate on it - only Confluence (rehydrateOnFullSync) shows the Sync now / Full resync dropdown; every other connector keeps its original one-click sync button (Full resync is a no-op for them, and this restores the pre-change one-click UX) - wrap the sync trigger in a span so its tooltip still shows while disabled (cooldown) --- .../connectors/[connectorId]/sync/route.ts | 2 +- .../connectors-section/connectors-section.tsx | 65 ++++++++++++++----- apps/sim/connectors/confluence/confluence.ts | 8 --- apps/sim/connectors/confluence/meta.ts | 9 +++ apps/sim/hooks/queries/kb/connectors.ts | 2 +- .../lib/api/contracts/knowledge/connectors.ts | 8 +-- 6 files changed, 62 insertions(+), 32 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index a983e61e6d7..714f554040b 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -29,7 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context) if (!parsed.success) return parsed.response const { id: knowledgeBaseId, connectorId } = parsed.data.params - const rehydrate = parsed.data.query?.rehydrate === 'true' + const { rehydrate } = parsed.data.query try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 6be5636dd0c..b7e7b4b6145 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -339,6 +339,14 @@ function ConnectorCard({ ) const syncLogs = detail?.syncLogs ?? [] + const canFullResync = Boolean(connectorDef?.rehydrateOnFullSync) + const syncDisabled = + connector.status === 'syncing' || + connector.status === 'disabled' || + isSyncPending || + syncCooldown + const syncTooltip = syncCooldown ? 'Sync recently triggered' : canFullResync ? 'Sync' : 'Sync now' + return (
{canEdit && ( <> - + {canFullResync ? ( + + + + {/* span keeps the tooltip hoverable while the trigger button is disabled */} + + + + + + + {syncTooltip} + + + onSync(false)}>Sync now + onSync(true)}>Full resync + + + ) : ( - + {/* span keeps the tooltip hoverable while the button is disabled */} + - + - - {syncCooldown ? 'Sync recently triggered' : 'Sync'} - + {syncTooltip} - - onSync(false)}>Sync now - onSync(true)}>Full resync - - + )} diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 454915740b2..4cc5e81f705 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -156,14 +156,6 @@ function cqlResultToStub(item: Record, domain: string): Externa export const confluenceConnector: ConnectorConfig = { ...confluenceConnectorMeta, - /** - * Confluence pages can transclude other pages (Include Page / Excerpt macros). - * Editing an included page changes a container page's rendered `view` without - * bumping the container's version, so its version-based hash can't detect the - * change. A full resync re-hydrates and re-indexes to pick up that drift. - */ - rehydrateOnFullSync: true, - listDocuments: async ( accessToken: string, sourceConfig: Record, diff --git a/apps/sim/connectors/confluence/meta.ts b/apps/sim/connectors/confluence/meta.ts index fce21b83225..83c3aa98bee 100644 --- a/apps/sim/connectors/confluence/meta.ts +++ b/apps/sim/connectors/confluence/meta.ts @@ -22,6 +22,15 @@ export const confluenceConnectorMeta: ConnectorMeta = { ], }, + /** + * Confluence pages can transclude other pages (Include Page / Excerpt macros). + * Editing an included page changes a container page's rendered `view` without + * bumping the container's version, so its version-based hash can't detect the + * change. A full resync re-hydrates and re-indexes to pick up that drift. This + * lives on the meta so the client can offer "Full resync" only where it applies. + */ + rehydrateOnFullSync: true, + configFields: [ { id: 'domain', diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 0269cd12b8a..a1291918bd0 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -215,7 +215,7 @@ async function triggerSync({ }: TriggerSyncParams): Promise { await requestJson(triggerKnowledgeConnectorSyncContract, { params: { id: knowledgeBaseId, connectorId }, - query: rehydrate ? { rehydrate: 'true' } : {}, + query: rehydrate ? { rehydrate: true } : {}, }) } diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index c2f09e0c377..6db063d47d1 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -4,6 +4,7 @@ import { knowledgeConnectorParamsSchema, successResponseSchema, } from '@/lib/api/contracts/knowledge/shared' +import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' export const createConnectorBodySchema = z.object({ @@ -155,11 +156,10 @@ export const triggerKnowledgeConnectorSyncQuerySchema = z.object({ * Force re-hydration: for connectors whose rendered content can drift without a * hash change (e.g. Confluence transclusions), re-fetch and re-index every * already-synced document rather than only hash-changed ones. Listing and - * deletion reconciliation are unchanged from a normal sync. Omitted performs the - * normal hash-gated sync. Modeled as a string literal (not a boolean) so it - * round-trips cleanly through the query string. + * deletion reconciliation are unchanged from a normal sync. Defaults to the + * normal hash-gated sync. */ - rehydrate: z.enum(['true', 'false']).optional(), + rehydrate: booleanQueryFlagSchema.optional().default(false), }) export const triggerKnowledgeConnectorSyncContract = defineRouteContract({ From 6e0595f1dbb4b49dc4b029327a7b1fcebeb23fc6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 15:07:23 -0700 Subject: [PATCH 6/7] fix(connectors): forward rehydrate flag through the Trigger.dev worker executeConnectorSyncJob (the production async sync path) destructured only fullSync from the payload and forwarded only fullSync to executeSync, silently dropping rehydrate. A manual Full resync would therefore never re-hydrate on the default Trigger.dev path. Forward rehydrate too. --- .../knowledge-connector-sync.test.ts | 23 +++++++++++++++++++ .../background/knowledge-connector-sync.ts | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/sim/background/knowledge-connector-sync.test.ts b/apps/sim/background/knowledge-connector-sync.test.ts index 61f2156dddb..39a2d8f4ca6 100644 --- a/apps/sim/background/knowledge-connector-sync.test.ts +++ b/apps/sim/background/knowledge-connector-sync.test.ts @@ -72,6 +72,29 @@ describe('knowledge connector sync worker', () => { expect(mockExecuteSync).toHaveBeenCalledWith('connector-1', { billingAttribution: BILLING_ATTRIBUTION, fullSync: true, + rehydrate: undefined, + }) + }) + + it('forwards the rehydrate flag to the sync engine (async worker path)', async () => { + mockAssertConnectorSyncPayload.mockReturnValue({ + connectorId: 'connector-1', + requestId: 'request-1', + rehydrate: true, + billingAttribution: BILLING_ATTRIBUTION, + }) + + await executeConnectorSyncJob({ + connectorId: 'connector-1', + requestId: 'request-1', + rehydrate: true, + billingAttribution: BILLING_ATTRIBUTION, + }) + + expect(mockExecuteSync).toHaveBeenCalledWith('connector-1', { + billingAttribution: BILLING_ATTRIBUTION, + fullSync: undefined, + rehydrate: true, }) }) }) diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts index dd29cf11108..f92c440a146 100644 --- a/apps/sim/background/knowledge-connector-sync.ts +++ b/apps/sim/background/knowledge-connector-sync.ts @@ -9,13 +9,13 @@ import { executeSync } from '@/lib/knowledge/connectors/sync-engine' const logger = createLogger('TriggerKnowledgeConnectorSync') export async function executeConnectorSyncJob(payload: unknown) { - const { connectorId, fullSync, requestId, billingAttribution } = + const { connectorId, fullSync, rehydrate, requestId, billingAttribution } = assertConnectorSyncPayload(payload) logger.info(`[${requestId}] Starting connector sync: ${connectorId}`) try { - const result = await executeSync(connectorId, { billingAttribution, fullSync }) + const result = await executeSync(connectorId, { billingAttribution, fullSync, rehydrate }) logger.info(`[${requestId}] Connector sync completed`, { connectorId, From 92f9f0968c11e502642e5c2b4b0b71181818fed0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 15:15:33 -0700 Subject: [PATCH 7/7] fix(connectors): rehydrate forces a full listing so containers aren't omitted A rehydrate request set forceRehydrate but left listing incremental. For a connector that is both incremental and rehydrateOnFullSync, an unchanged container page that transcludes a changed page would be omitted from the incremental listing and never re-hydrated. Force a full (non-incremental) listing on rehydrate so every document is seen; deletion-safety guards stay armed (unlike fullSync). No-op for Confluence, which is already non-incremental. --- .../sim/lib/api/contracts/knowledge/connectors.ts | 8 ++++---- apps/sim/lib/knowledge/connectors/queue.test.ts | 15 +++++++++++++++ apps/sim/lib/knowledge/connectors/queue.ts | 5 +++-- apps/sim/lib/knowledge/connectors/sync-engine.ts | 15 +++++++++++---- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index 6db063d47d1..ed147c741a1 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -154,10 +154,10 @@ export const deleteKnowledgeConnectorContract = defineRouteContract({ export const triggerKnowledgeConnectorSyncQuerySchema = z.object({ /** * Force re-hydration: for connectors whose rendered content can drift without a - * hash change (e.g. Confluence transclusions), re-fetch and re-index every - * already-synced document rather than only hash-changed ones. Listing and - * deletion reconciliation are unchanged from a normal sync. Defaults to the - * normal hash-gated sync. + * hash change (e.g. Confluence transclusions), do a full listing and re-fetch + + * re-index every already-synced document rather than only hash-changed ones. The + * deletion-reconciliation safety guards stay armed. Defaults to the normal + * hash-gated sync. */ rehydrate: booleanQueryFlagSchema.optional().default(false), }) diff --git a/apps/sim/lib/knowledge/connectors/queue.test.ts b/apps/sim/lib/knowledge/connectors/queue.test.ts index 3c7d58bcf82..070ed03ea55 100644 --- a/apps/sim/lib/knowledge/connectors/queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/queue.test.ts @@ -98,6 +98,7 @@ describe('connector sync queue', () => { { connectorId: 'connector-1', fullSync: true, + rehydrate: undefined, requestId: 'request-1', billingAttribution: BILLING_ATTRIBUTION, }, @@ -113,6 +114,20 @@ describe('connector sync queue', () => { ) }) + it('carries the rehydrate flag into the queued payload', async () => { + await dispatchSync('connector-1', { + billingAttribution: BILLING_ATTRIBUTION, + rehydrate: true, + requestId: 'request-1', + }) + + expect(mockTrigger).toHaveBeenCalledWith( + 'knowledge-connector-sync', + expect.objectContaining({ connectorId: 'connector-1', rehydrate: true }), + expect.anything() + ) + }) + 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 1a4b3832171..3bd73456ad5 100644 --- a/apps/sim/lib/knowledge/connectors/queue.ts +++ b/apps/sim/lib/knowledge/connectors/queue.ts @@ -22,8 +22,9 @@ export interface ConnectorSyncPayload { /** * Force re-hydration + re-indexing of already-synced documents for connectors * whose rendered content can drift without a hash change (see - * `ConnectorMeta.rehydrateOnFullSync`). Unlike `fullSync`, this does NOT alter - * listing or bypass any deletion-reconciliation safety guard. + * `ConnectorMeta.rehydrateOnFullSync`). Forces a full (non-incremental) listing + * so every document is re-hydrated, but — unlike `fullSync` — keeps every + * deletion-reconciliation safety guard armed. */ rehydrate?: boolean requestId: string diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index ee47b0d967c..a4e07e20f9b 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -432,11 +432,17 @@ export async function executeSync( let hasMore = true const syncContext: Record = { syncRunId: generateId() } - // Determine if this sync should be incremental + /** + * Determine if this sync should be incremental. A `rehydrate` request forces a + * full listing too: re-hydration must see *every* document (a container page can + * be unchanged itself yet transclude a page that changed), and an incremental + * listing would omit those unchanged containers, so they'd never be re-fetched. + */ const isIncremental = connectorConfig.supportsIncrementalSync && connector.syncMode !== 'full' && !options?.fullSync && + !options?.rehydrate && connector.lastSyncAt != null const lastSyncAt = isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined @@ -445,9 +451,10 @@ export async function executeSync( * Re-hydrate and re-index connectors whose rendered content can drift without a * hash change (transclusions) — see `ConnectorMeta.rehydrateOnFullSync`. Driven * by the dedicated `rehydrate` request (the "Full resync" action) or implied by a - * true `fullSync`. This ONLY affects hydration/indexing — it does not change - * listing or bypass any deletion-reconciliation guard. Incremental syncs of other - * connectors stay hash-gated. + * true `fullSync`. It forces a full listing (above) and re-indexes unchanged + * deferred docs, but — unlike `fullSync` — it does NOT bypass any + * deletion-reconciliation safety guard. Incremental syncs of other connectors + * stay hash-gated. */ const forceRehydrate = Boolean( (options?.rehydrate || options?.fullSync) && connectorConfig.rehydrateOnFullSync