@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/components/detail/AssignRoleDialog.js +9 -3
- package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/DealsSection.js +2 -3
- package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
- package/dist/modules/customers/components/detail/assignableStaff.js +2 -1
- package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +11 -12
- package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +7 -4
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/data_sync/api/run.js +9 -1
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/retry.js +9 -1
- package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
- package/dist/modules/data_sync/lib/adapter-registry.js +10 -1
- package/dist/modules/data_sync/lib/adapter-registry.js.map +2 -2
- package/dist/modules/data_sync/lib/start-cursor.js +17 -0
- package/dist/modules/data_sync/lib/start-cursor.js.map +7 -0
- package/dist/modules/data_sync/lib/sync-engine.js +84 -27
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +86 -10
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-scheduled.js +9 -6
- package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js +2 -0
- package/dist/modules/progress/lib/progressService.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +78 -34
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/sales/api/channels/route.js +1 -1
- package/dist/modules/sales/api/channels/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -3
- package/src/modules/customers/components/detail/DealsSection.tsx +6 -11
- package/src/modules/customers/components/detail/assignableStaff.ts +9 -1
- package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +16 -13
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +11 -4
- package/src/modules/data_sync/AGENTS.md +6 -1
- package/src/modules/data_sync/api/run.ts +9 -1
- package/src/modules/data_sync/api/runs/[id]/retry.ts +9 -1
- package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
- package/src/modules/data_sync/lib/adapter.ts +18 -0
- package/src/modules/data_sync/lib/start-cursor.ts +35 -0
- package/src/modules/data_sync/lib/sync-engine.ts +101 -27
- package/src/modules/data_sync/lib/sync-run-service.ts +118 -10
- package/src/modules/data_sync/workers/sync-scheduled.ts +9 -6
- package/src/modules/progress/AGENTS.md +2 -1
- package/src/modules/progress/lib/progressService.ts +9 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +111 -37
- 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
|
-
|
|
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
|
|
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
|
|
98
|
-
: `/api/sales/quotes?pageSize
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 &&
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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 =
|
|
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
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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)
|