@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.
Files changed (43) 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 +5 -7
  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/sales/api/channels/route.js +1 -1
  27. package/dist/modules/sales/api/channels/route.js.map +2 -2
  28. package/package.json +7 -7
  29. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -3
  30. package/src/modules/customers/components/detail/DealsSection.tsx +6 -11
  31. package/src/modules/customers/components/detail/assignableStaff.ts +9 -1
  32. package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +16 -13
  33. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +11 -4
  34. package/src/modules/data_sync/AGENTS.md +4 -1
  35. package/src/modules/data_sync/api/run.ts +9 -1
  36. package/src/modules/data_sync/api/runs/[id]/retry.ts +9 -1
  37. package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
  38. package/src/modules/data_sync/lib/adapter.ts +18 -0
  39. package/src/modules/data_sync/lib/start-cursor.ts +35 -0
  40. package/src/modules/data_sync/lib/sync-engine.ts +5 -7
  41. package/src/modules/data_sync/lib/sync-run-service.ts +118 -10
  42. package/src/modules/data_sync/workers/sync-scheduled.ts +9 -6
  43. 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(run: SyncRun, cursorRow: SyncCursor | null, cursor: string, scope: SyncScope): void {
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 cursorRow = await resolveCursorRow(run, scope)
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 still be
247
- * `running` and still sit on that batch count, or another delivery of the
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
- expectedBatchesCompleted?: number,
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 cursorRow = await resolveCursorRow(run, scope)
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 syncRunService.resolveCursor(
69
- schedule.integrationId,
70
- schedule.entityType,
71
- schedule.direction,
72
- job.payload.scope,
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.manage'] },
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'] },