@open-mercato/core 0.6.8-develop.6987.1.02f619470c → 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 +5 -7
- 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/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 +4 -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 +5 -7
- 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/sales/api/channels/route.ts +1 -1
|
@@ -26,6 +26,25 @@ type SyncScope = {
|
|
|
26
26
|
tenantId: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export type CursorCommitOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* Mirror the committed cursor into the shared `sync_cursors` row. Defaults to
|
|
32
|
+
* `true`; the engine passes the adapter's `persistsSharedCursor(entityType)`
|
|
33
|
+
* verdict. `false` keeps the cursor on the run row alone.
|
|
34
|
+
*/
|
|
35
|
+
persistSharedCursor?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Fences the write against a concurrent delivery: the run must still be
|
|
38
|
+
* `running` and still sit on this batch count, or the commit throws
|
|
39
|
+
* {@link SyncRunOwnershipConflictError} and rolls back. Omit to keep the
|
|
40
|
+
* unguarded write for callers outside the engine.
|
|
41
|
+
*/
|
|
42
|
+
expectedBatchesCompleted?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** {@link CursorCommitOptions} minus the fence, which `updateCursor` does not apply. */
|
|
46
|
+
export type SharedCursorOption = Pick<CursorCommitOptions, 'persistSharedCursor'>
|
|
47
|
+
|
|
29
48
|
/**
|
|
30
49
|
* Raised when a batch commit loses the ownership compare-and-swap, meaning
|
|
31
50
|
* another delivery of the same job advanced the run while this worker was
|
|
@@ -64,8 +83,15 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
64
83
|
)
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
function applyCursorMutation(
|
|
86
|
+
function applyCursorMutation(
|
|
87
|
+
run: SyncRun,
|
|
88
|
+
cursorRow: SyncCursor | null,
|
|
89
|
+
cursor: string,
|
|
90
|
+
scope: SyncScope,
|
|
91
|
+
persistSharedCursor: boolean,
|
|
92
|
+
): void {
|
|
68
93
|
run.cursor = cursor
|
|
94
|
+
if (!persistSharedCursor) return
|
|
69
95
|
if (cursorRow) {
|
|
70
96
|
cursorRow.cursor = cursor
|
|
71
97
|
} else {
|
|
@@ -230,25 +256,37 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
230
256
|
* @deprecated Use {@link commitBatchProgress}. This method advances the
|
|
231
257
|
* cursor without the ownership fence, so a stale delivery can move the
|
|
232
258
|
* cursor of a run another worker owns. Kept for external callers only.
|
|
259
|
+
*
|
|
260
|
+
* It still takes `persistSharedCursor` despite being deprecated: an external
|
|
261
|
+
* caller advancing the cursor of an opted-out entity type would otherwise
|
|
262
|
+
* create the very `sync_cursors` row the opt-out exists to avoid, and a
|
|
263
|
+
* later incremental run would read it as a start position. The deprecated
|
|
264
|
+
* path has to honour the opt-out for as long as it exists.
|
|
233
265
|
*/
|
|
234
|
-
async updateCursor(runId: string, cursor: string, scope: SyncScope): Promise<void> {
|
|
266
|
+
async updateCursor(runId: string, cursor: string, scope: SyncScope, options?: SharedCursorOption): Promise<void> {
|
|
235
267
|
const run = await this.getRun(runId, scope)
|
|
236
268
|
if (!run) return
|
|
237
|
-
const
|
|
269
|
+
const persistSharedCursor = options?.persistSharedCursor ?? true
|
|
270
|
+
const cursorRow = persistSharedCursor ? await resolveCursorRow(run, scope) : null
|
|
238
271
|
await withAtomicFlush(em, [
|
|
239
|
-
() => applyCursorMutation(run, cursorRow, cursor, scope),
|
|
272
|
+
() => applyCursorMutation(run, cursorRow, cursor, scope, persistSharedCursor),
|
|
240
273
|
], { transaction: true })
|
|
241
274
|
},
|
|
242
275
|
|
|
243
276
|
/**
|
|
244
277
|
* Commits one batch's counters and cursor in a single transaction.
|
|
245
278
|
*
|
|
246
|
-
* Passing `expectedBatchesCompleted` fences the write: the run must
|
|
247
|
-
* `running` and still sit on that batch count, or another delivery
|
|
248
|
-
* same BullMQ job owns the run and this commit throws
|
|
279
|
+
* Passing `options.expectedBatchesCompleted` fences the write: the run must
|
|
280
|
+
* still be `running` and still sit on that batch count, or another delivery
|
|
281
|
+
* of the same BullMQ job owns the run and this commit throws
|
|
249
282
|
* `SyncRunOwnershipConflictError` and rolls back. Omitting it keeps the
|
|
250
283
|
* legacy unguarded write for callers outside the engine.
|
|
251
284
|
*
|
|
285
|
+
* `options.persistSharedCursor` is orthogonal to the fence: it decides
|
|
286
|
+
* whether the committed cursor is mirrored into the shared `sync_cursors`
|
|
287
|
+
* row, and the two compose freely — a fenced commit for an opted-out entity
|
|
288
|
+
* type advances the run row alone and still throws on a stale fence.
|
|
289
|
+
*
|
|
252
290
|
* The fence token is `batchesCompleted` rather than `cursor` because it
|
|
253
291
|
* advances by construction on every commit. A cursor is a free-form adapter
|
|
254
292
|
* string that an adapter may legitimately repeat between batches — the
|
|
@@ -268,11 +306,13 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
268
306
|
delta: Partial<Pick<SyncRun, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount' | 'batchesCompleted'>>,
|
|
269
307
|
cursor: string,
|
|
270
308
|
scope: SyncScope,
|
|
271
|
-
|
|
309
|
+
options?: CursorCommitOptions,
|
|
272
310
|
): Promise<SyncRun | null> {
|
|
273
311
|
const run = await this.getRun(runId, scope)
|
|
274
312
|
if (!run) return null
|
|
275
|
-
const
|
|
313
|
+
const { expectedBatchesCompleted } = options ?? {}
|
|
314
|
+
const persistSharedCursor = options?.persistSharedCursor ?? true
|
|
315
|
+
const cursorRow = persistSharedCursor ? await resolveCursorRow(run, scope) : null
|
|
276
316
|
const claimRunOwnership = async () => {
|
|
277
317
|
if ((delta.batchesCompleted ?? 0) < 1) {
|
|
278
318
|
throw new Error(`[internal] A fenced commit for sync run ${runId} must advance batchesCompleted`)
|
|
@@ -301,7 +341,7 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
301
341
|
run.skippedCount += delta.skippedCount ?? 0
|
|
302
342
|
run.failedCount += delta.failedCount ?? 0
|
|
303
343
|
run.batchesCompleted += delta.batchesCompleted ?? 0
|
|
304
|
-
applyCursorMutation(run, cursorRow, cursor, scope)
|
|
344
|
+
applyCursorMutation(run, cursorRow, cursor, scope, persistSharedCursor)
|
|
305
345
|
},
|
|
306
346
|
], { transaction: true })
|
|
307
347
|
return run
|
|
@@ -324,6 +364,74 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
324
364
|
return row?.cursor ?? null
|
|
325
365
|
},
|
|
326
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Resume position for an entity type whose adapter opted out of the shared
|
|
369
|
+
* `sync_cursors` row: the cursor of the most recent run, unless that run
|
|
370
|
+
* reached `completed`. A finished walk resumes from `null` so the next run
|
|
371
|
+
* starts over rather than skipping everything an older interrupted run had
|
|
372
|
+
* already passed.
|
|
373
|
+
*/
|
|
374
|
+
async resolveResumeCursor(integrationId: string, entityType: string, direction: 'import' | 'export', scope: SyncScope): Promise<string | null> {
|
|
375
|
+
const [run] = await findWithDecryption(
|
|
376
|
+
em,
|
|
377
|
+
SyncRun,
|
|
378
|
+
{
|
|
379
|
+
integrationId,
|
|
380
|
+
entityType,
|
|
381
|
+
direction,
|
|
382
|
+
organizationId: scope.organizationId,
|
|
383
|
+
tenantId: scope.tenantId,
|
|
384
|
+
deletedAt: null,
|
|
385
|
+
},
|
|
386
|
+
{ orderBy: { createdAt: 'DESC' }, limit: 1 },
|
|
387
|
+
scope,
|
|
388
|
+
)
|
|
389
|
+
if (!run || run.status === 'completed') return null
|
|
390
|
+
return run.cursor ?? null
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Clears the run-scoped resume position for an entity type, so the next
|
|
395
|
+
* non-`fullSync` run starts from the beginning. Returns how many runs were
|
|
396
|
+
* cleared.
|
|
397
|
+
*
|
|
398
|
+
* This is the opt-out's equivalent of deleting the shared `sync_cursors`
|
|
399
|
+
* row. An entity type whose adapter returns `false` from
|
|
400
|
+
* `persistsSharedCursor` has no such row, so a reset flow that only deletes
|
|
401
|
+
* `SyncCursor` would leave {@link resolveResumeCursor} returning the cursor
|
|
402
|
+
* of the interrupted run it just reset against — re-importing the tail of a
|
|
403
|
+
* walk instead of the whole thing. Reset flows MUST call this alongside
|
|
404
|
+
* their `SyncCursor` delete; it is a no-op when nothing is interrupted.
|
|
405
|
+
*
|
|
406
|
+
* The `status` filter here selects which rows to clear. It is deliberately
|
|
407
|
+
* NOT the read-side filter {@link resolveResumeCursor} avoids: that method
|
|
408
|
+
* reads the single most recent run whatever its status, precisely so an
|
|
409
|
+
* older interrupted run cannot outlive a later completed walk. Clearing
|
|
410
|
+
* every interrupted run is enough to start fresh either way — if the latest
|
|
411
|
+
* run was interrupted its cursor is now null, and if it completed the resume
|
|
412
|
+
* path already returns null.
|
|
413
|
+
*/
|
|
414
|
+
async resetResumePosition(
|
|
415
|
+
integrationId: string,
|
|
416
|
+
entityType: string,
|
|
417
|
+
direction: 'import' | 'export',
|
|
418
|
+
scope: SyncScope,
|
|
419
|
+
): Promise<number> {
|
|
420
|
+
return em.nativeUpdate(
|
|
421
|
+
SyncRun,
|
|
422
|
+
{
|
|
423
|
+
integrationId,
|
|
424
|
+
entityType,
|
|
425
|
+
direction,
|
|
426
|
+
status: { $ne: 'completed' },
|
|
427
|
+
organizationId: scope.organizationId,
|
|
428
|
+
tenantId: scope.tenantId,
|
|
429
|
+
deletedAt: null,
|
|
430
|
+
},
|
|
431
|
+
{ cursor: null, updatedAt: new Date() },
|
|
432
|
+
)
|
|
433
|
+
},
|
|
434
|
+
|
|
327
435
|
async findRunningOverlap(integrationId: string, entityType: string, direction: 'import' | 'export', scope: SyncScope): Promise<SyncRun | null> {
|
|
328
436
|
const [run] = await findWithDecryption(
|
|
329
437
|
em,
|
|
@@ -6,6 +6,7 @@ import type { ProgressService } from '../../progress/lib/progressService'
|
|
|
6
6
|
import type { SyncRunService } from '../lib/sync-run-service'
|
|
7
7
|
import { SyncSchedule } from '../data/entities'
|
|
8
8
|
import { startDataSyncRun } from '../lib/start-run'
|
|
9
|
+
import { resolveAdapterForIntegration, resolveStartCursor } from '../lib/start-cursor'
|
|
9
10
|
|
|
10
11
|
type ScheduledSyncPayload = {
|
|
11
12
|
scheduleId: string
|
|
@@ -65,12 +66,14 @@ export default async function handle(job: QueuedJob<ScheduledSyncPayload>, ctx:
|
|
|
65
66
|
|
|
66
67
|
const cursor = schedule.fullSync
|
|
67
68
|
? null
|
|
68
|
-
: await
|
|
69
|
-
|
|
70
|
-
schedule.
|
|
71
|
-
schedule.
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
: await resolveStartCursor({
|
|
70
|
+
syncRunService,
|
|
71
|
+
adapter: resolveAdapterForIntegration(schedule.integrationId),
|
|
72
|
+
integrationId: schedule.integrationId,
|
|
73
|
+
entityType: schedule.entityType,
|
|
74
|
+
direction: schedule.direction,
|
|
75
|
+
scope: job.payload.scope,
|
|
76
|
+
})
|
|
74
77
|
|
|
75
78
|
schedule.lastRunAt = new Date()
|
|
76
79
|
await em.flush()
|
|
@@ -38,7 +38,7 @@ const listSchema = z
|
|
|
38
38
|
.passthrough()
|
|
39
39
|
|
|
40
40
|
const routeMetadata = {
|
|
41
|
-
GET: { requireAuth: true, requireFeatures: ['sales.channels.
|
|
41
|
+
GET: { requireAuth: true, requireFeatures: ['sales.channels.view'] },
|
|
42
42
|
POST: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },
|
|
43
43
|
PUT: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },
|
|
44
44
|
DELETE: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },
|