@open-mercato/core 0.4.6-develop-a88276bc52 → 0.4.6-develop-806a2ed6b9

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 (226) hide show
  1. package/AGENTS.md +10 -0
  2. package/dist/generated/entities/integration_credentials/index.js +19 -0
  3. package/dist/generated/entities/integration_credentials/index.js.map +7 -0
  4. package/dist/generated/entities/integration_log/index.js +27 -0
  5. package/dist/generated/entities/integration_log/index.js.map +7 -0
  6. package/dist/generated/entities/integration_state/index.js +27 -0
  7. package/dist/generated/entities/integration_state/index.js.map +7 -0
  8. package/dist/generated/entities/sync_cursor/index.js +19 -0
  9. package/dist/generated/entities/sync_cursor/index.js.map +7 -0
  10. package/dist/generated/entities/sync_external_id_mapping/index.js +27 -0
  11. package/dist/generated/entities/sync_external_id_mapping/index.js.map +7 -0
  12. package/dist/generated/entities/sync_mapping/index.js +19 -0
  13. package/dist/generated/entities/sync_mapping/index.js.map +7 -0
  14. package/dist/generated/entities/sync_run/index.js +45 -0
  15. package/dist/generated/entities/sync_run/index.js.map +7 -0
  16. package/dist/generated/entities/sync_schedule/index.js +35 -0
  17. package/dist/generated/entities/sync_schedule/index.js.map +7 -0
  18. package/dist/generated/entities.ids.generated.js +14 -0
  19. package/dist/generated/entities.ids.generated.js.map +2 -2
  20. package/dist/generated/entity-fields-registry.js +16 -0
  21. package/dist/generated/entity-fields-registry.js.map +2 -2
  22. package/dist/modules/data_sync/acl.js +11 -0
  23. package/dist/modules/data_sync/acl.js.map +7 -0
  24. package/dist/modules/data_sync/api/mappings/[id]/route.js +137 -0
  25. package/dist/modules/data_sync/api/mappings/[id]/route.js.map +7 -0
  26. package/dist/modules/data_sync/api/mappings/route.js +132 -0
  27. package/dist/modules/data_sync/api/mappings/route.js.map +7 -0
  28. package/dist/modules/data_sync/api/run.js +87 -0
  29. package/dist/modules/data_sync/api/run.js.map +7 -0
  30. package/dist/modules/data_sync/api/runs/[id]/cancel.js +49 -0
  31. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +7 -0
  32. package/dist/modules/data_sync/api/runs/[id]/retry.js +93 -0
  33. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +7 -0
  34. package/dist/modules/data_sync/api/runs/[id]/route.js +69 -0
  35. package/dist/modules/data_sync/api/runs/[id]/route.js.map +7 -0
  36. package/dist/modules/data_sync/api/runs.js +66 -0
  37. package/dist/modules/data_sync/api/runs.js.map +7 -0
  38. package/dist/modules/data_sync/api/validate.js +66 -0
  39. package/dist/modules/data_sync/api/validate.js.map +7 -0
  40. package/dist/modules/data_sync/backend/data-sync/page.js +216 -0
  41. package/dist/modules/data_sync/backend/data-sync/page.js.map +7 -0
  42. package/dist/modules/data_sync/backend/data-sync/page.meta.js +25 -0
  43. package/dist/modules/data_sync/backend/data-sync/page.meta.js.map +7 -0
  44. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +178 -0
  45. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +7 -0
  46. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.meta.js +14 -0
  47. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.meta.js.map +7 -0
  48. package/dist/modules/data_sync/data/entities.js +228 -0
  49. package/dist/modules/data_sync/data/entities.js.map +7 -0
  50. package/dist/modules/data_sync/data/validators.js +32 -0
  51. package/dist/modules/data_sync/data/validators.js.map +7 -0
  52. package/dist/modules/data_sync/di.js +26 -0
  53. package/dist/modules/data_sync/di.js.map +7 -0
  54. package/dist/modules/data_sync/events.js +16 -0
  55. package/dist/modules/data_sync/events.js.map +7 -0
  56. package/dist/modules/data_sync/index.js +9 -0
  57. package/dist/modules/data_sync/index.js.map +7 -0
  58. package/dist/modules/data_sync/lib/adapter-registry.js +16 -0
  59. package/dist/modules/data_sync/lib/adapter-registry.js.map +7 -0
  60. package/dist/modules/data_sync/lib/adapter.js +1 -0
  61. package/dist/modules/data_sync/lib/adapter.js.map +7 -0
  62. package/dist/modules/data_sync/lib/id-mapping.js +79 -0
  63. package/dist/modules/data_sync/lib/id-mapping.js.map +7 -0
  64. package/dist/modules/data_sync/lib/queue.js +17 -0
  65. package/dist/modules/data_sync/lib/queue.js.map +7 -0
  66. package/dist/modules/data_sync/lib/sync-engine.js +309 -0
  67. package/dist/modules/data_sync/lib/sync-engine.js.map +7 -0
  68. package/dist/modules/data_sync/lib/sync-run-service.js +148 -0
  69. package/dist/modules/data_sync/lib/sync-run-service.js.map +7 -0
  70. package/dist/modules/data_sync/migrations/Migration20260304113737.js +17 -0
  71. package/dist/modules/data_sync/migrations/Migration20260304113737.js.map +7 -0
  72. package/dist/modules/data_sync/setup.js +13 -0
  73. package/dist/modules/data_sync/setup.js.map +7 -0
  74. package/dist/modules/data_sync/workers/sync-export.js +14 -0
  75. package/dist/modules/data_sync/workers/sync-export.js.map +7 -0
  76. package/dist/modules/data_sync/workers/sync-import.js +14 -0
  77. package/dist/modules/data_sync/workers/sync-import.js.map +7 -0
  78. package/dist/modules/data_sync/workers/sync-scheduled.js +63 -0
  79. package/dist/modules/data_sync/workers/sync-scheduled.js.map +7 -0
  80. package/dist/modules/entities/lib/encryptionDefaults.js +4 -0
  81. package/dist/modules/entities/lib/encryptionDefaults.js.map +2 -2
  82. package/dist/modules/integrations/acl.js +4 -1
  83. package/dist/modules/integrations/acl.js.map +2 -2
  84. package/dist/modules/integrations/api/[id]/credentials/route.js +127 -0
  85. package/dist/modules/integrations/api/[id]/credentials/route.js.map +7 -0
  86. package/dist/modules/integrations/api/[id]/health/route.js +46 -0
  87. package/dist/modules/integrations/api/[id]/health/route.js.map +7 -0
  88. package/dist/modules/integrations/api/[id]/route.js +65 -0
  89. package/dist/modules/integrations/api/[id]/route.js.map +7 -0
  90. package/dist/modules/integrations/api/[id]/state/route.js +109 -0
  91. package/dist/modules/integrations/api/[id]/state/route.js.map +7 -0
  92. package/dist/modules/integrations/api/[id]/version/route.js +117 -0
  93. package/dist/modules/integrations/api/[id]/version/route.js.map +7 -0
  94. package/dist/modules/integrations/api/guards.js +31 -0
  95. package/dist/modules/integrations/api/guards.js.map +7 -0
  96. package/dist/modules/integrations/api/logs/route.js +60 -0
  97. package/dist/modules/integrations/api/logs/route.js.map +7 -0
  98. package/dist/modules/integrations/api/openapi.js +25 -0
  99. package/dist/modules/integrations/api/openapi.js.map +7 -0
  100. package/dist/modules/integrations/api/route.js +68 -0
  101. package/dist/modules/integrations/api/route.js.map +7 -0
  102. package/dist/modules/integrations/backend/integrations/[id]/page.js +313 -0
  103. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +7 -0
  104. package/dist/modules/integrations/backend/integrations/[id]/page.meta.js +15 -0
  105. package/dist/modules/integrations/backend/integrations/[id]/page.meta.js.map +7 -0
  106. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +189 -0
  107. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +7 -0
  108. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.meta.js +15 -0
  109. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.meta.js.map +7 -0
  110. package/dist/modules/integrations/backend/integrations/page.js +212 -0
  111. package/dist/modules/integrations/backend/integrations/page.js.map +7 -0
  112. package/dist/modules/integrations/backend/integrations/page.meta.js +22 -0
  113. package/dist/modules/integrations/backend/integrations/page.meta.js.map +7 -0
  114. package/dist/modules/integrations/data/enrichers.js +27 -12
  115. package/dist/modules/integrations/data/enrichers.js.map +2 -2
  116. package/dist/modules/integrations/data/entities.js +136 -1
  117. package/dist/modules/integrations/data/entities.js.map +2 -2
  118. package/dist/modules/integrations/data/validators.js +36 -0
  119. package/dist/modules/integrations/data/validators.js.map +7 -0
  120. package/dist/modules/integrations/di.js +24 -0
  121. package/dist/modules/integrations/di.js.map +7 -0
  122. package/dist/modules/integrations/events.js +19 -0
  123. package/dist/modules/integrations/events.js.map +7 -0
  124. package/dist/modules/integrations/lib/credentials-service.js +159 -0
  125. package/dist/modules/integrations/lib/credentials-service.js.map +7 -0
  126. package/dist/modules/integrations/lib/health-service.js +37 -0
  127. package/dist/modules/integrations/lib/health-service.js.map +7 -0
  128. package/dist/modules/integrations/lib/log-service.js +66 -0
  129. package/dist/modules/integrations/lib/log-service.js.map +7 -0
  130. package/dist/modules/integrations/lib/registry-service.js +33 -0
  131. package/dist/modules/integrations/lib/registry-service.js.map +7 -0
  132. package/dist/modules/integrations/lib/state-service.js +55 -0
  133. package/dist/modules/integrations/lib/state-service.js.map +7 -0
  134. package/dist/modules/integrations/lib/types.js +1 -0
  135. package/dist/modules/integrations/lib/types.js.map +7 -0
  136. package/dist/modules/integrations/migrations/Migration20260304113737.js +19 -0
  137. package/dist/modules/integrations/migrations/Migration20260304113737.js.map +7 -0
  138. package/dist/modules/integrations/setup.js +2 -2
  139. package/dist/modules/integrations/setup.js.map +2 -2
  140. package/dist/modules/integrations/widgets/injection-table.js.map +1 -1
  141. package/dist/modules/integrations/workers/log-pruner.js +18 -0
  142. package/dist/modules/integrations/workers/log-pruner.js.map +7 -0
  143. package/generated/entities/integration_credentials/index.ts +8 -0
  144. package/generated/entities/integration_log/index.ts +12 -0
  145. package/generated/entities/integration_state/index.ts +12 -0
  146. package/generated/entities/sync_cursor/index.ts +8 -0
  147. package/generated/entities/sync_external_id_mapping/index.ts +12 -0
  148. package/generated/entities/sync_mapping/index.ts +8 -0
  149. package/generated/entities/sync_run/index.ts +21 -0
  150. package/generated/entities/sync_schedule/index.ts +16 -0
  151. package/generated/entities.ids.generated.ts +14 -0
  152. package/generated/entity-fields-registry.ts +16 -0
  153. package/package.json +2 -2
  154. package/src/modules/data_sync/AGENTS.md +157 -0
  155. package/src/modules/data_sync/acl.ts +7 -0
  156. package/src/modules/data_sync/api/mappings/[id]/route.ts +158 -0
  157. package/src/modules/data_sync/api/mappings/route.ts +144 -0
  158. package/src/modules/data_sync/api/run.ts +97 -0
  159. package/src/modules/data_sync/api/runs/[id]/cancel.ts +57 -0
  160. package/src/modules/data_sync/api/runs/[id]/retry.ts +108 -0
  161. package/src/modules/data_sync/api/runs/[id]/route.ts +81 -0
  162. package/src/modules/data_sync/api/runs.ts +69 -0
  163. package/src/modules/data_sync/api/validate.ts +73 -0
  164. package/src/modules/data_sync/backend/data-sync/page.meta.ts +21 -0
  165. package/src/modules/data_sync/backend/data-sync/page.tsx +244 -0
  166. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.meta.ts +10 -0
  167. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +278 -0
  168. package/src/modules/data_sync/data/entities.ts +180 -0
  169. package/src/modules/data_sync/data/validators.ts +35 -0
  170. package/src/modules/data_sync/di.ts +38 -0
  171. package/src/modules/data_sync/events.ts +12 -0
  172. package/src/modules/data_sync/i18n/de.json +48 -0
  173. package/src/modules/data_sync/i18n/en.json +48 -0
  174. package/src/modules/data_sync/i18n/es.json +48 -0
  175. package/src/modules/data_sync/i18n/pl.json +48 -0
  176. package/src/modules/data_sync/index.ts +5 -0
  177. package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
  178. package/src/modules/data_sync/lib/adapter.ts +90 -0
  179. package/src/modules/data_sync/lib/id-mapping.ts +95 -0
  180. package/src/modules/data_sync/lib/queue.ts +19 -0
  181. package/src/modules/data_sync/lib/sync-engine.ts +375 -0
  182. package/src/modules/data_sync/lib/sync-run-service.ts +187 -0
  183. package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +653 -0
  184. package/src/modules/data_sync/migrations/Migration20260304113737.ts +19 -0
  185. package/src/modules/data_sync/setup.ts +11 -0
  186. package/src/modules/data_sync/workers/sync-export.ts +27 -0
  187. package/src/modules/data_sync/workers/sync-import.ts +27 -0
  188. package/src/modules/data_sync/workers/sync-scheduled.ts +84 -0
  189. package/src/modules/entities/lib/encryptionDefaults.ts +4 -0
  190. package/src/modules/integrations/AGENTS.md +160 -0
  191. package/src/modules/integrations/acl.ts +3 -0
  192. package/src/modules/integrations/api/[id]/credentials/route.ts +142 -0
  193. package/src/modules/integrations/api/[id]/health/route.ts +53 -0
  194. package/src/modules/integrations/api/[id]/route.ts +76 -0
  195. package/src/modules/integrations/api/[id]/state/route.ts +121 -0
  196. package/src/modules/integrations/api/[id]/version/route.ts +132 -0
  197. package/src/modules/integrations/api/guards.ts +59 -0
  198. package/src/modules/integrations/api/logs/route.ts +63 -0
  199. package/src/modules/integrations/api/openapi.ts +22 -0
  200. package/src/modules/integrations/api/route.ts +73 -0
  201. package/src/modules/integrations/backend/integrations/[id]/page.meta.ts +11 -0
  202. package/src/modules/integrations/backend/integrations/[id]/page.tsx +424 -0
  203. package/src/modules/integrations/backend/integrations/bundle/[id]/page.meta.ts +11 -0
  204. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +249 -0
  205. package/src/modules/integrations/backend/integrations/page.meta.ts +18 -0
  206. package/src/modules/integrations/backend/integrations/page.tsx +296 -0
  207. package/src/modules/integrations/data/enrichers.ts +35 -18
  208. package/src/modules/integrations/data/entities.ts +114 -5
  209. package/src/modules/integrations/data/validators.ts +41 -0
  210. package/src/modules/integrations/di.ts +31 -0
  211. package/src/modules/integrations/events.ts +17 -0
  212. package/src/modules/integrations/i18n/de.json +70 -0
  213. package/src/modules/integrations/i18n/en.json +70 -0
  214. package/src/modules/integrations/i18n/es.json +70 -0
  215. package/src/modules/integrations/i18n/pl.json +70 -0
  216. package/src/modules/integrations/lib/credentials-service.ts +204 -0
  217. package/src/modules/integrations/lib/health-service.ts +59 -0
  218. package/src/modules/integrations/lib/log-service.ts +84 -0
  219. package/src/modules/integrations/lib/registry-service.ts +42 -0
  220. package/src/modules/integrations/lib/state-service.ts +64 -0
  221. package/src/modules/integrations/lib/types.ts +4 -0
  222. package/src/modules/integrations/migrations/.snapshot-open-mercato.json +582 -0
  223. package/src/modules/integrations/migrations/Migration20260304113737.ts +21 -0
  224. package/src/modules/integrations/setup.ts +2 -2
  225. package/src/modules/integrations/widgets/injection-table.ts +1 -1
  226. package/src/modules/integrations/workers/log-pruner.ts +30 -0
@@ -0,0 +1,19 @@
1
+ import { createQueue, type Queue } from '@open-mercato/queue'
2
+ import { getRedisUrl } from '@open-mercato/shared/lib/redis/connection'
3
+
4
+ const queues = new Map<string, Queue<Record<string, unknown>>>()
5
+
6
+ export function getSyncQueue(queueName: string): Queue<Record<string, unknown>> {
7
+ const existing = queues.get(queueName)
8
+ if (existing) return existing
9
+
10
+ const created = process.env.QUEUE_STRATEGY === 'async'
11
+ ? createQueue<Record<string, unknown>>(queueName, 'async', {
12
+ connection: { url: getRedisUrl('QUEUE') },
13
+ concurrency: Math.max(1, Number.parseInt(process.env.DATA_SYNC_QUEUE_CONCURRENCY ?? '5', 10) || 5),
14
+ })
15
+ : createQueue<Record<string, unknown>>(queueName, 'local')
16
+
17
+ queues.set(queueName, created)
18
+ return created
19
+ }
@@ -0,0 +1,375 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
3
+ import type { CredentialsService } from '../../integrations/lib/credentials-service'
4
+ import type { IntegrationLogService } from '../../integrations/lib/log-service'
5
+ import type { ProgressService } from '../../progress/lib/progressService'
6
+ import { emitDataSyncEvent } from '../events'
7
+ import type { DataSyncAdapter, DataMapping, ExportBatch, ImportBatch } from './adapter'
8
+ import { getDataSyncAdapter } from './adapter-registry'
9
+ import type { SyncRunService } from './sync-run-service'
10
+
11
+ type SyncScope = {
12
+ organizationId: string
13
+ tenantId: string
14
+ userId?: string | null
15
+ }
16
+
17
+ type EngineDeps = {
18
+ em: EntityManager
19
+ syncRunService: SyncRunService
20
+ integrationCredentialsService: CredentialsService
21
+ integrationLogService: IntegrationLogService
22
+ progressService: ProgressService
23
+ }
24
+
25
+ function resolveProviderKey(integrationId: string): string {
26
+ return getIntegration(integrationId)?.providerKey ?? integrationId
27
+ }
28
+
29
+ function applyImportCounters(batch: ImportBatch): Pick<Required<SyncCounterDelta>, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount'> {
30
+ let createdCount = 0
31
+ let updatedCount = 0
32
+ let skippedCount = 0
33
+ let failedCount = 0
34
+
35
+ for (const item of batch.items) {
36
+ if (item.action === 'create') createdCount += 1
37
+ else if (item.action === 'update') updatedCount += 1
38
+ else skippedCount += 1
39
+ }
40
+
41
+ return { createdCount, updatedCount, skippedCount, failedCount }
42
+ }
43
+
44
+ type SyncCounterDelta = {
45
+ createdCount?: number
46
+ updatedCount?: number
47
+ skippedCount?: number
48
+ failedCount?: number
49
+ processedCount: number
50
+ }
51
+
52
+ function applyExportCounters(batch: ExportBatch): SyncCounterDelta {
53
+ let failedCount = 0
54
+ let skippedCount = 0
55
+ let updatedCount = 0
56
+
57
+ for (const result of batch.results) {
58
+ if (result.status === 'error') failedCount += 1
59
+ else if (result.status === 'skipped') skippedCount += 1
60
+ else updatedCount += 1
61
+ }
62
+
63
+ return {
64
+ failedCount,
65
+ skippedCount,
66
+ updatedCount,
67
+ processedCount: batch.results.length,
68
+ }
69
+ }
70
+
71
+ export function createSyncEngine(deps: EngineDeps) {
72
+ const { syncRunService, integrationCredentialsService, integrationLogService, progressService } = deps
73
+
74
+ async function resolveMapping(adapter: DataSyncAdapter, entityType: string, scope: SyncScope): Promise<DataMapping> {
75
+ return adapter.getMapping({
76
+ entityType,
77
+ scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
78
+ })
79
+ }
80
+
81
+ async function updateProgress(progressJobId: string | null | undefined, processedCount: number, totalCount: number | null, scope: SyncScope): Promise<void> {
82
+ if (!progressJobId) return
83
+
84
+ await progressService.updateProgress(
85
+ progressJobId,
86
+ {
87
+ processedCount,
88
+ totalCount: totalCount ?? undefined,
89
+ },
90
+ {
91
+ tenantId: scope.tenantId,
92
+ organizationId: scope.organizationId,
93
+ userId: scope.userId,
94
+ },
95
+ )
96
+ }
97
+
98
+ async function finalizeRun(runId: string, status: 'completed' | 'failed' | 'cancelled', scope: SyncScope, error?: string): Promise<void> {
99
+ const run = await syncRunService.markStatus(runId, status, scope, error)
100
+ if (!run) return
101
+
102
+ if (run.progressJobId) {
103
+ if (status === 'completed') {
104
+ await progressService.completeJob(
105
+ run.progressJobId,
106
+ {
107
+ resultSummary: {
108
+ createdCount: run.createdCount,
109
+ updatedCount: run.updatedCount,
110
+ skippedCount: run.skippedCount,
111
+ failedCount: run.failedCount,
112
+ batchesCompleted: run.batchesCompleted,
113
+ },
114
+ },
115
+ {
116
+ tenantId: scope.tenantId,
117
+ organizationId: scope.organizationId,
118
+ userId: scope.userId,
119
+ },
120
+ )
121
+ } else if (status === 'failed') {
122
+ await progressService.failJob(
123
+ run.progressJobId,
124
+ {
125
+ errorMessage: error ?? 'Sync run failed',
126
+ },
127
+ {
128
+ tenantId: scope.tenantId,
129
+ organizationId: scope.organizationId,
130
+ userId: scope.userId,
131
+ },
132
+ )
133
+ }
134
+ }
135
+
136
+ if (status === 'completed') {
137
+ await emitDataSyncEvent('data_sync.run.completed', {
138
+ runId,
139
+ integrationId: run.integrationId,
140
+ entityType: run.entityType,
141
+ direction: run.direction,
142
+ tenantId: scope.tenantId,
143
+ organizationId: scope.organizationId,
144
+ })
145
+ return
146
+ }
147
+
148
+ if (status === 'cancelled') {
149
+ await emitDataSyncEvent('data_sync.run.cancelled', {
150
+ runId,
151
+ integrationId: run.integrationId,
152
+ entityType: run.entityType,
153
+ direction: run.direction,
154
+ tenantId: scope.tenantId,
155
+ organizationId: scope.organizationId,
156
+ })
157
+ return
158
+ }
159
+
160
+ await emitDataSyncEvent('data_sync.run.failed', {
161
+ runId,
162
+ integrationId: run.integrationId,
163
+ entityType: run.entityType,
164
+ direction: run.direction,
165
+ error: error ?? null,
166
+ tenantId: scope.tenantId,
167
+ organizationId: scope.organizationId,
168
+ })
169
+ }
170
+
171
+ return {
172
+ async runImport(runId: string, batchSize: number, scope: SyncScope): Promise<void> {
173
+ const run = await syncRunService.getRun(runId, scope)
174
+ if (!run) throw new Error(`Sync run ${runId} not found`)
175
+
176
+ const providerKey = resolveProviderKey(run.integrationId)
177
+ const adapter = getDataSyncAdapter(providerKey)
178
+ if (!adapter?.streamImport) {
179
+ throw new Error(`No import adapter registered for provider ${providerKey}`)
180
+ }
181
+
182
+ const credentials = await integrationCredentialsService.resolve(run.integrationId, scope)
183
+ if (!credentials) {
184
+ throw new Error(`Integration ${run.integrationId} is missing credentials`)
185
+ }
186
+
187
+ await syncRunService.markStatus(run.id, 'running', scope)
188
+ await emitDataSyncEvent('data_sync.run.started', {
189
+ runId: run.id,
190
+ integrationId: run.integrationId,
191
+ entityType: run.entityType,
192
+ direction: run.direction,
193
+ tenantId: scope.tenantId,
194
+ organizationId: scope.organizationId,
195
+ })
196
+
197
+ if (run.progressJobId) {
198
+ await progressService.startJob(run.progressJobId, {
199
+ tenantId: scope.tenantId,
200
+ organizationId: scope.organizationId,
201
+ userId: scope.userId,
202
+ })
203
+ }
204
+
205
+ const mapping = await resolveMapping(adapter, run.entityType, scope)
206
+ let processedCount = 0
207
+ let totalCount: number | null = null
208
+
209
+ try {
210
+ for await (const batch of adapter.streamImport({
211
+ entityType: run.entityType,
212
+ cursor: run.cursor ?? undefined,
213
+ batchSize,
214
+ credentials,
215
+ mapping,
216
+ scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
217
+ })) {
218
+ if (run.progressJobId && await progressService.isCancellationRequested(run.progressJobId)) {
219
+ await finalizeRun(run.id, 'cancelled', scope)
220
+ return
221
+ }
222
+
223
+ const delta = applyImportCounters(batch)
224
+ processedCount += batch.items.length
225
+ totalCount = batch.totalEstimate ?? totalCount
226
+
227
+ await syncRunService.updateCounts(
228
+ run.id,
229
+ {
230
+ ...delta,
231
+ batchesCompleted: 1,
232
+ },
233
+ scope,
234
+ )
235
+ await syncRunService.updateCursor(run.id, batch.cursor, scope)
236
+
237
+ await updateProgress(run.progressJobId, processedCount, totalCount, scope)
238
+
239
+ await integrationLogService.write(
240
+ {
241
+ integrationId: run.integrationId,
242
+ runId: run.id,
243
+ level: 'info',
244
+ message: `Processed import batch ${batch.batchIndex}`,
245
+ payload: {
246
+ processedCount,
247
+ batchSize: batch.items.length,
248
+ cursor: batch.cursor,
249
+ },
250
+ },
251
+ scope,
252
+ )
253
+ }
254
+ } catch (error) {
255
+ const message = error instanceof Error ? error.message : 'Sync import failed'
256
+ await integrationLogService.write(
257
+ {
258
+ integrationId: run.integrationId,
259
+ runId: run.id,
260
+ level: 'error',
261
+ message,
262
+ },
263
+ scope,
264
+ )
265
+ await finalizeRun(run.id, 'failed', scope, message)
266
+ return
267
+ }
268
+
269
+ await finalizeRun(run.id, 'completed', scope)
270
+ },
271
+
272
+ async runExport(runId: string, batchSize: number, scope: SyncScope): Promise<void> {
273
+ const run = await syncRunService.getRun(runId, scope)
274
+ if (!run) throw new Error(`Sync run ${runId} not found`)
275
+
276
+ const providerKey = resolveProviderKey(run.integrationId)
277
+ const adapter = getDataSyncAdapter(providerKey)
278
+ if (!adapter?.streamExport) {
279
+ throw new Error(`No export adapter registered for provider ${providerKey}`)
280
+ }
281
+
282
+ const credentials = await integrationCredentialsService.resolve(run.integrationId, scope)
283
+ if (!credentials) {
284
+ throw new Error(`Integration ${run.integrationId} is missing credentials`)
285
+ }
286
+
287
+ await syncRunService.markStatus(run.id, 'running', scope)
288
+ await emitDataSyncEvent('data_sync.run.started', {
289
+ runId: run.id,
290
+ integrationId: run.integrationId,
291
+ entityType: run.entityType,
292
+ direction: run.direction,
293
+ tenantId: scope.tenantId,
294
+ organizationId: scope.organizationId,
295
+ })
296
+
297
+ if (run.progressJobId) {
298
+ await progressService.startJob(run.progressJobId, {
299
+ tenantId: scope.tenantId,
300
+ organizationId: scope.organizationId,
301
+ userId: scope.userId,
302
+ })
303
+ }
304
+
305
+ const mapping = await resolveMapping(adapter, run.entityType, scope)
306
+ let processedCount = 0
307
+
308
+ try {
309
+ for await (const batch of adapter.streamExport({
310
+ entityType: run.entityType,
311
+ cursor: run.cursor ?? undefined,
312
+ batchSize,
313
+ credentials,
314
+ mapping,
315
+ scope: { organizationId: scope.organizationId, tenantId: scope.tenantId },
316
+ })) {
317
+ if (run.progressJobId && await progressService.isCancellationRequested(run.progressJobId)) {
318
+ await finalizeRun(run.id, 'cancelled', scope)
319
+ return
320
+ }
321
+
322
+ const delta = applyExportCounters(batch)
323
+ processedCount += delta.processedCount
324
+
325
+ await syncRunService.updateCounts(
326
+ run.id,
327
+ {
328
+ createdCount: 0,
329
+ updatedCount: delta.updatedCount,
330
+ skippedCount: delta.skippedCount,
331
+ failedCount: delta.failedCount,
332
+ batchesCompleted: 1,
333
+ },
334
+ scope,
335
+ )
336
+
337
+ await syncRunService.updateCursor(run.id, batch.cursor, scope)
338
+ await updateProgress(run.progressJobId, processedCount, null, scope)
339
+
340
+ await integrationLogService.write(
341
+ {
342
+ integrationId: run.integrationId,
343
+ runId: run.id,
344
+ level: 'info',
345
+ message: `Processed export batch ${batch.batchIndex}`,
346
+ payload: {
347
+ processedCount,
348
+ batchSize: batch.results.length,
349
+ cursor: batch.cursor,
350
+ },
351
+ },
352
+ scope,
353
+ )
354
+ }
355
+ } catch (error) {
356
+ const message = error instanceof Error ? error.message : 'Sync export failed'
357
+ await integrationLogService.write(
358
+ {
359
+ integrationId: run.integrationId,
360
+ runId: run.id,
361
+ level: 'error',
362
+ message,
363
+ },
364
+ scope,
365
+ )
366
+ await finalizeRun(run.id, 'failed', scope, message)
367
+ return
368
+ }
369
+
370
+ await finalizeRun(run.id, 'completed', scope)
371
+ },
372
+ }
373
+ }
374
+
375
+ export type SyncEngine = ReturnType<typeof createSyncEngine>
@@ -0,0 +1,187 @@
1
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
+ import { findAndCountWithDecryption, findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { SyncCursor, SyncRun } from '../data/entities'
4
+
5
+ type SyncScope = {
6
+ organizationId: string
7
+ tenantId: string
8
+ }
9
+
10
+ export function createSyncRunService(em: EntityManager) {
11
+ return {
12
+ async createRun(input: {
13
+ integrationId: string
14
+ entityType: string
15
+ direction: 'import' | 'export'
16
+ cursor?: string | null
17
+ triggeredBy?: string | null
18
+ progressJobId?: string | null
19
+ jobId?: string | null
20
+ }, scope: SyncScope): Promise<SyncRun> {
21
+ const row = em.create(SyncRun, {
22
+ integrationId: input.integrationId,
23
+ entityType: input.entityType,
24
+ direction: input.direction,
25
+ status: 'pending',
26
+ cursor: input.cursor,
27
+ initialCursor: input.cursor,
28
+ triggeredBy: input.triggeredBy,
29
+ progressJobId: input.progressJobId,
30
+ jobId: input.jobId,
31
+ organizationId: scope.organizationId,
32
+ tenantId: scope.tenantId,
33
+ })
34
+
35
+ await em.persistAndFlush(row)
36
+ return row
37
+ },
38
+
39
+ async getRun(runId: string, scope: SyncScope): Promise<SyncRun | null> {
40
+ return findOneWithDecryption(
41
+ em,
42
+ SyncRun,
43
+ {
44
+ id: runId,
45
+ organizationId: scope.organizationId,
46
+ tenantId: scope.tenantId,
47
+ deletedAt: null,
48
+ },
49
+ undefined,
50
+ scope,
51
+ )
52
+ },
53
+
54
+ async listRuns(query: {
55
+ integrationId?: string
56
+ entityType?: string
57
+ direction?: 'import' | 'export'
58
+ status?: string
59
+ page: number
60
+ pageSize: number
61
+ }, scope: SyncScope): Promise<{ items: SyncRun[]; total: number }> {
62
+ const where: FilterQuery<SyncRun> = {
63
+ organizationId: scope.organizationId,
64
+ tenantId: scope.tenantId,
65
+ deletedAt: null,
66
+ }
67
+
68
+ if (query.integrationId) where.integrationId = query.integrationId
69
+ if (query.entityType) where.entityType = query.entityType
70
+ if (query.direction) where.direction = query.direction
71
+ if (query.status) where.status = query.status as SyncRun['status']
72
+
73
+ const [items, total] = await findAndCountWithDecryption(
74
+ em,
75
+ SyncRun,
76
+ where,
77
+ {
78
+ orderBy: { createdAt: 'DESC' },
79
+ limit: query.pageSize,
80
+ offset: (query.page - 1) * query.pageSize,
81
+ },
82
+ scope,
83
+ )
84
+
85
+ return { items, total }
86
+ },
87
+
88
+ async markStatus(runId: string, status: SyncRun['status'], scope: SyncScope, error?: string): Promise<SyncRun | null> {
89
+ const row = await this.getRun(runId, scope)
90
+ if (!row) return null
91
+ row.status = status
92
+ if (error !== undefined) row.lastError = error
93
+ await em.flush()
94
+ return row
95
+ },
96
+
97
+ async updateCounts(
98
+ runId: string,
99
+ delta: Partial<Pick<SyncRun, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount' | 'batchesCompleted'>>,
100
+ scope: SyncScope,
101
+ ): Promise<SyncRun | null> {
102
+ const row = await this.getRun(runId, scope)
103
+ if (!row) return null
104
+
105
+ row.createdCount += delta.createdCount ?? 0
106
+ row.updatedCount += delta.updatedCount ?? 0
107
+ row.skippedCount += delta.skippedCount ?? 0
108
+ row.failedCount += delta.failedCount ?? 0
109
+ row.batchesCompleted += delta.batchesCompleted ?? 0
110
+ await em.flush()
111
+ return row
112
+ },
113
+
114
+ async updateCursor(runId: string, cursor: string, scope: SyncScope): Promise<void> {
115
+ const run = await this.getRun(runId, scope)
116
+ if (!run) return
117
+ run.cursor = cursor
118
+
119
+ const cursorRow = await findOneWithDecryption(
120
+ em,
121
+ SyncCursor,
122
+ {
123
+ integrationId: run.integrationId,
124
+ entityType: run.entityType,
125
+ direction: run.direction,
126
+ organizationId: scope.organizationId,
127
+ tenantId: scope.tenantId,
128
+ },
129
+ undefined,
130
+ scope,
131
+ )
132
+
133
+ if (cursorRow) {
134
+ cursorRow.cursor = cursor
135
+ } else {
136
+ em.create(SyncCursor, {
137
+ integrationId: run.integrationId,
138
+ entityType: run.entityType,
139
+ direction: run.direction,
140
+ cursor,
141
+ organizationId: scope.organizationId,
142
+ tenantId: scope.tenantId,
143
+ })
144
+ }
145
+
146
+ await em.flush()
147
+ },
148
+
149
+ async resolveCursor(integrationId: string, entityType: string, direction: 'import' | 'export', scope: SyncScope): Promise<string | null> {
150
+ const row = await findOneWithDecryption(
151
+ em,
152
+ SyncCursor,
153
+ {
154
+ integrationId,
155
+ entityType,
156
+ direction,
157
+ organizationId: scope.organizationId,
158
+ tenantId: scope.tenantId,
159
+ },
160
+ undefined,
161
+ scope,
162
+ )
163
+ return row?.cursor ?? null
164
+ },
165
+
166
+ async findRunningOverlap(integrationId: string, entityType: string, direction: 'import' | 'export', scope: SyncScope): Promise<SyncRun | null> {
167
+ const [run] = await findWithDecryption(
168
+ em,
169
+ SyncRun,
170
+ {
171
+ integrationId,
172
+ entityType,
173
+ direction,
174
+ status: 'running',
175
+ organizationId: scope.organizationId,
176
+ tenantId: scope.tenantId,
177
+ deletedAt: null,
178
+ },
179
+ { limit: 1 },
180
+ scope,
181
+ )
182
+ return run ?? null
183
+ },
184
+ }
185
+ }
186
+
187
+ export type SyncRunService = ReturnType<typeof createSyncRunService>