@open-mercato/core 0.6.8-develop.6987.1.02f619470c → 0.6.8-develop.6993.1.3fc50f3409

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 (46) 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/dist/modules/sales/api/documents/factory.js +39 -1
  29. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  30. package/package.json +7 -7
  31. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -3
  32. package/src/modules/customers/components/detail/DealsSection.tsx +6 -11
  33. package/src/modules/customers/components/detail/assignableStaff.ts +9 -1
  34. package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +16 -13
  35. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +11 -4
  36. package/src/modules/data_sync/AGENTS.md +4 -1
  37. package/src/modules/data_sync/api/run.ts +9 -1
  38. package/src/modules/data_sync/api/runs/[id]/retry.ts +9 -1
  39. package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
  40. package/src/modules/data_sync/lib/adapter.ts +18 -0
  41. package/src/modules/data_sync/lib/start-cursor.ts +35 -0
  42. package/src/modules/data_sync/lib/sync-engine.ts +5 -7
  43. package/src/modules/data_sync/lib/sync-run-service.ts +118 -10
  44. package/src/modules/data_sync/workers/sync-scheduled.ts +9 -6
  45. package/src/modules/sales/api/channels/route.ts +1 -1
  46. package/src/modules/sales/api/documents/factory.ts +62 -1
@@ -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()`
@@ -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
+ }
@@ -8,7 +8,7 @@ import { STALE_JOB_TIMEOUT_SECONDS } from '../../progress/lib/progressService'
8
8
  import { refreshCoverageSnapshot } from '../../query_index/lib/coverage'
9
9
  import { emitDataSyncEvent } from '../events'
10
10
  import type { DataSyncAdapter, DataMapping, ExportBatch, ImportBatch } from './adapter'
11
- import { getDataSyncAdapter } from './adapter-registry'
11
+ import { getDataSyncAdapter, resolveProviderKey } from './adapter-registry'
12
12
  import type { SyncRunService } from './sync-run-service'
13
13
  import { SyncRunOwnershipConflictError } from './sync-run-service'
14
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -30,10 +30,6 @@ type EngineDeps = {
30
30
  progressService: ProgressService
31
31
  }
32
32
 
33
- function resolveProviderKey(integrationId: string): string {
34
- return getIntegration(integrationId)?.providerKey ?? integrationId
35
- }
36
-
37
33
  function applyImportCounters(batch: ImportBatch): Pick<Required<SyncCounterDelta>, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount'> {
38
34
  let createdCount = 0
39
35
  let updatedCount = 0
@@ -486,6 +482,7 @@ export function createSyncEngine(deps: EngineDeps) {
486
482
  throw new Error(`No import adapter registered for provider ${providerKey}`)
487
483
  }
488
484
  const operationalTelemetry = adapter.operationalTelemetry === true
485
+ const persistSharedCursor = adapter.persistsSharedCursor?.(run.entityType) ?? true
489
486
 
490
487
  const credentials = await integrationCredentialsService.resolve(run.integrationId, scope)
491
488
  if (!credentials) {
@@ -575,7 +572,7 @@ export function createSyncEngine(deps: EngineDeps) {
575
572
  },
576
573
  batch.cursor,
577
574
  scope,
578
- committedBatches,
575
+ { expectedBatchesCompleted: committedBatches, persistSharedCursor },
579
576
  )
580
577
  committedBatches += 1
581
578
 
@@ -650,6 +647,7 @@ export function createSyncEngine(deps: EngineDeps) {
650
647
  throw new Error(`No export adapter registered for provider ${providerKey}`)
651
648
  }
652
649
  const operationalTelemetry = adapter.operationalTelemetry === true
650
+ const persistSharedCursor = adapter.persistsSharedCursor?.(run.entityType) ?? true
653
651
 
654
652
  const credentials = await integrationCredentialsService.resolve(run.integrationId, scope)
655
653
  if (!credentials) {
@@ -739,7 +737,7 @@ export function createSyncEngine(deps: EngineDeps) {
739
737
  },
740
738
  batch.cursor,
741
739
  scope,
742
- committedBatches,
740
+ { expectedBatchesCompleted: committedBatches, persistSharedCursor },
743
741
  )
744
742
  committedBatches += 1
745
743
  await updateProgress(run.progressJobId, processedCount, null, scope)
@@ -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'] },
@@ -4,8 +4,10 @@ import { splitCustomFieldPayload, extractAllCustomFieldEntries } from '@open-mer
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
5
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
6
  import { E } from '#generated/entities.ids.generated'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
8
  import type { SalesOrder, SalesQuote } from '../../data/entities'
8
- import { SalesDocumentTagAssignment } from '../../data/entities'
9
+ import { SalesChannel, SalesDocumentTagAssignment } from '../../data/entities'
10
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
11
  import {
10
12
  ORDER_PAYMENT_LEDGER_FIELDS,
11
13
  ORDER_PAYMENT_LEDGER_WARNING_CODE,
@@ -298,6 +300,62 @@ const attachTags = async (payload: any, ctx: any) => {
298
300
  })
299
301
  }
300
302
 
303
+ // Liveness note: this hook runs BEFORE the CRUD list cache stores the payload, so `channelName`
304
+ // and `channelCode` are embedded in the cached entry. What keeps them fresh is that the hook also
305
+ // runs on the cache-HIT path (shared/lib/crud/factory.ts:1683) and reassigns both fields
306
+ // unconditionally for every item carrying a channel id. Anything that later skips this hook on a
307
+ // hit — the way `skipEnrichersOnCacheHit` does for record-pure enrichers — would start serving the
308
+ // stale names baked into the entry.
309
+ export const attachChannelNames = async (
310
+ payload: { items?: Array<Record<string, unknown>> },
311
+ ctx: CrudCtx,
312
+ ) => {
313
+ const items = Array.isArray(payload?.items) ? payload.items : []
314
+ if (!items.length) return
315
+ const channelIds = Array.from(
316
+ new Set(
317
+ items
318
+ .map((item) => (item && typeof item.channelId === 'string' ? item.channelId : null))
319
+ .filter((value): value is string => !!value)
320
+ )
321
+ )
322
+ if (!channelIds.length) return
323
+ const em = ctx?.container?.resolve ? (ctx.container.resolve('em') as EntityManager) : null
324
+ if (!em) return
325
+
326
+ const where: Record<string, unknown> = { id: { $in: channelIds } }
327
+ if (ctx?.auth?.tenantId) where.tenantId = ctx.auth.tenantId
328
+ const orgIds =
329
+ Array.isArray(ctx?.organizationIds) && ctx.organizationIds.length
330
+ ? ctx.organizationIds.filter((val: string | null) => !!val)
331
+ : ctx?.selectedOrganizationId
332
+ ? [ctx.selectedOrganizationId]
333
+ : []
334
+ if (orgIds.length) where.organizationId = { $in: orgIds }
335
+
336
+ // Only `name` and `code` are read. Without this projection the query loads the whole channel row,
337
+ // and `sales/encryption.ts` declares eight of its columns encrypted (contact email/phone, the
338
+ // address block) — so every documents-list request would decrypt PII it never renders, on the
339
+ // cache-hit path too.
340
+ const channels = await findWithDecryption(
341
+ em,
342
+ SalesChannel,
343
+ where,
344
+ { fields: ['id', 'name', 'code'] },
345
+ {
346
+ tenantId: ctx?.auth?.tenantId ?? null,
347
+ organizationId: ctx?.selectedOrganizationId ?? ctx?.auth?.orgId ?? null,
348
+ }
349
+ )
350
+ const byId = new Map(channels.map((channel) => [channel.id, channel]))
351
+ items.forEach((item) => {
352
+ if (!item || typeof item.channelId !== 'string') return
353
+ const channel = byId.get(item.channelId)
354
+ item.channelName = channel?.name ?? null
355
+ item.channelCode = channel?.code ?? null
356
+ })
357
+ }
358
+
301
359
  async function ensureNumberEditPermission(
302
360
  ctx: CrudCtx,
303
361
  translate: (key: string, fallback?: string) => string
@@ -557,6 +615,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
557
615
  hooks: {
558
616
  afterList: async (payload: any, ctx: CrudCtx) => {
559
617
  await attachTags(payload, { ...ctx, bindingKind: binding.kind })
618
+ await attachChannelNames(payload, ctx)
560
619
  if (binding.kind === 'order' && Array.isArray(payload?.items) && payload.items.length === 1) {
561
620
  const item = payload.items[0] as Record<string, unknown>
562
621
  const orderId = typeof item?.id === 'string' ? item.id : null
@@ -629,6 +688,8 @@ export function buildDocumentOpenApi(binding: DocumentBinding) {
629
688
  paymentMethodSnapshot: z.record(z.string(), z.unknown()).nullable().optional(),
630
689
  currencyCode: z.string().nullable(),
631
690
  channelId: z.string().uuid().nullable(),
691
+ channelName: z.string().nullable().optional(),
692
+ channelCode: z.string().nullable().optional(),
632
693
  organizationId: z.string().uuid().nullable(),
633
694
  tenantId: z.string().uuid().nullable(),
634
695
  validFrom: z.string().nullable().optional(),