Skip to content

Commit 5789a6a

Browse files
committed
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.
1 parent 0980add commit 5789a6a

9 files changed

Lines changed: 186 additions & 33 deletions

File tree

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,50 @@ describe('Connector Manual Sync API Route', () => {
138138
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', {
139139
billingAttribution,
140140
requestId: 'test-req-id',
141+
fullSync: false,
142+
})
143+
})
144+
145+
it('dispatches a full resync when fullSync=true is set', async () => {
146+
const billingAttribution = {
147+
actorUserId: 'external-admin',
148+
workspaceId: 'ws-1',
149+
organizationId: null,
150+
billedAccountUserId: 'owner-1',
151+
billingEntity: { type: 'user' as const, id: 'owner-1' },
152+
billingPeriod: {
153+
start: '2026-07-01T00:00:00.000Z',
154+
end: '2026-08-01T00:00:00.000Z',
155+
},
156+
payerSubscription: null,
157+
}
158+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
159+
success: true,
160+
authType: 'session',
161+
userId: 'external-admin',
162+
userName: 'Test',
163+
userEmail: 'test@test.com',
164+
})
165+
mockCheckWriteAccess.mockResolvedValue({
166+
hasAccess: true,
167+
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
168+
})
169+
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
170+
mockResolveBillingAttribution.mockResolvedValue(billingAttribution)
171+
172+
const req = createMockRequest(
173+
'POST',
174+
undefined,
175+
{},
176+
'http://localhost:3000/api/knowledge/kb-123/connectors/conn-456/sync?fullSync=true'
177+
)
178+
const response = await POST(req as never, { params: mockParams })
179+
180+
expect(response.status).toBe(200)
181+
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', {
182+
billingAttribution,
183+
requestId: 'test-req-id',
184+
fullSync: true,
141185
})
142186
})
143187
})

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
2929
const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context)
3030
if (!parsed.success) return parsed.response
3131
const { id: knowledgeBaseId, connectorId } = parsed.data.params
32+
const fullSync = parsed.data.query?.fullSync === 'true'
3233

3334
try {
3435
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -81,7 +82,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
8182
workspaceId: kbWorkspaceId,
8283
})
8384

84-
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
85+
logger.info(
86+
`[${requestId}] Manual ${fullSync ? 'full ' : ''}sync triggered for connector ${connectorId}`
87+
)
8588

8689
captureServerEvent(
8790
auth.userId,
@@ -109,12 +112,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
109112
knowledgeBaseName: writeCheck.knowledgeBase.name,
110113
connectorType: connectorRows[0].connectorType,
111114
connectorStatus: connectorRows[0].status,
112-
syncType: 'manual',
115+
syncType: fullSync ? 'manual-full' : 'manual',
113116
},
114117
request,
115118
})
116119

117-
dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => {
120+
dispatchSync(connectorId, { billingAttribution, requestId, fullSync }).catch((error) => {
118121
logger.error(
119122
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
120123
error

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
'use client'
22

33
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
4-
import { Badge, Button, Checkbox, ChipConfirmModal, cn, Loader, Tooltip } from '@sim/emcn'
4+
import {
5+
Badge,
6+
Button,
7+
Checkbox,
8+
ChipConfirmModal,
9+
cn,
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
Loader,
15+
Tooltip,
16+
} from '@sim/emcn'
517
import { createLogger } from '@sim/logger'
618
import { format, formatDistanceToNow, isPast } from 'date-fns'
719
import {
@@ -115,14 +127,14 @@ export function ConnectorsSection({
115127
}, [])
116128

117129
const handleSync = useCallback(
118-
(connectorId: string) => {
130+
(connectorId: string, fullSync = false) => {
119131
if (isSyncOnCooldown(connectorId)) return
120132

121133
syncTriggeredAt.current[connectorId] = Date.now()
122134
addToSet(setSyncingIds, connectorId)
123135

124136
triggerSync(
125-
{ knowledgeBaseId, connectorId },
137+
{ knowledgeBaseId, connectorId, fullSync },
126138
{
127139
onSuccess: () => {
128140
setError(null)
@@ -214,7 +226,7 @@ export function ConnectorsSection({
214226
isSyncPending={syncingIds.has(connector.id)}
215227
isUpdating={updatingIds.has(connector.id)}
216228
syncCooldown={isSyncOnCooldown(connector.id)}
217-
onSync={() => handleSync(connector.id)}
229+
onSync={(fullSync) => handleSync(connector.id, fullSync)}
218230
onTogglePause={() => handleTogglePause(connector)}
219231
onEdit={() => setEditingConnector(connector)}
220232
onDelete={() => setDeleteTarget(connector.id)}
@@ -273,7 +285,7 @@ interface ConnectorCardProps {
273285
isSyncPending: boolean
274286
isUpdating: boolean
275287
syncCooldown: boolean
276-
onSync: () => void
288+
onSync: (fullSync?: boolean) => void
277289
onEdit: () => void
278290
onTogglePause: () => void
279291
onDelete: () => void
@@ -409,28 +421,39 @@ function ConnectorCard({
409421
<div className='flex flex-shrink-0 items-center gap-0.5'>
410422
{canEdit && (
411423
<>
412-
<Tooltip.Root>
413-
<Tooltip.Trigger asChild>
414-
<Button
415-
variant='ghost'
416-
className={CONNECTOR_ACTION_BUTTON_CLASSES}
417-
onClick={onSync}
418-
disabled={
419-
connector.status === 'syncing' ||
420-
connector.status === 'disabled' ||
421-
isSyncPending ||
422-
syncCooldown
423-
}
424-
>
425-
<RefreshCw
426-
className={cn('size-3.5', connector.status === 'syncing' && 'animate-spin')}
427-
/>
428-
</Button>
429-
</Tooltip.Trigger>
430-
<Tooltip.Content>
431-
{syncCooldown ? 'Sync recently triggered' : 'Sync now'}
432-
</Tooltip.Content>
433-
</Tooltip.Root>
424+
<DropdownMenu>
425+
<Tooltip.Root>
426+
<Tooltip.Trigger asChild>
427+
<DropdownMenuTrigger asChild>
428+
<Button
429+
variant='ghost'
430+
aria-label='Sync options'
431+
className={CONNECTOR_ACTION_BUTTON_CLASSES}
432+
disabled={
433+
connector.status === 'syncing' ||
434+
connector.status === 'disabled' ||
435+
isSyncPending ||
436+
syncCooldown
437+
}
438+
>
439+
<RefreshCw
440+
className={cn(
441+
'size-3.5',
442+
connector.status === 'syncing' && 'animate-spin'
443+
)}
444+
/>
445+
</Button>
446+
</DropdownMenuTrigger>
447+
</Tooltip.Trigger>
448+
<Tooltip.Content>
449+
{syncCooldown ? 'Sync recently triggered' : 'Sync'}
450+
</Tooltip.Content>
451+
</Tooltip.Root>
452+
<DropdownMenuContent align='end'>
453+
<DropdownMenuItem onSelect={() => onSync(false)}>Sync now</DropdownMenuItem>
454+
<DropdownMenuItem onSelect={() => onSync(true)}>Full resync</DropdownMenuItem>
455+
</DropdownMenuContent>
456+
</DropdownMenu>
434457

435458
<Tooltip.Root>
436459
<Tooltip.Trigger asChild>

apps/sim/connectors/confluence/confluence.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,14 @@ function cqlResultToStub(item: Record<string, unknown>, domain: string): Externa
156156
export const confluenceConnector: ConnectorConfig = {
157157
...confluenceConnectorMeta,
158158

159+
/**
160+
* Confluence pages can transclude other pages (Include Page / Excerpt macros).
161+
* Editing an included page changes a container page's rendered `view` without
162+
* bumping the container's version, so its version-based hash can't detect the
163+
* change. A full resync re-hydrates and re-indexes to pick up that drift.
164+
*/
165+
rehydrateOnFullSync: true,
166+
159167
listDocuments: async (
160168
accessToken: string,
161169
sourceConfig: Record<string, unknown>,

apps/sim/connectors/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ export interface ConnectorMeta {
127127
*/
128128
supportsIncrementalSync?: boolean
129129

130+
/**
131+
* Whether this connector's extracted content can change without the source item's
132+
* own change-detection hash changing — e.g. a Confluence page that transcludes
133+
* another page via the Include Page / Excerpt macro: editing the included page
134+
* changes the container's rendered `view` output without bumping the container's
135+
* version, so its version-based `contentHash` stays identical.
136+
*
137+
* Incremental syncs remain hash-gated (cheap). On an explicit **full resync**
138+
* (`fullSync`), the engine re-hydrates and re-indexes these connectors' documents
139+
* even when their hash is unchanged, so transcluded/rendered-dependency changes are
140+
* picked up. Only the deliberate full resync pays this re-index cost.
141+
*/
142+
rehydrateOnFullSync?: boolean
143+
130144
/**
131145
* Tag definitions this connector populates. Shown in the add-connector modal
132146
* as opt-out checkboxes. On connector creation, tag definitions are auto-created

apps/sim/hooks/queries/kb/connectors.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,18 @@ export function useDeleteConnector() {
204204
interface TriggerSyncParams {
205205
knowledgeBaseId: string
206206
connectorId: string
207+
/** Force a full resync (re-hydrate + re-index rendered content). Defaults to incremental. */
208+
fullSync?: boolean
207209
}
208210

209-
async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise<void> {
211+
async function triggerSync({
212+
knowledgeBaseId,
213+
connectorId,
214+
fullSync,
215+
}: TriggerSyncParams): Promise<void> {
210216
await requestJson(triggerKnowledgeConnectorSyncContract, {
211217
params: { id: knowledgeBaseId, connectorId },
218+
query: fullSync ? { fullSync: 'true' } : {},
212219
})
213220
}
214221

apps/sim/lib/api/contracts/knowledge/connectors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,22 @@ export const deleteKnowledgeConnectorContract = defineRouteContract({
150150
},
151151
})
152152

153+
export const triggerKnowledgeConnectorSyncQuerySchema = z.object({
154+
/**
155+
* Force a full resync: re-list everything and, for connectors whose rendered
156+
* content can drift without a hash change (e.g. Confluence transclusions),
157+
* re-hydrate and re-index every document rather than only hash-changed ones.
158+
* Omitted performs the normal incremental, hash-gated sync. Modeled as a string
159+
* literal (not a boolean) so it round-trips cleanly through the query string.
160+
*/
161+
fullSync: z.enum(['true', 'false']).optional(),
162+
})
163+
153164
export const triggerKnowledgeConnectorSyncContract = defineRouteContract({
154165
method: 'POST',
155166
path: '/api/knowledge/[id]/connectors/[connectorId]/sync',
156167
params: knowledgeConnectorParamsSchema,
168+
query: triggerKnowledgeConnectorSyncQuerySchema,
157169
response: {
158170
mode: 'json',
159171
schema: z.object({

apps/sim/lib/knowledge/connectors/sync-engine.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,24 @@ describe('classifyExternalDoc', () => {
227227
type: 'unchanged',
228228
})
229229
})
230+
231+
it('forces re-hydration of an unchanged deferred doc when forceRehydrate is set', async () => {
232+
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
233+
const deferred = { ...base, content: '', contentDeferred: true }
234+
// Same hash → normally unchanged, but forceRehydrate promotes it to update.
235+
expect(classifyExternalDoc(deferred, { id: 'doc-1', contentHash: 'h1' }, true)).toEqual({
236+
type: 'update',
237+
existingId: 'doc-1',
238+
})
239+
})
240+
241+
it('does not force re-hydration of a non-deferred doc (content already final)', async () => {
242+
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
243+
// Ready (non-deferred) content with an unchanged hash stays unchanged even under forceRehydrate.
244+
expect(classifyExternalDoc(base, { id: 'doc-1', contentHash: 'h1' }, true)).toEqual({
245+
type: 'unchanged',
246+
})
247+
})
230248
})
231249

232250
describe('chunkOpsByByteBudget', () => {

apps/sim/lib/knowledge/connectors/sync-engine.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,17 @@ type DocClassification =
8484
* is already indexed is kept as-is (last-known-good) rather than downgraded.
8585
* - `drop`: empty, non-deferred content that cannot be indexed.
8686
* - `add` / `update` / `unchanged`: normal content reconciliation by content hash.
87+
*
88+
* `forceRehydrate` (set on a full resync of a `rehydrateOnFullSync` connector) promotes
89+
* an otherwise-`unchanged` deferred document to `update` so its content is re-fetched —
90+
* needed when rendered content can drift without the hash changing (e.g. Confluence
91+
* transclusions). Non-deferred docs already carry final content from listing, so they
92+
* are left `unchanged` (re-indexing identical content would be pointless).
8793
*/
8894
export function classifyExternalDoc(
8995
extDoc: Pick<ExternalDocument, 'content' | 'contentDeferred' | 'contentHash' | 'skippedReason'>,
90-
existing: { id: string; contentHash: string | null } | undefined
96+
existing: { id: string; contentHash: string | null } | undefined,
97+
forceRehydrate = false
9198
): DocClassification {
9299
if (extDoc.skippedReason) {
93100
return existing ? { type: 'unchanged' } : { type: 'skip' }
@@ -101,6 +108,9 @@ export function classifyExternalDoc(
101108
if (existing.contentHash !== extDoc.contentHash) {
102109
return { type: 'update', existingId: existing.id }
103110
}
111+
if (forceRehydrate && extDoc.contentDeferred) {
112+
return { type: 'update', existingId: existing.id }
113+
}
104114
return { type: 'unchanged' }
105115
}
106116

@@ -427,6 +437,13 @@ export async function executeSync(
427437
const lastSyncAt =
428438
isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined
429439

440+
/**
441+
* On an explicit full resync, re-hydrate and re-index connectors whose rendered
442+
* content can drift without a hash change (transclusions) — see
443+
* `ConnectorMeta.rehydrateOnFullSync`. Incremental syncs stay hash-gated.
444+
*/
445+
const forceRehydrate = Boolean(options?.fullSync && connectorConfig.rehydrateOnFullSync)
446+
430447
for (let pageNum = 0; hasMore && pageNum < MAX_PAGES; pageNum++) {
431448
if (pageNum > 0 && connectorConfig.auth.mode === 'oauth') {
432449
accessToken = await resolveAccessToken(connector, connectorConfig, userId)
@@ -551,7 +568,7 @@ export async function executeSync(
551568
}
552569

553570
const existing = existingByExternalId.get(extDoc.externalId)
554-
const classification = classifyExternalDoc(extDoc, existing)
571+
const classification = classifyExternalDoc(extDoc, existing, forceRehydrate)
555572

556573
switch (classification.type) {
557574
case 'skip':
@@ -635,8 +652,15 @@ export async function executeSync(
635652
return null
636653
}
637654
const hydratedHash = fullDoc.contentHash ?? op.extDoc.contentHash
655+
/**
656+
* Normally an update whose hydrated hash matches the stored hash is a
657+
* no-op (content unchanged). On a forced re-hydration the hash is
658+
* version-based and cannot reflect the rendered-dependency change we are
659+
* refreshing for, so re-index unconditionally instead of skipping.
660+
*/
638661
if (
639662
op.type === 'update' &&
663+
!forceRehydrate &&
640664
existingByExternalId.get(op.extDoc.externalId)?.contentHash === hydratedHash
641665
) {
642666
result.docsUnchanged++

0 commit comments

Comments
 (0)