@open-mercato/core 0.6.8-develop.6986.1.3adb0d0df6 → 0.6.8-develop.6992.1.00c90fecff

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/detail/AssignRoleDialog.js +9 -3
  3. package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
  4. package/dist/modules/customers/components/detail/DealsSection.js +2 -3
  5. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  6. package/dist/modules/customers/components/detail/assignableStaff.js +2 -1
  7. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  8. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +11 -12
  9. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
  10. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +7 -4
  11. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  12. package/dist/modules/data_sync/api/run.js +9 -1
  13. package/dist/modules/data_sync/api/run.js.map +2 -2
  14. package/dist/modules/data_sync/api/runs/[id]/retry.js +9 -1
  15. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  16. package/dist/modules/data_sync/lib/adapter-registry.js +10 -1
  17. package/dist/modules/data_sync/lib/adapter-registry.js.map +2 -2
  18. package/dist/modules/data_sync/lib/start-cursor.js +17 -0
  19. package/dist/modules/data_sync/lib/start-cursor.js.map +7 -0
  20. package/dist/modules/data_sync/lib/sync-engine.js +84 -27
  21. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  22. package/dist/modules/data_sync/lib/sync-run-service.js +86 -10
  23. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  24. package/dist/modules/data_sync/workers/sync-scheduled.js +9 -6
  25. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  26. package/dist/modules/progress/lib/progressService.js +2 -0
  27. package/dist/modules/progress/lib/progressService.js.map +2 -2
  28. package/dist/modules/progress/lib/progressServiceImpl.js +78 -34
  29. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  30. package/dist/modules/sales/api/channels/route.js +1 -1
  31. package/dist/modules/sales/api/channels/route.js.map +2 -2
  32. package/package.json +7 -7
  33. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -3
  34. package/src/modules/customers/components/detail/DealsSection.tsx +6 -11
  35. package/src/modules/customers/components/detail/assignableStaff.ts +9 -1
  36. package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +16 -13
  37. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +11 -4
  38. package/src/modules/data_sync/AGENTS.md +6 -1
  39. package/src/modules/data_sync/api/run.ts +9 -1
  40. package/src/modules/data_sync/api/runs/[id]/retry.ts +9 -1
  41. package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
  42. package/src/modules/data_sync/lib/adapter.ts +18 -0
  43. package/src/modules/data_sync/lib/start-cursor.ts +35 -0
  44. package/src/modules/data_sync/lib/sync-engine.ts +101 -27
  45. package/src/modules/data_sync/lib/sync-run-service.ts +118 -10
  46. package/src/modules/data_sync/workers/sync-scheduled.ts +9 -6
  47. package/src/modules/progress/AGENTS.md +2 -1
  48. package/src/modules/progress/lib/progressService.ts +9 -0
  49. package/src/modules/progress/lib/progressServiceImpl.ts +111 -37
  50. package/src/modules/sales/api/channels/route.ts +1 -1
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { Building2, Briefcase, FileText, Search, X } from 'lucide-react'
5
5
  import { cn } from '@open-mercato/shared/lib/utils'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'
7
8
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
9
  import { Button } from '@open-mercato/ui/primitives/button'
9
10
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
@@ -14,6 +15,8 @@ import type { LinkedEntity } from './useScheduleFormState'
14
15
 
15
16
  const ENTITY_LINK_TYPES = ['company', 'deal', 'offer'] as const
16
17
 
18
+ const PAGE_SIZE = 20
19
+
17
20
  function readLabelCandidate(value: unknown): string | null {
18
21
  if (typeof value !== 'string') return null
19
22
  const trimmed = value.trim()
@@ -78,7 +81,10 @@ function EntityLinkSearchPopover({
78
81
  const [query, setQuery] = React.useState('')
79
82
  const [results, setResults] = React.useState<Array<{ id: string; label: string }>>([])
80
83
  const [page, setPage] = React.useState(1)
81
- const [totalPages, setTotalPages] = React.useState(1)
84
+ // Short-page termination instead of a `total`/`totalPages` bound see
85
+ // `hasMoreFromPage`. `items` is the raw response, before the mapping and
86
+ // filtering below, so its length is what the server served.
87
+ const [hasMore, setHasMore] = React.useState(false)
82
88
  const [loading, setLoading] = React.useState(false)
83
89
  const selectableResults = React.useMemo(
84
90
  () => results.filter((result) => !existingIds.has(result.id)),
@@ -90,12 +96,12 @@ function EntityLinkSearchPopover({
90
96
  const controller = new AbortController()
91
97
  setLoading(true)
92
98
  const searchParam = query.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
93
- const pagingParam = `&page=${page}&pageSize=20`
99
+ const pagingParam = `&page=${page}&pageSize=${PAGE_SIZE}`
94
100
  const endpoint = linkType === 'company'
95
101
  ? `/api/customers/companies?sortField=name&sortDir=asc${pagingParam}${searchParam}`
96
102
  : linkType === 'deal'
97
- ? `/api/customers/deals?pageSize=20&page=${page}${searchParam}`
98
- : `/api/sales/quotes?pageSize=20&page=${page}${searchParam}`
103
+ ? `/api/customers/deals?pageSize=${PAGE_SIZE}&page=${page}${searchParam}`
104
+ : `/api/sales/quotes?pageSize=${PAGE_SIZE}&page=${page}${searchParam}`
99
105
  readApiResultOrThrow<{ items?: Array<Record<string, unknown>>; totalPages?: number; page?: number; pageSize?: number; total?: number }>(endpoint, { signal: controller.signal })
100
106
  .then((data) => {
101
107
  const items = Array.isArray(data?.items) ? data.items : []
@@ -109,15 +115,12 @@ function EntityLinkSearchPopover({
109
115
  nextResults.forEach((entry) => merged.set(entry.id, entry))
110
116
  return Array.from(merged.values())
111
117
  })
112
- if (typeof data?.totalPages === 'number') {
113
- setTotalPages(data.totalPages)
114
- } else if (typeof data?.total === 'number' && typeof data?.pageSize === 'number') {
115
- setTotalPages(Math.max(1, Math.ceil(data.total / data.pageSize)))
116
- } else {
117
- setTotalPages(1)
118
- }
118
+ setHasMore(hasMoreFromPage(items.length, PAGE_SIZE))
119
+ })
120
+ .catch(() => {
121
+ setResults([])
122
+ setHasMore(false)
119
123
  })
120
- .catch(() => setResults([]))
121
124
  .finally(() => setLoading(false))
122
125
  return () => controller.abort()
123
126
  }, [open, page, query, linkType])
@@ -212,7 +215,7 @@ function EntityLinkSearchPopover({
212
215
  </Button>
213
216
  )
214
217
  })}
215
- {!loading && page < totalPages ? (
218
+ {!loading && hasMore ? (
216
219
  <div className="px-2 py-2">
217
220
  <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => setPage((current) => current + 1)}>
218
221
  {t('customers.schedule.loadMore', 'Load more')}
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { Users, X, Clock, CheckCircle2, XCircle } from 'lucide-react'
5
5
  import { cn } from '@open-mercato/shared/lib/utils'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'
7
8
  import { Button } from '@open-mercato/ui/primitives/button'
8
9
  import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
9
10
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
@@ -15,6 +16,8 @@ import { isVisible, getFieldLabel } from './fieldConfig'
15
16
  import type { Participant, RsvpStatus } from './useScheduleFormState'
16
17
  import { PARTICIPANT_COLORS } from './useScheduleFormState'
17
18
 
19
+ const PAGE_SIZE = 20
20
+
18
21
  function ParticipantSearchPopover({
19
22
  existingIds,
20
23
  onAdd,
@@ -30,7 +33,10 @@ function ParticipantSearchPopover({
30
33
  const [query, setQuery] = React.useState('')
31
34
  const [results, setResults] = React.useState<Array<{ userId: string; name: string; email: string }>>([])
32
35
  const [page, setPage] = React.useState(1)
33
- const [totalPages, setTotalPages] = React.useState(1)
36
+ // Short-page termination instead of a `total`-derived page bound — see
37
+ // `hasMoreFromPage`. Measured on the served count, not on `members`, which is
38
+ // deduped by user id.
39
+ const [hasMore, setHasMore] = React.useState(false)
34
40
  const [loading, setLoading] = React.useState(false)
35
41
  const [loadError, setLoadError] = React.useState<string | null>(null)
36
42
  const selectableResults = React.useMemo(
@@ -42,7 +48,7 @@ function ParticipantSearchPopover({
42
48
  if (!open) return
43
49
  const controller = new AbortController()
44
50
  setLoading(true)
45
- fetchAssignableStaffMembersPage(query, { page, pageSize: 20, signal: controller.signal })
51
+ fetchAssignableStaffMembersPage(query, { page, pageSize: PAGE_SIZE, signal: controller.signal })
46
52
  .then((result) => {
47
53
  const members = result.items
48
54
  const nextResults = members.map((member) => ({
@@ -56,11 +62,12 @@ function ParticipantSearchPopover({
56
62
  nextResults.forEach((entry) => merged.set(entry.userId, entry))
57
63
  return Array.from(merged.values())
58
64
  })
59
- setTotalPages(result.total > 0 ? Math.max(1, Math.ceil(result.total / result.pageSize)) : 1)
65
+ setHasMore(hasMoreFromPage(result.servedCount, PAGE_SIZE))
60
66
  setLoadError(null)
61
67
  })
62
68
  .catch(() => {
63
69
  setResults([])
70
+ setHasMore(false)
64
71
  setLoadError(
65
72
  t(
66
73
  'customers.assignableStaff.loadError',
@@ -148,7 +155,7 @@ function ParticipantSearchPopover({
148
155
  </Button>
149
156
  )
150
157
  })}
151
- {!loading && !loadError && page < totalPages ? (
158
+ {!loading && !loadError && hasMore ? (
152
159
  <div className="px-2 py-2">
153
160
  <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => setPage((current) => current + 1)}>
154
161
  {t('customers.schedule.loadMore', 'Load more')}
@@ -104,6 +104,7 @@ interface DataSyncAdapter {
104
104
  streamExport?(entityType: string, cursor: string | null, config: SyncConfig): AsyncIterable<ExportBatch>
105
105
  getInitialCursor?(entityType: string): Promise<string | null>
106
106
  getMapping?(entityType: string): Promise<FieldMapping[]>
107
+ persistsSharedCursor?(entityType: string): boolean
107
108
  validateConnection?(credentials: Record<string, unknown>): Promise<{ valid: boolean; message?: string }>
108
109
  }
109
110
  ```
@@ -124,7 +125,9 @@ If the sync provider needs bootstrap credentials, mappings, locales, channels, o
124
125
 
125
126
  `pending` → `running` → `completed` | `failed` | `cancelled`
126
127
 
127
- - **Cursor persistence**: After each batch, cursor is saved to `SyncCursor`
128
+ - **Cursor persistence**: After each batch, the cursor is saved on the run row and mirrored into the shared `SyncCursor` row
129
+ - **Shared cursor opt-out**: An adapter returning `persistsSharedCursor(entityType) === false` keeps that entity type's cursor on the run row only — use it for whole-table backfills whose cursor is one run's scan state, not a durable log position. Those entity types resolve an incremental start position from the most recent run (`resolveResumeCursor`) instead of the shared row, and from `null` when that run completed
130
+ - **Resetting an opt-out**: A reset flow that deletes the shared `SyncCursor` row MUST also call `syncRunService.resetResumePosition(integrationId, entityType, direction, scope)`. An opted-out entity type has no shared row to delete, so deleting only that leaves the resume position on the last interrupted run and the next incremental run re-imports just the tail of the walk it was reset against. The call is a no-op when nothing is interrupted, so make it unconditionally
128
131
  - **Resume**: Retry reads the last successful cursor, resumes from there
129
132
  - **Progress**: Linked to `ProgressJob` via `progressJobId` for `ProgressTopBar` display
130
133
  - **Cancellation**: Via `progressService.isCancellationRequested()`
@@ -172,6 +175,8 @@ Data sync providers can leverage the **Unified Module Extension System (UMES)**
172
175
 
173
176
  - `ProgressTopBar` and sync-run detail pages use `progress.job.*` SSE updates for live progress.
174
177
  - Create `ProgressJob` in `run`/`retry` endpoints; start/update/complete/fail in `sync-engine`.
178
+ - The engine heartbeats (`touchJobHeartbeat`, forked-EM) on a timer while an adapter batch is being produced, because batches can outlast the 60s stale-job sweep — keep the `withHeartbeat` wrapper around `streamImport`/`streamExport` when touching the batch loops.
179
+ - On redelivery the progress counter is seeded from the progress job's own `processedCount` (`progressService.getJob`), mirroring how `committedBatches` resumes — never reset it to zero (`updateProgress` writes absolute counts) and never seed it from the run's `created/updated/skipped/failed` columns: those count emitted items, while progress counts source records (`batch.processedCount`), and adapters may emit several items per source record.
175
180
  - Include `progressJob` details in run detail response.
176
181
  - SSE DOM bridge forwards only events with `clientBroadcast: true`.
177
182
  - `progress.job.*` events are marked `clientBroadcast: true` and must reach the browser from both web and worker processes.
@@ -10,6 +10,7 @@ import type { SyncRunService } from '../lib/sync-run-service'
10
10
  import { runSyncSchema } from '../data/validators'
11
11
  import { startDataSyncRun } from '../lib/start-run'
12
12
  import { getDataSyncAdapter } from '../lib/adapter-registry'
13
+ import { resolveStartCursor } from '../lib/start-cursor'
13
14
  import {
14
15
  runCrudMutationGuardAfterSuccess,
15
16
  validateCrudMutationGuard,
@@ -109,7 +110,14 @@ export async function POST(req: Request) {
109
110
 
110
111
  const cursor = parsed.data.fullSync
111
112
  ? null
112
- : await syncRunService.resolveCursor(parsed.data.integrationId, parsed.data.entityType, parsed.data.direction, scope)
113
+ : await resolveStartCursor({
114
+ syncRunService,
115
+ adapter,
116
+ integrationId: parsed.data.integrationId,
117
+ entityType: parsed.data.entityType,
118
+ direction: parsed.data.direction,
119
+ scope,
120
+ })
113
121
 
114
122
  const { run, progressJob } = await startDataSyncRun({
115
123
  syncRunService,
@@ -8,6 +8,7 @@ import type { ProgressService } from '../../../../progress/lib/progressService'
8
8
  import type { SyncRunService } from '../../../lib/sync-run-service'
9
9
  import { retrySyncSchema } from '../../../data/validators'
10
10
  import { startDataSyncRun } from '../../../lib/start-run'
11
+ import { resolveAdapterForIntegration, resolveStartCursor } from '../../../lib/start-cursor'
11
12
  import {
12
13
  runCrudMutationGuardAfterSuccess,
13
14
  validateCrudMutationGuard,
@@ -89,7 +90,14 @@ export async function POST(req: Request, ctx: { params?: Promise<{ id?: string }
89
90
 
90
91
  const cursor = parsedBody.data.fromBeginning
91
92
  ? null
92
- : previous.cursor ?? await syncRunService.resolveCursor(previous.integrationId, previous.entityType, previous.direction, scope)
93
+ : previous.cursor ?? await resolveStartCursor({
94
+ syncRunService,
95
+ adapter: resolveAdapterForIntegration(previous.integrationId),
96
+ integrationId: previous.integrationId,
97
+ entityType: previous.entityType,
98
+ direction: previous.direction,
99
+ scope,
100
+ })
93
101
 
94
102
  const { run, progressJob } = await startDataSyncRun({
95
103
  syncRunService,
@@ -1,3 +1,4 @@
1
+ import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
1
2
  import type { DataSyncAdapter } from './adapter'
2
3
 
3
4
  const DATA_SYNC_ADAPTER_REGISTRY_KEY = Symbol.for('@open-mercato/data-sync/adapter-registry')
@@ -25,3 +26,17 @@ export function getDataSyncAdapter(providerKey: string): DataSyncAdapter | undef
25
26
  export function getAllDataSyncAdapters(): DataSyncAdapter[] {
26
27
  return Array.from(getAdapterRegistry().values())
27
28
  }
29
+
30
+ export function resolveProviderKey(integrationId: string): string {
31
+ return getIntegration(integrationId)?.providerKey ?? integrationId
32
+ }
33
+
34
+ /**
35
+ * The adapter serving an integration. Single source of truth on purpose: the
36
+ * engine uses it to decide whether to WRITE the shared cursor row and the start
37
+ * paths use it to decide whether to READ one, and those two decisions must agree
38
+ * for every integration or an opted-out entity type silently resumes wrong.
39
+ */
40
+ export function resolveAdapterForIntegration(integrationId: string): DataSyncAdapter | null {
41
+ return getDataSyncAdapter(resolveProviderKey(integrationId)) ?? null
42
+ }
@@ -124,6 +124,24 @@ export interface DataSyncAdapter {
124
124
  */
125
125
  streamImport?(input: StreamImportInput): AsyncIterable<ImportBatch>
126
126
  streamExport?(input: StreamExportInput): AsyncIterable<ExportBatch>
127
+ /**
128
+ * Whether the engine mirrors this entity type's cursor into the shared
129
+ * `sync_cursors` row — one row per (integration, entityType, direction,
130
+ * scope), overwritten by every run that commits a batch.
131
+ *
132
+ * Default `true`. Return `false` for an entity type whose cursor is a single
133
+ * run's scan state rather than a durable position in a log. The run row keeps
134
+ * `initialCursor` + `cursor` either way, so a redelivered job still resumes
135
+ * exactly; what goes away is two concurrent or consecutive runs of the same
136
+ * entity type silently redefining each other's start position.
137
+ *
138
+ * The distinction is a blast radius, not a preference: losing a log position
139
+ * means re-draining the whole change queue, while losing a table walk's
140
+ * position means re-walking a table the adapter already re-walks idempotently.
141
+ * The predicate is per entity type because one adapter commonly serves both
142
+ * kinds — an incremental feed and a whole-table backfill.
143
+ */
144
+ persistsSharedCursor?(entityType: string): boolean
127
145
  getInitialCursor?(input: { entityType: string; scope: TenantScope }): Promise<string | null>
128
146
  getMapping(input: { entityType: string; scope: TenantScope }): Promise<DataMapping>
129
147
  validateConnection?(input: {
@@ -0,0 +1,35 @@
1
+ import type { DataSyncAdapter } from './adapter'
2
+ import type { SyncRunService } from './sync-run-service'
3
+
4
+ type SyncScope = {
5
+ organizationId: string
6
+ tenantId: string
7
+ }
8
+
9
+ export { resolveAdapterForIntegration } from './adapter-registry'
10
+
11
+ export function persistsSharedCursor(adapter: DataSyncAdapter | null | undefined, entityType: string): boolean {
12
+ return adapter?.persistsSharedCursor?.(entityType) ?? true
13
+ }
14
+
15
+ /**
16
+ * Start position for a non-full run. Entity types that mirror their cursor into
17
+ * the shared `sync_cursors` row read it from there. Entity types whose adapter
18
+ * opted out never write that row, so reading it would silently turn every
19
+ * incremental run into a full one — they resume from their own last run
20
+ * instead.
21
+ */
22
+ export async function resolveStartCursor(params: {
23
+ syncRunService: SyncRunService
24
+ adapter?: DataSyncAdapter | null
25
+ integrationId: string
26
+ entityType: string
27
+ direction: 'import' | 'export'
28
+ scope: SyncScope
29
+ }): Promise<string | null> {
30
+ const { syncRunService, adapter, integrationId, entityType, direction, scope } = params
31
+ if (persistsSharedCursor(adapter, entityType)) {
32
+ return syncRunService.resolveCursor(integrationId, entityType, direction, scope)
33
+ }
34
+ return syncRunService.resolveResumeCursor(integrationId, entityType, direction, scope)
35
+ }
@@ -4,10 +4,11 @@ import type { CredentialsService } from '../../integrations/lib/credentials-serv
4
4
  import type { IntegrationLogService } from '../../integrations/lib/log-service'
5
5
  import type { IntegrationStateService } from '../../integrations/lib/state-service'
6
6
  import type { ProgressService } from '../../progress/lib/progressService'
7
+ import { STALE_JOB_TIMEOUT_SECONDS } from '../../progress/lib/progressService'
7
8
  import { refreshCoverageSnapshot } from '../../query_index/lib/coverage'
8
9
  import { emitDataSyncEvent } from '../events'
9
10
  import type { DataSyncAdapter, DataMapping, ExportBatch, ImportBatch } from './adapter'
10
- import { getDataSyncAdapter } from './adapter-registry'
11
+ import { getDataSyncAdapter, resolveProviderKey } from './adapter-registry'
11
12
  import type { SyncRunService } from './sync-run-service'
12
13
  import { SyncRunOwnershipConflictError } from './sync-run-service'
13
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -29,10 +30,6 @@ type EngineDeps = {
29
30
  progressService: ProgressService
30
31
  }
31
32
 
32
- function resolveProviderKey(integrationId: string): string {
33
- return getIntegration(integrationId)?.providerKey ?? integrationId
34
- }
35
-
36
33
  function applyImportCounters(batch: ImportBatch): Pick<Required<SyncCounterDelta>, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount'> {
37
34
  let createdCount = 0
38
35
  let updatedCount = 0
@@ -76,6 +73,32 @@ function applyExportCounters(batch: ExportBatch): SyncCounterDelta {
76
73
  }
77
74
  }
78
75
 
76
+ // Adapter batches can legitimately outlast the stale-job sweep window (slow upstream
77
+ // APIs), so the engine must heartbeat while a batch is still being produced.
78
+ const HEARTBEAT_TICK_MS = (STALE_JOB_TIMEOUT_SECONDS * 1000) / 4
79
+
80
+ // Runs `tick` on an interval only while the source iterator is pending, so heartbeats
81
+ // stop the moment the producer dies and genuinely stale jobs still get swept. The outer
82
+ // finally closes the adapter generator on early exits (cancellation, ownership conflict).
83
+ async function* withHeartbeat<T>(source: AsyncIterable<T>, tick: () => void, intervalMs: number): AsyncGenerator<T, void, undefined> {
84
+ const iterator = source[Symbol.asyncIterator]()
85
+ try {
86
+ while (true) {
87
+ const timer = setInterval(tick, intervalMs)
88
+ let result: IteratorResult<T>
89
+ try {
90
+ result = await iterator.next()
91
+ } finally {
92
+ clearInterval(timer)
93
+ }
94
+ if (result.done) return
95
+ yield result.value
96
+ }
97
+ } finally {
98
+ await iterator.return?.()
99
+ }
100
+ }
101
+
79
102
  export function createSyncEngine(deps: EngineDeps) {
80
103
  const { syncRunService, integrationCredentialsService, integrationLogService, integrationStateService, progressService } = deps
81
104
 
@@ -103,6 +126,47 @@ export function createSyncEngine(deps: EngineDeps) {
103
126
  )
104
127
  }
105
128
 
129
+ // On redelivery the progress counter must resume where the last delivery left off the
130
+ // same way committedBatches does — updateProgress writes absolute counts, so starting at
131
+ // zero would regress the visible count. The progress job's own processedCount is the only
132
+ // persisted value already in the engine's unit: `batch.processedCount ?? items.length`,
133
+ // i.e. source records. The run's created/updated/skipped/failed counters count emitted
134
+ // items, which adapters may explode several-per-source-record (Akeneo yields a product
135
+ // plus its variants), so seeding from them would overshoot the total and pin the bar.
136
+ async function seedProcessedCount(progressJobId: string | null | undefined, scope: SyncScope): Promise<number> {
137
+ if (!progressJobId) return 0
138
+ const job = await progressService.getJob(progressJobId, {
139
+ tenantId: scope.tenantId,
140
+ organizationId: scope.organizationId,
141
+ userId: scope.userId,
142
+ })
143
+ return job?.processedCount ?? 0
144
+ }
145
+
146
+ function makeHeartbeatTick(progressJobId: string | null | undefined, scope: SyncScope): () => void {
147
+ const touchJobHeartbeat = progressService.touchJobHeartbeat?.bind(progressService)
148
+ if (!progressJobId || !touchJobHeartbeat) return () => {}
149
+ let inFlight = false
150
+ return () => {
151
+ if (inFlight) return
152
+ inFlight = true
153
+ touchJobHeartbeat(progressJobId, {
154
+ tenantId: scope.tenantId,
155
+ organizationId: scope.organizationId,
156
+ userId: scope.userId,
157
+ })
158
+ .catch((error) => {
159
+ logger.warn('Progress heartbeat failed', {
160
+ progressJobId,
161
+ error: error instanceof Error ? error.message : String(error),
162
+ })
163
+ })
164
+ .finally(() => {
165
+ inFlight = false
166
+ })
167
+ }
168
+ }
169
+
106
170
  async function refreshCoverageSnapshots(entityTypes: string[] | undefined, scope: SyncScope): Promise<void> {
107
171
  if (!entityTypes || entityTypes.length === 0) return
108
172
 
@@ -418,6 +482,7 @@ export function createSyncEngine(deps: EngineDeps) {
418
482
  throw new Error(`No import adapter registered for provider ${providerKey}`)
419
483
  }
420
484
  const operationalTelemetry = adapter.operationalTelemetry === true
485
+ const persistSharedCursor = adapter.persistsSharedCursor?.(run.entityType) ?? true
421
486
 
422
487
  const credentials = await integrationCredentialsService.resolve(run.integrationId, scope)
423
488
  if (!credentials) {
@@ -471,20 +536,24 @@ export function createSyncEngine(deps: EngineDeps) {
471
536
  }
472
537
 
473
538
  const mapping = await resolveMapping(adapter, run.entityType, scope)
474
- let processedCount = 0
539
+ let processedCount = await seedProcessedCount(run.progressJobId, scope)
475
540
  let totalCount: number | null = null
476
541
  let committedBatches = activeRun.batchesCompleted ?? 0
477
542
 
478
543
  try {
479
- for await (const batch of adapter.streamImport({
480
- entityType: run.entityType,
481
- cursor: run.cursor ?? undefined,
482
- batchSize,
483
- credentials,
484
- mapping,
485
- scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
486
- runId: run.id,
487
- })) {
544
+ for await (const batch of withHeartbeat(
545
+ adapter.streamImport({
546
+ entityType: run.entityType,
547
+ cursor: run.cursor ?? undefined,
548
+ batchSize,
549
+ credentials,
550
+ mapping,
551
+ scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
552
+ runId: run.id,
553
+ }),
554
+ makeHeartbeatTick(run.progressJobId, scope),
555
+ HEARTBEAT_TICK_MS,
556
+ )) {
488
557
  if (run.progressJobId && await progressService.isCancellationRequested(run.progressJobId, scope.tenantId, scope.organizationId)) {
489
558
  await finalizeRun(run.id, 'cancelled', scope, undefined, operationalTelemetry)
490
559
  return
@@ -503,7 +572,7 @@ export function createSyncEngine(deps: EngineDeps) {
503
572
  },
504
573
  batch.cursor,
505
574
  scope,
506
- committedBatches,
575
+ { expectedBatchesCompleted: committedBatches, persistSharedCursor },
507
576
  )
508
577
  committedBatches += 1
509
578
 
@@ -578,6 +647,7 @@ export function createSyncEngine(deps: EngineDeps) {
578
647
  throw new Error(`No export adapter registered for provider ${providerKey}`)
579
648
  }
580
649
  const operationalTelemetry = adapter.operationalTelemetry === true
650
+ const persistSharedCursor = adapter.persistsSharedCursor?.(run.entityType) ?? true
581
651
 
582
652
  const credentials = await integrationCredentialsService.resolve(run.integrationId, scope)
583
653
  if (!credentials) {
@@ -631,19 +701,23 @@ export function createSyncEngine(deps: EngineDeps) {
631
701
  }
632
702
 
633
703
  const mapping = await resolveMapping(adapter, run.entityType, scope)
634
- let processedCount = 0
704
+ let processedCount = await seedProcessedCount(run.progressJobId, scope)
635
705
  let committedBatches = activeRun.batchesCompleted ?? 0
636
706
 
637
707
  try {
638
- for await (const batch of adapter.streamExport({
639
- entityType: run.entityType,
640
- cursor: run.cursor ?? undefined,
641
- batchSize,
642
- credentials,
643
- mapping,
644
- scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
645
- runId: run.id,
646
- })) {
708
+ for await (const batch of withHeartbeat(
709
+ adapter.streamExport({
710
+ entityType: run.entityType,
711
+ cursor: run.cursor ?? undefined,
712
+ batchSize,
713
+ credentials,
714
+ mapping,
715
+ scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
716
+ runId: run.id,
717
+ }),
718
+ makeHeartbeatTick(run.progressJobId, scope),
719
+ HEARTBEAT_TICK_MS,
720
+ )) {
647
721
  if (run.progressJobId && await progressService.isCancellationRequested(run.progressJobId, scope.tenantId, scope.organizationId)) {
648
722
  await finalizeRun(run.id, 'cancelled', scope, undefined, operationalTelemetry)
649
723
  return
@@ -663,7 +737,7 @@ export function createSyncEngine(deps: EngineDeps) {
663
737
  },
664
738
  batch.cursor,
665
739
  scope,
666
- committedBatches,
740
+ { expectedBatchesCompleted: committedBatches, persistSharedCursor },
667
741
  )
668
742
  committedBatches += 1
669
743
  await updateProgress(run.progressJobId, processedCount, null, scope)