@open-mercato/core 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6749.1.6b54c56dfe

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 (191) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/resources_resource/index.js +2 -0
  3. package/dist/generated/entities/resources_resource/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/salesFixtures.js +31 -2
  7. package/dist/helpers/integration/salesFixtures.js.map +2 -2
  8. package/dist/helpers/integration/salesUi.js +5 -0
  9. package/dist/helpers/integration/salesUi.js.map +2 -2
  10. package/dist/modules/auth/api/profile/route.js +8 -2
  11. package/dist/modules/auth/api/profile/route.js.map +2 -2
  12. package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
  13. package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
  14. package/dist/modules/communication_channels/data/enrichers.js +4 -4
  15. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  16. package/dist/modules/customers/analytics.js +1 -0
  17. package/dist/modules/customers/analytics.js.map +2 -2
  18. package/dist/modules/customers/components/detail/DealForm.js +2 -1
  19. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  20. package/dist/modules/customers/components/detail/DealsSection.js +1 -1
  21. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  22. package/dist/modules/customers/data/validators.js +3 -1
  23. package/dist/modules/customers/data/validators.js.map +2 -2
  24. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
  25. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
  26. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
  27. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  28. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
  29. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +2 -2
  30. package/dist/modules/data_sync/lib/queue-policy.js +15 -0
  31. package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
  32. package/dist/modules/data_sync/lib/queue.js +16 -1
  33. package/dist/modules/data_sync/lib/queue.js.map +2 -2
  34. package/dist/modules/data_sync/lib/start-run.js +2 -1
  35. package/dist/modules/data_sync/lib/start-run.js.map +2 -2
  36. package/dist/modules/data_sync/lib/sync-engine.js +35 -2
  37. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
  39. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  40. package/dist/modules/data_sync/workers/sync-export.js +9 -2
  41. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  42. package/dist/modules/data_sync/workers/sync-import.js +9 -2
  43. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  44. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
  45. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
  46. package/dist/modules/dictionaries/api/openapi.js +15 -2
  47. package/dist/modules/dictionaries/api/openapi.js.map +2 -2
  48. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
  49. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
  50. package/dist/modules/dictionaries/data/validators.js +7 -0
  51. package/dist/modules/dictionaries/data/validators.js.map +2 -2
  52. package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
  53. package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
  54. package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
  55. package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
  56. package/dist/modules/entities/api/definitions.js +51 -3
  57. package/dist/modules/entities/api/definitions.js.map +2 -2
  58. package/dist/modules/entities/api/entities.js +21 -2
  59. package/dist/modules/entities/api/entities.js.map +2 -2
  60. package/dist/modules/entities/api/records.js +3 -24
  61. package/dist/modules/entities/api/records.js.map +2 -2
  62. package/dist/modules/entities/api/sidebar-entities.js +34 -12
  63. package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
  64. package/dist/modules/entities/lib/entityAcl.js +46 -0
  65. package/dist/modules/entities/lib/entityAcl.js.map +2 -2
  66. package/dist/modules/messages/api/route.js +2 -5
  67. package/dist/modules/messages/api/route.js.map +2 -2
  68. package/dist/modules/messages/lib/participantScope.js +15 -0
  69. package/dist/modules/messages/lib/participantScope.js.map +7 -0
  70. package/dist/modules/notifications/api/route.js +3 -1
  71. package/dist/modules/notifications/api/route.js.map +2 -2
  72. package/dist/modules/notifications/api/unread-count/route.js +25 -6
  73. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  74. package/dist/modules/notifications/lib/notificationScope.js +26 -0
  75. package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
  76. package/dist/modules/notifications/lib/notificationService.js +7 -3
  77. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  78. package/dist/modules/notifications/lib/routeHelpers.js +16 -2
  79. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  80. package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
  81. package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
  82. package/dist/modules/progress/lib/progressService.js +2 -0
  83. package/dist/modules/progress/lib/progressService.js.map +2 -2
  84. package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
  85. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  86. package/dist/modules/resources/api/resources.js +2 -0
  87. package/dist/modules/resources/api/resources.js.map +2 -2
  88. package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
  89. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  90. package/dist/modules/resources/commands/resources.js +9 -0
  91. package/dist/modules/resources/commands/resources.js.map +2 -2
  92. package/dist/modules/resources/components/detail/dictionaries.js +3 -3
  93. package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
  94. package/dist/modules/resources/data/entities.js +3 -0
  95. package/dist/modules/resources/data/entities.js.map +2 -2
  96. package/dist/modules/resources/data/validators.js +6 -2
  97. package/dist/modules/resources/data/validators.js.map +2 -2
  98. package/dist/modules/resources/lib/seeds.js +16 -9
  99. package/dist/modules/resources/lib/seeds.js.map +2 -2
  100. package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
  101. package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
  102. package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
  103. package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
  104. package/dist/modules/sales/commands/documents.js +8 -0
  105. package/dist/modules/sales/commands/documents.js.map +2 -2
  106. package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
  107. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  108. package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
  109. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  110. package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
  111. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
  113. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
  114. package/dist/modules/sales/data/validators.js +5 -1
  115. package/dist/modules/sales/data/validators.js.map +2 -2
  116. package/dist/modules/staff/components/detail/dictionaries.js +3 -3
  117. package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
  118. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
  119. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  120. package/generated/entities/resources_resource/index.ts +1 -0
  121. package/generated/entity-fields-registry.ts +1 -0
  122. package/package.json +7 -7
  123. package/src/helpers/integration/salesFixtures.ts +34 -2
  124. package/src/helpers/integration/salesUi.ts +8 -0
  125. package/src/modules/auth/api/profile/route.ts +8 -2
  126. package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
  127. package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
  128. package/src/modules/communication_channels/data/enrichers.ts +6 -26
  129. package/src/modules/customers/analytics.ts +1 -0
  130. package/src/modules/customers/components/detail/DealForm.tsx +5 -1
  131. package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
  132. package/src/modules/customers/data/validators.ts +5 -1
  133. package/src/modules/dashboards/i18n/de.json +3 -0
  134. package/src/modules/dashboards/i18n/en.json +3 -0
  135. package/src/modules/dashboards/i18n/es.json +3 -0
  136. package/src/modules/dashboards/i18n/pl.json +3 -0
  137. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
  138. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
  139. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
  140. package/src/modules/data_sync/lib/adapter.ts +18 -0
  141. package/src/modules/data_sync/lib/queue-policy.ts +30 -0
  142. package/src/modules/data_sync/lib/queue.ts +19 -1
  143. package/src/modules/data_sync/lib/start-run.ts +2 -1
  144. package/src/modules/data_sync/lib/sync-engine.ts +46 -0
  145. package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
  146. package/src/modules/data_sync/workers/sync-export.ts +8 -1
  147. package/src/modules/data_sync/workers/sync-import.ts +8 -1
  148. package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
  149. package/src/modules/dictionaries/api/openapi.ts +19 -0
  150. package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
  151. package/src/modules/dictionaries/data/validators.ts +14 -0
  152. package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
  153. package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
  154. package/src/modules/entities/api/definitions.ts +51 -3
  155. package/src/modules/entities/api/entities.ts +30 -2
  156. package/src/modules/entities/api/records.ts +3 -27
  157. package/src/modules/entities/api/sidebar-entities.ts +43 -14
  158. package/src/modules/entities/lib/entityAcl.ts +58 -0
  159. package/src/modules/messages/api/route.ts +4 -5
  160. package/src/modules/messages/lib/participantScope.ts +64 -0
  161. package/src/modules/notifications/api/route.ts +2 -0
  162. package/src/modules/notifications/api/unread-count/route.ts +42 -5
  163. package/src/modules/notifications/lib/notificationScope.ts +40 -0
  164. package/src/modules/notifications/lib/notificationService.ts +5 -0
  165. package/src/modules/notifications/lib/routeHelpers.ts +19 -2
  166. package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
  167. package/src/modules/progress/AGENTS.md +11 -1
  168. package/src/modules/progress/lib/progressService.ts +1 -0
  169. package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
  170. package/src/modules/resources/api/resources.ts +2 -0
  171. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
  172. package/src/modules/resources/commands/resources.ts +10 -0
  173. package/src/modules/resources/components/detail/dictionaries.ts +3 -3
  174. package/src/modules/resources/data/entities.ts +3 -0
  175. package/src/modules/resources/data/validators.ts +5 -0
  176. package/src/modules/resources/lib/seeds.ts +16 -9
  177. package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
  178. package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
  179. package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
  180. package/src/modules/sales/commands/documents.ts +8 -0
  181. package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
  182. package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
  183. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
  184. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
  185. package/src/modules/sales/data/validators.ts +7 -1
  186. package/src/modules/sales/i18n/de.json +2 -0
  187. package/src/modules/sales/i18n/en.json +2 -0
  188. package/src/modules/sales/i18n/es.json +2 -0
  189. package/src/modules/sales/i18n/pl.json +2 -0
  190. package/src/modules/staff/components/detail/dictionaries.ts +3 -3
  191. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +12 -5
@@ -1,28 +1,55 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import { ProgressJob } from '../data/entities'
2
+ import { raw } from '@mikro-orm/core'
3
+ import type { EntityData, FilterQuery } from '@mikro-orm/core'
4
+ import { ProgressJob, type ProgressJobStatus } from '../data/entities'
3
5
  import type { ProgressService, ProgressServiceContext } from './progressService'
4
- import { calculateEta, calculateProgressPercent, STALE_JOB_TIMEOUT_SECONDS } from './progressService'
6
+ import {
7
+ calculateEta,
8
+ calculateProgressPercent,
9
+ HEARTBEAT_INTERVAL_MS,
10
+ STALE_JOB_TIMEOUT_SECONDS,
11
+ STALE_PENDING_TIMEOUT_SECONDS,
12
+ } from './progressService'
5
13
  import { PROGRESS_EVENTS } from './events'
6
14
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
15
 
8
16
  const DEFAULT_BROADCAST_MIN_INTERVAL_MS = 250
9
17
 
10
- // Minimum elapsed time between coalesced `progress.job.updated` flush+broadcasts for a
18
+ // Minimum elapsed time between coalesced `progress.job.updated` broadcasts for a
11
19
  // single job. Bulk workers call updateProgress/incrementProgress once per record, and
12
20
  // every emit of the `clientBroadcast: true` event pays a serialized pg_notify roundtrip
13
21
  // plus a tenant-wide SSE fan-out. Setting the knob to 0 restores per-record emission.
22
+ // Persistence is throttled separately: heartbeats hit the database at least every
23
+ // HEARTBEAT_INTERVAL_MS regardless of this knob, so a high broadcast interval can
24
+ // never starve the stale-job sweep of heartbeats.
14
25
  function resolveBroadcastMinIntervalMs(): number {
15
- const raw = process.env.OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS
16
- if (raw == null || raw.trim() === '') return DEFAULT_BROADCAST_MIN_INTERVAL_MS
17
- const parsed = Number.parseInt(raw, 10)
26
+ const rawValue = process.env.OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS
27
+ if (rawValue == null || rawValue.trim() === '') return DEFAULT_BROADCAST_MIN_INTERVAL_MS
28
+ const parsed = Number.parseInt(rawValue, 10)
18
29
  if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_BROADCAST_MIN_INTERVAL_MS
19
30
  return parsed
20
31
  }
21
32
 
33
+ // Statuses a given transition is allowed to move FROM. Every write below is a
34
+ // compare-and-swap (`nativeUpdate` guarded on status), so concurrent writers in other
35
+ // processes can never resurrect a terminal job or double-apply a transition — the
36
+ // UPDATE that matches zero rows simply lost the race and must not emit events.
37
+ const TERMINAL_STATUSES: readonly ProgressJobStatus[] = ['completed', 'failed', 'cancelled']
38
+ const PROGRESS_WRITABLE_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running']
39
+ // `failed` is restartable/completable so a job wrongly swept as stale (worker alive but
40
+ // slow) or retried by an at-least-once queue converges to its true outcome.
41
+ const START_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'failed']
42
+ const COMPLETE_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running', 'failed']
43
+ const FAIL_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running']
44
+ const CANCEL_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running', 'failed']
45
+
22
46
  type JobUpdateThrottleEntry = {
23
47
  job: ProgressJob
24
48
  lastBroadcastAt: number
49
+ lastPersistedAt: number
25
50
  lastBroadcastPercent: number
51
+ pendingDelta: number
52
+ absoluteCountsPending: boolean
26
53
  }
27
54
 
28
55
  function buildJobPayload(job: ProgressJob): Record<string, unknown> {
@@ -46,10 +73,10 @@ function buildJobPayload(job: ProgressJob): Record<string, unknown> {
46
73
  export function createProgressService(em: EntityManager, eventBus: { emit: (event: string, payload: Record<string, unknown>) => Promise<void> }): ProgressService {
47
74
  const broadcastMinIntervalMs = resolveBroadcastMinIntervalMs()
48
75
  // Per-job coalescing state, scoped to this service instance (request/worker scope).
49
- // The cached managed entity doubles as the in-memory buffer: intermediate updates mutate
50
- // it without flushing, and a single flush+emit at the throttle boundary persists the
51
- // accumulated change. Domain commands fork their own EntityManager, so no other operation
52
- // queries this job on `em` between calls, keeping the buffered changes safe until flush.
76
+ // The cached entity is a DETACHED snapshot (loaded with disableIdentityMap) that doubles
77
+ // as the in-memory buffer: intermediate updates mutate it without touching the shared
78
+ // identity map or UnitOfWork, so an unrelated em.flush() elsewhere can never write the
79
+ // buffered values. Persistence happens exclusively through the CAS nativeUpdate below.
53
80
  const jobUpdateThrottle = new Map<string, JobUpdateThrottleEntry>()
54
81
 
55
82
  function jobScopeFilter(jobId: string, ctx: ProgressServiceContext) {
@@ -60,42 +87,101 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
60
87
  }
61
88
  }
62
89
 
63
- async function loadUpdatableJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob> {
90
+ async function loadFreshJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob | null> {
91
+ return em.findOne(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
92
+ }
93
+
94
+ async function ensureThrottleEntry(jobId: string, ctx: ProgressServiceContext): Promise<JobUpdateThrottleEntry> {
64
95
  const cached = jobUpdateThrottle.get(jobId)
65
- if (cached) return cached.job
66
- return em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx))
96
+ if (cached) return cached
97
+ const job = await em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
98
+ const entry: JobUpdateThrottleEntry = {
99
+ job,
100
+ lastBroadcastAt: 0,
101
+ lastPersistedAt: 0,
102
+ lastBroadcastPercent: job.progressPercent,
103
+ pendingDelta: 0,
104
+ absoluteCountsPending: false,
105
+ }
106
+ jobUpdateThrottle.set(jobId, entry)
107
+ return entry
108
+ }
109
+
110
+ function forgetJobThrottle(jobId: string) {
111
+ jobUpdateThrottle.delete(jobId)
67
112
  }
68
113
 
69
- async function commitJobUpdate(job: ProgressJob, ctx: ProgressServiceContext): Promise<ProgressJob> {
114
+ function buildBufferedCountData(entry: JobUpdateThrottleEntry): EntityData<ProgressJob> {
115
+ if (entry.absoluteCountsPending || !Number.isSafeInteger(entry.pendingDelta)) {
116
+ return { processedCount: entry.job.processedCount }
117
+ }
118
+ if (entry.pendingDelta !== 0) {
119
+ const processedCount = raw(`processed_count + ${entry.pendingDelta}`)
120
+ const totalCount = entry.job.totalCount
121
+ if (Number.isSafeInteger(totalCount) && totalCount != null && totalCount > 0) {
122
+ return {
123
+ processedCount,
124
+ progressPercent: raw(
125
+ `least(100, round(((processed_count + ${entry.pendingDelta})::numeric / ${totalCount}) * 100))`,
126
+ ),
127
+ }
128
+ }
129
+ return { processedCount }
130
+ }
131
+ return {}
132
+ }
133
+
134
+ async function persistAndMaybeBroadcast(entry: JobUpdateThrottleEntry, ctx: ProgressServiceContext): Promise<ProgressJob> {
135
+ const job = entry.job
70
136
  const now = Date.now()
71
- const cached = jobUpdateThrottle.get(job.id)
72
137
  const shouldBroadcast =
73
138
  broadcastMinIntervalMs <= 0 ||
74
- cached == null ||
75
- now - cached.lastBroadcastAt >= broadcastMinIntervalMs ||
76
- Math.abs(job.progressPercent - cached.lastBroadcastPercent) >= 1
139
+ now - entry.lastBroadcastAt >= broadcastMinIntervalMs ||
140
+ Math.abs(job.progressPercent - entry.lastBroadcastPercent) >= 1
141
+ const shouldPersist = shouldBroadcast || now - entry.lastPersistedAt >= HEARTBEAT_INTERVAL_MS
142
+
143
+ if (!shouldPersist) return job
144
+
145
+ const usesAtomicIncrement =
146
+ !entry.absoluteCountsPending && Number.isSafeInteger(entry.pendingDelta) && entry.pendingDelta !== 0
147
+ const data: EntityData<ProgressJob> = {
148
+ progressPercent: job.progressPercent,
149
+ totalCount: job.totalCount ?? null,
150
+ etaSeconds: job.etaSeconds ?? null,
151
+ meta: job.meta ?? null,
152
+ heartbeatAt: job.heartbeatAt ?? new Date(),
153
+ updatedAt: new Date(),
154
+ ...buildBufferedCountData(entry),
155
+ }
156
+ const affected = await em.nativeUpdate(ProgressJob, {
157
+ ...jobScopeFilter(job.id, ctx),
158
+ status: { $in: PROGRESS_WRITABLE_STATUSES as ProgressJobStatus[] },
159
+ } as FilterQuery<ProgressJob>, data)
160
+
161
+ if (affected === 0) {
162
+ // The job reached a terminal state in another process; stop writing to it.
163
+ forgetJobThrottle(job.id)
164
+ const fresh = await loadFreshJob(job.id, ctx)
165
+ return fresh ?? job
166
+ }
167
+
168
+ const persistedJob = usesAtomicIncrement ? (await loadFreshJob(job.id, ctx)) ?? job : job
169
+ entry.job = persistedJob
170
+ entry.pendingDelta = 0
171
+ entry.absoluteCountsPending = false
172
+ entry.lastPersistedAt = now
77
173
 
78
174
  if (shouldBroadcast) {
79
- await em.flush()
80
175
  await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
81
- ...buildJobPayload(job),
176
+ ...buildJobPayload(persistedJob),
82
177
  tenantId: ctx.tenantId,
83
- organizationId: job.organizationId ?? null,
84
- })
85
- jobUpdateThrottle.set(job.id, { job, lastBroadcastAt: now, lastBroadcastPercent: job.progressPercent })
86
- } else {
87
- jobUpdateThrottle.set(job.id, {
88
- job,
89
- lastBroadcastAt: cached.lastBroadcastAt,
90
- lastBroadcastPercent: cached.lastBroadcastPercent,
178
+ organizationId: persistedJob.organizationId ?? null,
91
179
  })
180
+ entry.lastBroadcastAt = now
181
+ entry.lastBroadcastPercent = persistedJob.progressPercent
92
182
  }
93
183
 
94
- return job
95
- }
96
-
97
- function forgetJobThrottle(jobId: string) {
98
- jobUpdateThrottle.delete(jobId)
184
+ return persistedJob
99
185
  }
100
186
 
101
187
  return {
@@ -128,20 +214,36 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
128
214
  },
129
215
 
130
216
  async startJob(jobId, ctx) {
131
- const job = await em.findOneOrFail(ProgressJob, {
132
- id: jobId,
133
- tenantId: ctx.tenantId,
134
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
135
- })
136
- if (job.status === 'cancelled') {
217
+ const job = await em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
218
+ if (job.status === 'running' || job.status === 'cancelled' || job.status === 'completed') {
137
219
  return job
138
220
  }
139
221
 
140
- job.status = 'running'
141
- job.startedAt = new Date()
142
- job.heartbeatAt = new Date()
222
+ const now = new Date()
223
+ const affected = await em.nativeUpdate(ProgressJob, {
224
+ ...jobScopeFilter(jobId, ctx),
225
+ status: { $in: START_FROM_STATUSES as ProgressJobStatus[] },
226
+ } as FilterQuery<ProgressJob>, {
227
+ status: 'running',
228
+ startedAt: now,
229
+ heartbeatAt: now,
230
+ finishedAt: null,
231
+ errorMessage: null,
232
+ errorStack: null,
233
+ updatedAt: now,
234
+ })
235
+
236
+ if (affected === 0) {
237
+ const fresh = await loadFreshJob(jobId, ctx)
238
+ return fresh ?? job
239
+ }
143
240
 
144
- await em.flush()
241
+ job.status = 'running'
242
+ job.startedAt = now
243
+ job.heartbeatAt = now
244
+ job.finishedAt = null
245
+ job.errorMessage = null
246
+ job.errorStack = null
145
247
 
146
248
  await eventBus.emit(PROGRESS_EVENTS.JOB_STARTED, {
147
249
  ...buildJobPayload(job),
@@ -153,14 +255,17 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
153
255
  },
154
256
 
155
257
  async updateProgress(jobId, input, ctx) {
156
- const job = await loadUpdatableJob(jobId, ctx)
157
- if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
258
+ const entry = await ensureThrottleEntry(jobId, ctx)
259
+ const job = entry.job
260
+ if (TERMINAL_STATUSES.includes(job.status)) {
158
261
  forgetJobThrottle(jobId)
159
262
  return job
160
263
  }
161
264
 
162
265
  if (input.processedCount !== undefined) {
163
266
  job.processedCount = input.processedCount
267
+ entry.absoluteCountsPending = true
268
+ entry.pendingDelta = 0
164
269
  }
165
270
  if (input.totalCount !== undefined) {
166
271
  job.totalCount = input.totalCount
@@ -183,17 +288,19 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
183
288
 
184
289
  job.heartbeatAt = new Date()
185
290
 
186
- return commitJobUpdate(job, ctx)
291
+ return persistAndMaybeBroadcast(entry, ctx)
187
292
  },
188
293
 
189
294
  async incrementProgress(jobId, delta, ctx) {
190
- const job = await loadUpdatableJob(jobId, ctx)
191
- if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
295
+ const entry = await ensureThrottleEntry(jobId, ctx)
296
+ const job = entry.job
297
+ if (TERMINAL_STATUSES.includes(job.status)) {
192
298
  forgetJobThrottle(jobId)
193
299
  return job
194
300
  }
195
301
 
196
302
  job.processedCount += delta
303
+ entry.pendingDelta += delta
197
304
  job.heartbeatAt = new Date()
198
305
 
199
306
  if (job.totalCount) {
@@ -203,88 +310,172 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
203
310
  }
204
311
  }
205
312
 
206
- return commitJobUpdate(job, ctx)
313
+ return persistAndMaybeBroadcast(entry, ctx)
207
314
  },
208
315
 
209
316
  async completeJob(jobId, input, ctx) {
210
- const job = await em.findOne(ProgressJob, {
211
- id: jobId,
212
- tenantId: ctx.tenantId,
213
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
214
- })
317
+ const job = await loadFreshJob(jobId, ctx)
215
318
  if (!job) throw new Error(`Job ${jobId} not found`)
216
- if (job.status === 'cancelled') {
319
+ if (job.status === 'cancelled' || job.status === 'completed') {
320
+ forgetJobThrottle(jobId)
217
321
  return job
218
322
  }
219
323
 
220
- job.status = 'completed'
221
- job.finishedAt = new Date()
222
- job.progressPercent = 100
223
- job.etaSeconds = 0
324
+ const entry = jobUpdateThrottle.get(jobId)
325
+ const snapshot = entry?.job ?? job
326
+ const now = new Date()
327
+ const data: EntityData<ProgressJob> = {
328
+ status: 'completed',
329
+ finishedAt: now,
330
+ etaSeconds: 0,
331
+ updatedAt: now,
332
+ ...(input?.resultSummary ? { resultSummary: input.resultSummary } : {}),
333
+ ...(entry
334
+ ? {
335
+ totalCount: snapshot.totalCount ?? null,
336
+ meta: snapshot.meta ?? null,
337
+ ...buildBufferedCountData(entry),
338
+ }
339
+ : {}),
340
+ progressPercent: 100,
341
+ }
342
+
343
+ const affected = await em.nativeUpdate(ProgressJob, {
344
+ ...jobScopeFilter(jobId, ctx),
345
+ status: { $in: COMPLETE_FROM_STATUSES as ProgressJobStatus[] },
346
+ } as FilterQuery<ProgressJob>, data)
347
+ forgetJobThrottle(jobId)
348
+
349
+ if (affected === 0) {
350
+ const fresh = await loadFreshJob(jobId, ctx)
351
+ return fresh ?? job
352
+ }
353
+
354
+ snapshot.status = 'completed'
355
+ snapshot.finishedAt = now
356
+ snapshot.progressPercent = 100
357
+ snapshot.etaSeconds = 0
224
358
  if (input?.resultSummary) {
225
- job.resultSummary = input.resultSummary
359
+ snapshot.resultSummary = input.resultSummary
226
360
  }
227
361
 
228
- await em.flush()
362
+ const persistedJob = (await loadFreshJob(jobId, ctx)) ?? snapshot
229
363
 
230
364
  await eventBus.emit(PROGRESS_EVENTS.JOB_COMPLETED, {
231
- ...buildJobPayload(job),
232
- resultSummary: job.resultSummary,
365
+ ...buildJobPayload(persistedJob),
366
+ resultSummary: persistedJob.resultSummary,
233
367
  tenantId: ctx.tenantId,
234
- organizationId: job.organizationId ?? null,
368
+ organizationId: persistedJob.organizationId ?? null,
235
369
  })
236
370
 
237
- forgetJobThrottle(jobId)
238
- return job
371
+ return persistedJob
239
372
  },
240
373
 
241
374
  async failJob(jobId, input, ctx) {
242
- const job = await em.findOne(ProgressJob, {
243
- id: jobId,
244
- tenantId: ctx.tenantId,
245
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
246
- })
375
+ const job = await loadFreshJob(jobId, ctx)
247
376
  if (!job) throw new Error(`Job ${jobId} not found`)
248
- if (job.status === 'cancelled') {
377
+ if (TERMINAL_STATUSES.includes(job.status)) {
378
+ forgetJobThrottle(jobId)
249
379
  return job
250
380
  }
251
381
 
252
- job.status = 'failed'
253
- job.finishedAt = new Date()
254
- job.errorMessage = input.errorMessage
255
- job.errorStack = input.errorStack
382
+ const entry = jobUpdateThrottle.get(jobId)
383
+ const snapshot = entry?.job ?? job
384
+ const now = new Date()
385
+ const data: EntityData<ProgressJob> = {
386
+ status: 'failed',
387
+ finishedAt: now,
388
+ errorMessage: input.errorMessage,
389
+ errorStack: input.errorStack,
390
+ updatedAt: now,
391
+ ...(entry
392
+ ? {
393
+ totalCount: snapshot.totalCount ?? null,
394
+ meta: snapshot.meta ?? null,
395
+ ...buildBufferedCountData(entry),
396
+ }
397
+ : {}),
398
+ }
256
399
 
257
- await em.flush()
400
+ const affected = await em.nativeUpdate(ProgressJob, {
401
+ ...jobScopeFilter(jobId, ctx),
402
+ status: { $in: FAIL_FROM_STATUSES as ProgressJobStatus[] },
403
+ } as FilterQuery<ProgressJob>, data)
404
+ forgetJobThrottle(jobId)
405
+
406
+ if (affected === 0) {
407
+ const fresh = await loadFreshJob(jobId, ctx)
408
+ return fresh ?? job
409
+ }
410
+
411
+ snapshot.status = 'failed'
412
+ snapshot.finishedAt = now
413
+ snapshot.errorMessage = input.errorMessage
414
+ snapshot.errorStack = input.errorStack
415
+
416
+ const persistedJob = (await loadFreshJob(jobId, ctx)) ?? snapshot
258
417
 
259
418
  await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {
260
- ...buildJobPayload(job),
261
- errorMessage: job.errorMessage,
419
+ ...buildJobPayload(persistedJob),
420
+ errorMessage: persistedJob.errorMessage,
262
421
  tenantId: ctx.tenantId,
263
- organizationId: job.organizationId ?? null,
422
+ organizationId: persistedJob.organizationId ?? null,
264
423
  })
265
424
 
266
- forgetJobThrottle(jobId)
267
- return job
425
+ return persistedJob
268
426
  },
269
427
 
270
428
  async cancelJob(jobId, ctx) {
271
- const job = await em.findOneOrFail(ProgressJob, {
272
- id: jobId,
273
- tenantId: ctx.tenantId,
274
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
429
+ const job = await em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
430
+ if (TERMINAL_STATUSES.includes(job.status)) {
431
+ // The job finished while the user clicked cancel — a benign race, not an error.
432
+ return job
433
+ }
434
+ if (!job.cancellable) {
435
+ throw new Error(`Job ${jobId} is not cancellable`)
436
+ }
437
+
438
+ const now = new Date()
439
+ const cancelledNow = await em.nativeUpdate(ProgressJob, {
440
+ ...jobScopeFilter(jobId, ctx),
275
441
  cancellable: true,
276
- status: { $in: ['pending', 'running'] },
442
+ status: 'pending',
443
+ } as FilterQuery<ProgressJob>, {
444
+ status: 'cancelled',
445
+ cancelRequestedAt: now,
446
+ cancelledByUserId: ctx.userId ?? null,
447
+ finishedAt: now,
448
+ etaSeconds: 0,
449
+ updatedAt: now,
277
450
  })
278
451
 
279
- job.cancelRequestedAt = new Date()
280
- job.cancelledByUserId = ctx.userId
452
+ let requested = 0
453
+ if (cancelledNow === 0) {
454
+ requested = await em.nativeUpdate(ProgressJob, {
455
+ ...jobScopeFilter(jobId, ctx),
456
+ cancellable: true,
457
+ status: 'running',
458
+ } as FilterQuery<ProgressJob>, {
459
+ cancelRequestedAt: now,
460
+ cancelledByUserId: ctx.userId ?? null,
461
+ updatedAt: now,
462
+ })
463
+ }
281
464
 
282
- if (job.status === 'pending') {
283
- job.status = 'cancelled'
284
- job.finishedAt = new Date()
465
+ if (cancelledNow === 0 && requested === 0) {
466
+ // Raced into a terminal state between the read and the CAS.
467
+ const fresh = await loadFreshJob(jobId, ctx)
468
+ return fresh ?? job
285
469
  }
286
470
 
287
- await em.flush()
471
+ forgetJobThrottle(jobId)
472
+ if (cancelledNow > 0) {
473
+ job.status = 'cancelled'
474
+ job.finishedAt = now
475
+ job.etaSeconds = 0
476
+ }
477
+ job.cancelRequestedAt = now
478
+ job.cancelledByUserId = ctx.userId ?? null
288
479
 
289
480
  await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {
290
481
  ...buildJobPayload(job),
@@ -292,45 +483,64 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
292
483
  organizationId: job.organizationId ?? null,
293
484
  })
294
485
 
295
- forgetJobThrottle(jobId)
296
486
  return job
297
487
  },
298
488
 
299
489
  async markCancelled(jobId, ctx) {
300
- const job = await em.findOne(ProgressJob, {
301
- id: jobId,
302
- tenantId: ctx.tenantId,
303
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
304
- })
490
+ const job = await loadFreshJob(jobId, ctx)
305
491
  if (!job) throw new Error(`Job ${jobId} not found`)
306
- if (job.status === 'cancelled') {
492
+ if (job.status === 'cancelled' || job.status === 'completed') {
493
+ forgetJobThrottle(jobId)
307
494
  return job
308
495
  }
309
496
 
310
- job.cancelRequestedAt = job.cancelRequestedAt ?? new Date()
311
- job.cancelledByUserId = ctx.userId
497
+ const now = new Date()
498
+ const cancelRequestedAt = job.cancelRequestedAt ?? now
499
+ const cancelledByUserId = job.cancelledByUserId ?? ctx.userId ?? null
500
+ const finishedAt = job.finishedAt ?? now
501
+
502
+ const affected = await em.nativeUpdate(ProgressJob, {
503
+ ...jobScopeFilter(jobId, ctx),
504
+ status: { $in: CANCEL_FROM_STATUSES as ProgressJobStatus[] },
505
+ } as FilterQuery<ProgressJob>, {
506
+ status: 'cancelled',
507
+ cancelRequestedAt,
508
+ cancelledByUserId,
509
+ finishedAt,
510
+ etaSeconds: 0,
511
+ updatedAt: now,
512
+ })
513
+ forgetJobThrottle(jobId)
514
+
515
+ if (affected === 0) {
516
+ const fresh = await loadFreshJob(jobId, ctx)
517
+ return fresh ?? job
518
+ }
519
+
312
520
  job.status = 'cancelled'
313
- job.finishedAt = job.finishedAt ?? new Date()
521
+ job.cancelRequestedAt = cancelRequestedAt
522
+ job.cancelledByUserId = cancelledByUserId
523
+ job.finishedAt = finishedAt
314
524
  job.etaSeconds = 0
315
525
 
316
- await em.flush()
317
-
318
526
  await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {
319
527
  ...buildJobPayload(job),
320
528
  tenantId: ctx.tenantId,
321
529
  organizationId: job.organizationId ?? null,
322
530
  })
323
531
 
324
- forgetJobThrottle(jobId)
325
532
  return job
326
533
  },
327
534
 
328
535
  async isCancellationRequested(jobId, tenantId, organizationId) {
536
+ // disableIdentityMap forces a fresh read: a managed copy of the job in this
537
+ // EntityManager (loaded by updateProgress) must never mask a cancellation
538
+ // requested from another process.
329
539
  const job = await findOneWithDecryption(em, ProgressJob, {
330
540
  id: jobId,
331
541
  tenantId,
332
542
  ...(organizationId ? { organizationId } : {}),
333
- })
543
+ }, { disableIdentityMap: true })
334
544
  return job?.cancelRequestedAt != null
335
545
  },
336
546
 
@@ -369,12 +579,16 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
369
579
  },
370
580
 
371
581
  async markStaleJobsFailed(tenantId: string, timeoutSeconds = STALE_JOB_TIMEOUT_SECONDS, organizationId?: string | null) {
372
- const cutoff = new Date(Date.now() - timeoutSeconds * 1000)
373
-
374
- const staleJobs = await em.find(ProgressJob, {
582
+ const now = new Date()
583
+ const cutoff = new Date(now.getTime() - timeoutSeconds * 1000)
584
+ const scope = {
375
585
  tenantId,
376
586
  ...(organizationId ? { organizationId } : {}),
377
- status: 'running',
587
+ }
588
+ let failedCount = 0
589
+
590
+ const staleFilter = {
591
+ status: 'running' as ProgressJobStatus,
378
592
  $or: [
379
593
  { heartbeatAt: { $lt: cutoff } },
380
594
  {
@@ -382,12 +596,67 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
382
596
  startedAt: { $lt: cutoff },
383
597
  },
384
598
  ],
385
- })
599
+ }
600
+ const staleRunning = await em.find(ProgressJob, { ...scope, ...staleFilter }, { disableIdentityMap: true })
601
+
602
+ for (const job of staleRunning) {
603
+ const errorMessage = `Job stale: no heartbeat for ${timeoutSeconds} seconds`
604
+ // Per-row CAS (re-checking the staleness condition): exactly one concurrent
605
+ // sweeper wins, and a heartbeat that landed after the SELECT aborts the failure.
606
+ const affected = await em.nativeUpdate(ProgressJob, {
607
+ id: job.id,
608
+ tenantId: job.tenantId,
609
+ ...staleFilter,
610
+ } as FilterQuery<ProgressJob>, {
611
+ status: 'failed',
612
+ finishedAt: now,
613
+ errorMessage,
614
+ updatedAt: now,
615
+ })
616
+ if (affected === 0) continue
617
+
618
+ failedCount += 1
619
+ job.status = 'failed'
620
+ job.finishedAt = now
621
+ job.errorMessage = errorMessage
622
+
623
+ await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {
624
+ ...buildJobPayload(job),
625
+ errorMessage: job.errorMessage,
626
+ tenantId: job.tenantId,
627
+ stale: true,
628
+ organizationId: job.organizationId ?? null,
629
+ })
630
+ }
631
+
632
+ // Jobs stuck in `pending` (enqueue failed, worker died before startJob) have no
633
+ // heartbeat, so they need their own sweep or they stay in the active list forever.
634
+ // A late queue delivery still recovers such a job: startJob transitions failed → running.
635
+ const pendingCutoff = new Date(now.getTime() - STALE_PENDING_TIMEOUT_SECONDS * 1000)
636
+ const stalePendingFilter = {
637
+ status: 'pending' as ProgressJobStatus,
638
+ createdAt: { $lt: pendingCutoff },
639
+ }
640
+ const stalePending = await em.find(ProgressJob, { ...scope, ...stalePendingFilter }, { disableIdentityMap: true })
641
+
642
+ for (const job of stalePending) {
643
+ const errorMessage = `Job stale: never started within ${STALE_PENDING_TIMEOUT_SECONDS} seconds`
644
+ const affected = await em.nativeUpdate(ProgressJob, {
645
+ id: job.id,
646
+ tenantId: job.tenantId,
647
+ ...stalePendingFilter,
648
+ } as FilterQuery<ProgressJob>, {
649
+ status: 'failed',
650
+ finishedAt: now,
651
+ errorMessage,
652
+ updatedAt: now,
653
+ })
654
+ if (affected === 0) continue
386
655
 
387
- for (const job of staleJobs) {
656
+ failedCount += 1
388
657
  job.status = 'failed'
389
- job.finishedAt = new Date()
390
- job.errorMessage = `Job stale: no heartbeat for ${timeoutSeconds} seconds`
658
+ job.finishedAt = now
659
+ job.errorMessage = errorMessage
391
660
 
392
661
  await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {
393
662
  ...buildJobPayload(job),
@@ -398,8 +667,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
398
667
  })
399
668
  }
400
669
 
401
- await em.flush()
402
- return staleJobs.length
670
+ return failedCount
403
671
  },
404
672
  }
405
673
  }