@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.
- 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/dist/modules/sales/api/documents/factory.js +39 -1
- package/dist/modules/sales/api/documents/factory.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
- 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
|
|
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
|
|
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
|
+
}
|
|
@@ -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(
|
|
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'] },
|
|
@@ -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(),
|