@open-mercato/core 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6744.1.0483b18565

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 (147) 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/communication_channels/data/enrichers.js +4 -4
  11. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  12. package/dist/modules/customers/analytics.js +1 -0
  13. package/dist/modules/customers/analytics.js.map +2 -2
  14. package/dist/modules/customers/components/detail/DealForm.js +2 -1
  15. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  16. package/dist/modules/customers/components/detail/DealsSection.js +1 -1
  17. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  18. package/dist/modules/customers/data/validators.js +3 -1
  19. package/dist/modules/customers/data/validators.js.map +2 -2
  20. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
  21. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
  22. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
  23. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  24. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
  25. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +2 -2
  26. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
  27. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
  28. package/dist/modules/dictionaries/api/openapi.js +15 -2
  29. package/dist/modules/dictionaries/api/openapi.js.map +2 -2
  30. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
  31. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
  32. package/dist/modules/dictionaries/data/validators.js +7 -0
  33. package/dist/modules/dictionaries/data/validators.js.map +2 -2
  34. package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
  35. package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
  36. package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
  37. package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
  38. package/dist/modules/entities/api/definitions.js +51 -3
  39. package/dist/modules/entities/api/definitions.js.map +2 -2
  40. package/dist/modules/entities/api/entities.js +21 -2
  41. package/dist/modules/entities/api/entities.js.map +2 -2
  42. package/dist/modules/entities/api/records.js +3 -24
  43. package/dist/modules/entities/api/records.js.map +2 -2
  44. package/dist/modules/entities/api/sidebar-entities.js +34 -12
  45. package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
  46. package/dist/modules/entities/lib/entityAcl.js +46 -0
  47. package/dist/modules/entities/lib/entityAcl.js.map +2 -2
  48. package/dist/modules/messages/api/route.js +2 -5
  49. package/dist/modules/messages/api/route.js.map +2 -2
  50. package/dist/modules/messages/lib/participantScope.js +15 -0
  51. package/dist/modules/messages/lib/participantScope.js.map +7 -0
  52. package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
  53. package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
  54. package/dist/modules/progress/lib/progressService.js +2 -0
  55. package/dist/modules/progress/lib/progressService.js.map +2 -2
  56. package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
  57. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  58. package/dist/modules/resources/api/resources.js +2 -0
  59. package/dist/modules/resources/api/resources.js.map +2 -2
  60. package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
  61. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  62. package/dist/modules/resources/commands/resources.js +9 -0
  63. package/dist/modules/resources/commands/resources.js.map +2 -2
  64. package/dist/modules/resources/components/detail/dictionaries.js +3 -3
  65. package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
  66. package/dist/modules/resources/data/entities.js +3 -0
  67. package/dist/modules/resources/data/entities.js.map +2 -2
  68. package/dist/modules/resources/data/validators.js +6 -2
  69. package/dist/modules/resources/data/validators.js.map +2 -2
  70. package/dist/modules/resources/lib/seeds.js +16 -9
  71. package/dist/modules/resources/lib/seeds.js.map +2 -2
  72. package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
  73. package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
  74. package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
  75. package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
  76. package/dist/modules/sales/commands/documents.js +8 -0
  77. package/dist/modules/sales/commands/documents.js.map +2 -2
  78. package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
  79. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  80. package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
  81. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  82. package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
  83. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  84. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
  85. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
  86. package/dist/modules/sales/data/validators.js +5 -1
  87. package/dist/modules/sales/data/validators.js.map +2 -2
  88. package/dist/modules/staff/components/detail/dictionaries.js +3 -3
  89. package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
  90. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
  91. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  92. package/generated/entities/resources_resource/index.ts +1 -0
  93. package/generated/entity-fields-registry.ts +1 -0
  94. package/package.json +7 -7
  95. package/src/helpers/integration/salesFixtures.ts +34 -2
  96. package/src/helpers/integration/salesUi.ts +8 -0
  97. package/src/modules/communication_channels/data/enrichers.ts +6 -26
  98. package/src/modules/customers/analytics.ts +1 -0
  99. package/src/modules/customers/components/detail/DealForm.tsx +5 -1
  100. package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
  101. package/src/modules/customers/data/validators.ts +5 -1
  102. package/src/modules/dashboards/i18n/de.json +3 -0
  103. package/src/modules/dashboards/i18n/en.json +3 -0
  104. package/src/modules/dashboards/i18n/es.json +3 -0
  105. package/src/modules/dashboards/i18n/pl.json +3 -0
  106. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
  107. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
  108. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
  109. package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
  110. package/src/modules/dictionaries/api/openapi.ts +19 -0
  111. package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
  112. package/src/modules/dictionaries/data/validators.ts +14 -0
  113. package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
  114. package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
  115. package/src/modules/entities/api/definitions.ts +51 -3
  116. package/src/modules/entities/api/entities.ts +30 -2
  117. package/src/modules/entities/api/records.ts +3 -27
  118. package/src/modules/entities/api/sidebar-entities.ts +43 -14
  119. package/src/modules/entities/lib/entityAcl.ts +58 -0
  120. package/src/modules/messages/api/route.ts +4 -5
  121. package/src/modules/messages/lib/participantScope.ts +64 -0
  122. package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
  123. package/src/modules/progress/AGENTS.md +11 -1
  124. package/src/modules/progress/lib/progressService.ts +1 -0
  125. package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
  126. package/src/modules/resources/api/resources.ts +2 -0
  127. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
  128. package/src/modules/resources/commands/resources.ts +10 -0
  129. package/src/modules/resources/components/detail/dictionaries.ts +3 -3
  130. package/src/modules/resources/data/entities.ts +3 -0
  131. package/src/modules/resources/data/validators.ts +5 -0
  132. package/src/modules/resources/lib/seeds.ts +16 -9
  133. package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
  134. package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
  135. package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
  136. package/src/modules/sales/commands/documents.ts +8 -0
  137. package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
  138. package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
  139. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
  140. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
  141. package/src/modules/sales/data/validators.ts +7 -1
  142. package/src/modules/sales/i18n/de.json +2 -0
  143. package/src/modules/sales/i18n/en.json +2 -0
  144. package/src/modules/sales/i18n/es.json +2 -0
  145. package/src/modules/sales/i18n/pl.json +2 -0
  146. package/src/modules/staff/components/detail/dictionaries.ts +3 -3
  147. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +12 -5
@@ -5,9 +5,11 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { EntityManager } from '@mikro-orm/core'
6
6
  import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
7
7
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
9
+ import { canReadEntityMetadata, getDeclaredCustomEntityRestriction } from '../lib/entityAcl'
8
10
 
9
11
  export const metadata = {
10
- GET: { requireAuth: true },
12
+ GET: { requireAuth: true, requireFeatures: ['entities.records.view'] },
11
13
  }
12
14
 
13
15
  export async function GET(req: Request) {
@@ -17,6 +19,11 @@ export async function GET(req: Request) {
17
19
  const { resolve } = await createRequestContainer()
18
20
  const em = resolve('em') as EntityManager
19
21
  const cache = resolve('cache') as any
22
+ const rbac = resolve('rbacService') as RbacService
23
+ const acl = await rbac.loadAcl(auth.sub ?? '', {
24
+ tenantId: auth.tenantId,
25
+ organizationId: auth.orgId ?? null,
26
+ })
20
27
 
21
28
  const where: any = {
22
29
  isActive: true,
@@ -27,27 +34,49 @@ export async function GET(req: Request) {
27
34
  { $or: [ { tenantId: auth.tenantId ?? undefined as any }, { tenantId: null } ] },
28
35
  ]
29
36
 
30
- // Try cache first to avoid repeated queries on focus refreshes
31
- const cacheKey = `entities:sidebar:${auth.tenantId || 'null'}`
37
+ const cacheKey = `entities:sidebar:v2:${auth.tenantId || 'null'}`
38
+ let candidates: Array<{
39
+ entityId: string
40
+ label: string
41
+ href: string
42
+ accessRestricted: boolean
43
+ }> | null = null
32
44
  try {
33
45
  if (cache) {
34
46
  const cached = await cache.get(cacheKey)
35
- if (cached && Array.isArray(cached.items)) return NextResponse.json(cached)
47
+ if (cached && Array.isArray(cached.candidates)) candidates = cached.candidates
36
48
  }
37
49
  } catch {}
38
50
 
39
- const entities = await em.find(CustomEntity as any, where as any, { orderBy: { label: 'asc' } as any })
40
-
41
- const items = (entities as any[]).map((e) => ({
42
- entityId: e.entityId,
43
- label: e.label,
44
- href: `/backend/entities/user/${encodeURIComponent(e.entityId)}/records`
45
- }))
51
+ if (!candidates) {
52
+ const entities = await em.find(CustomEntity as any, where as any, { orderBy: { label: 'asc' } as any })
53
+ candidates = (entities as any[]).map((entity) => ({
54
+ entityId: entity.entityId,
55
+ label: entity.label,
56
+ href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`,
57
+ accessRestricted: getDeclaredCustomEntityRestriction(entity.entityId)
58
+ ?? entity.accessRestricted === true,
59
+ }))
60
+ try {
61
+ if (cache) {
62
+ await cache.set(cacheKey, { candidates }, { tags: [`nav:entities:${auth.tenantId || 'null'}`] })
63
+ }
64
+ } catch {}
65
+ }
46
66
 
67
+ const items = candidates
68
+ .filter((entity) => canReadEntityMetadata({
69
+ entityId: entity.entityId,
70
+ isCustomEntity: true,
71
+ isRestricted: entity.accessRestricted,
72
+ acl,
73
+ }))
74
+ .map((entity) => ({
75
+ entityId: entity.entityId,
76
+ label: entity.label,
77
+ href: entity.href,
78
+ }))
47
79
  const payload = { items }
48
- try {
49
- if (cache) await cache.set(cacheKey, payload, { tags: [`nav:entities:${auth.tenantId || 'null'}`] })
50
- } catch {}
51
80
  return NextResponse.json(payload)
52
81
  }
53
82
 
@@ -1,6 +1,7 @@
1
1
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
2
  import { hasAllFeatures } from '@open-mercato/shared/security/features'
3
3
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
4
+ import { getModules } from '@open-mercato/shared/lib/i18n/server'
4
5
  import { deriveCustomEntityRecordFeature } from './recordFeatures'
5
6
 
6
7
  export type EntityAclRequirement = {
@@ -65,10 +66,67 @@ const ENTITY_ACL_REQUIREMENTS: Record<string, EntityAclRequirement> = {
65
66
  },
66
67
  }
67
68
 
69
+ let declaredCustomEntityRestrictions: Map<string, boolean> | null = null
70
+
71
+ function loadDeclaredCustomEntityRestrictions(): Map<string, boolean> {
72
+ if (declaredCustomEntityRestrictions === null) {
73
+ try {
74
+ const modules = getModules() as Array<{
75
+ customEntities?: Array<{ id?: string; accessRestricted?: boolean }>
76
+ }>
77
+ const restrictions = new Map<string, boolean>()
78
+ for (const moduleEntry of modules ?? []) {
79
+ for (const spec of moduleEntry.customEntities ?? []) {
80
+ if (spec.id) restrictions.set(spec.id, spec.accessRestricted === true)
81
+ }
82
+ }
83
+ declaredCustomEntityRestrictions = restrictions
84
+ } catch {}
85
+ }
86
+ return declaredCustomEntityRestrictions ?? new Map<string, boolean>()
87
+ }
88
+
89
+ export function getDeclaredCustomEntityRestriction(entityId: string): boolean | undefined {
90
+ return loadDeclaredCustomEntityRestrictions().get(entityId)
91
+ }
92
+
93
+ export function isDeclaredCustomEntity(entityId: string): boolean {
94
+ return loadDeclaredCustomEntityRestrictions().has(entityId)
95
+ }
96
+
68
97
  export function resolveEntityAclRequirement(entityId: string): EntityAclRequirement | null {
69
98
  return ENTITY_ACL_REQUIREMENTS[entityId] ?? null
70
99
  }
71
100
 
101
+ export function canReadAllEntityMetadata(acl: {
102
+ isSuperAdmin?: boolean
103
+ features?: readonly string[]
104
+ }): boolean {
105
+ return Boolean(acl.isSuperAdmin) || hasAllFeatures(acl.features, ['entities.definitions.view'])
106
+ }
107
+
108
+ export function canReadEntityMetadata(args: {
109
+ entityId: string
110
+ isCustomEntity: boolean
111
+ isRestricted?: boolean
112
+ acl: { isSuperAdmin?: boolean; features?: readonly string[] }
113
+ }): boolean {
114
+ if (args.acl.isSuperAdmin) return true
115
+
116
+ const requirement = resolveEntityAclRequirement(args.entityId)
117
+ if (requirement?.platformOnly) return false
118
+ if (canReadAllEntityMetadata(args.acl)) return true
119
+ if (args.isCustomEntity) {
120
+ const requiredFeatures = ['entities.records.view']
121
+ if (args.isRestricted) {
122
+ requiredFeatures.push(deriveCustomEntityRecordFeature(args.entityId, 'view'))
123
+ }
124
+ return hasAllFeatures(args.acl.features, requiredFeatures)
125
+ }
126
+ if (!requirement) return false
127
+ return hasAllFeatures(args.acl.features, requirement.view)
128
+ }
129
+
72
130
  type EntityAclActor = {
73
131
  sub?: string | null
74
132
  tenantId?: string | null
@@ -13,6 +13,7 @@ import { getMessageType } from '../lib/message-types-registry'
13
13
  import { validateMessageObjectsForType } from '../lib/object-validation'
14
14
  import { attachOperationMetadataHeader } from '../lib/operationMetadata'
15
15
  import { canUseMessageEmailFeature, resolveMessageContext } from '../lib/routeHelpers'
16
+ import { applyMessageParticipantScope } from '../lib/participantScope'
16
17
  import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from './guards'
17
18
  import { findMessageIdsBySearchTokens } from '../lib/searchLookup'
18
19
  import { MessageCommandExecuteResult } from '../commands/shared'
@@ -120,11 +121,9 @@ export async function GET(req: Request) {
120
121
  joinRecipient()
121
122
  break
122
123
  case 'all':
123
- joinRecipient()
124
- q = q.where((eb: any) => eb.or([
125
- eb('m.sender_user_id', '=', scope.userId),
126
- eb('r.message_id', 'is not', null),
127
- ]))
124
+ // Sender-OR-recipient participant scope shared with the
125
+ // communication_channels message enricher — see participantScope.ts (#4133).
126
+ q = applyMessageParticipantScope(q, scope.userId)
128
127
  break
129
128
  default: {
130
129
  const unsupportedFolder: never = input.folder
@@ -0,0 +1,64 @@
1
+ import type { SelectQueryBuilder } from 'kysely'
2
+
3
+ /**
4
+ * Minimal Kysely schema contract the message participant-scope predicate depends
5
+ * on. Both the messages list route (`api/route.ts`, `all` folder) and the
6
+ * `communication_channels.message-channel` response enricher build their
7
+ * sender-OR-recipient access filter from {@link applyMessageParticipantScope},
8
+ * so a column rename (`sender_user_id`, `recipient_user_id`, `deleted_at`) or a
9
+ * change to the recipient-visibility rules updates both call sites at once
10
+ * instead of silently desyncing the enricher's security boundary from the list
11
+ * route (#4133, follow-up to #4099).
12
+ */
13
+ export type MessagesParticipantScopeDatabase = {
14
+ messages: {
15
+ id: string
16
+ tenant_id: string
17
+ organization_id: string | null
18
+ sender_user_id: string
19
+ deleted_at: Date | null
20
+ }
21
+ message_recipients: {
22
+ message_id: string
23
+ recipient_user_id: string
24
+ deleted_at: Date | null
25
+ }
26
+ }
27
+
28
+ type MessagesTable = MessagesParticipantScopeDatabase['messages']
29
+ type MessageRecipientsTable = MessagesParticipantScopeDatabase['message_recipients']
30
+
31
+ type MessagesFrom = MessagesParticipantScopeDatabase & { m: MessagesTable }
32
+ type MessagesJoinedFrom = MessagesFrom & { r: MessageRecipientsTable }
33
+
34
+ /**
35
+ * Apply the shared message participant-scope predicate to a query already built
36
+ * from `messages as m`. A message is visible to `userId` when they are the
37
+ * sender OR a non-deleted recipient. The recipient soft-delete rule
38
+ * (`r.deleted_at is null`) lives inside the recipient join, so it is part of the
39
+ * single source of truth — the recipient-visibility boundary cannot drift
40
+ * between the list route and the enricher.
41
+ *
42
+ * Message-level tenant / organization / soft-delete scoping stays with the
43
+ * caller (both call sites already apply it uniformly to every query), but the
44
+ * shared {@link MessagesParticipantScopeDatabase} type keeps those column names
45
+ * coupled at compile time as well.
46
+ */
47
+ export function applyMessageParticipantScope<O>(
48
+ query: SelectQueryBuilder<MessagesFrom, 'm', O>,
49
+ userId: string,
50
+ ): SelectQueryBuilder<MessagesJoinedFrom, 'm' | 'r', O> {
51
+ return query
52
+ .leftJoin('message_recipients as r', (join) =>
53
+ join
54
+ .onRef('m.id', '=', 'r.message_id')
55
+ .on('r.recipient_user_id', '=', userId)
56
+ .on('r.deleted_at', 'is', null),
57
+ )
58
+ .where((eb) =>
59
+ eb.or([
60
+ eb('m.sender_user_id', '=', userId),
61
+ eb('r.message_id', 'is not', null),
62
+ ]),
63
+ ) as unknown as SelectQueryBuilder<MessagesJoinedFrom, 'm' | 'r', O>
64
+ }
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
4
+ import { fetchAllDictionaryEntries } from '@open-mercato/core/modules/dictionaries/lib/fetchAllEntries'
4
5
  import {
5
6
  resolveUnavailabilityReasonDictionary,
6
7
  type UnavailabilityReasonSubjectType,
@@ -45,10 +46,9 @@ export async function loadUnavailabilityReasonEntries(
45
46
  const dictionary = resolveUnavailabilityReasonDictionary(subjectType)
46
47
  const resolved = await ensureDictionary(dictionary.key, dictionary.name)
47
48
  if (!resolved) return []
48
- const entriesCall = await apiCall<{ items?: Record<string, unknown>[] }>(`/api/dictionaries/${resolved.id}/entries`)
49
+ const entriesCall = await fetchAllDictionaryEntries(resolved.id)
49
50
  if (!entriesCall.ok) return []
50
- const items = Array.isArray(entriesCall.result?.items) ? entriesCall.result?.items ?? [] : []
51
- return items
51
+ return entriesCall.items
52
52
  .map((entry) => {
53
53
  if (!entry || typeof entry !== 'object') return null
54
54
  const record = entry as Record<string, unknown>
@@ -86,7 +86,17 @@ Use stable, grep-friendly ids:
86
86
 
87
87
  | Variable | Effect | Default |
88
88
  |----------|--------|---------|
89
- | `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS` | Coalesces intermediate `progress.job.updated` flush+broadcasts per job: the service flushes and emits only when this many ms elapsed since the last broadcast **or** `progressPercent` advanced by ≥1; sub-threshold updates buffer in memory. Keeps bulk workers from firing one serialized `pg_notify` roundtrip + tenant-wide SSE fan-out per record. Terminal events (`created`/`started`/`completed`/`failed`/`cancelled`) are never throttled, so `ProgressTopBar` still converges and the 60s `STALE_JOB_TIMEOUT_SECONDS` heartbeat window is never exceeded. Set to `0` to restore per-record emission (tests/debugging). | `250` |
89
+ | `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS` | Coalesces intermediate `progress.job.updated` broadcasts per job: the service emits only when this many ms elapsed since the last broadcast **or** `progressPercent` advanced by ≥1; sub-threshold updates buffer in memory. Keeps bulk workers from firing one serialized `pg_notify` roundtrip + tenant-wide SSE fan-out per record. Persistence is throttled independently: heartbeats reach the database at least every `HEARTBEAT_INTERVAL_MS` (5s) regardless of this knob, so no value can starve the 60s `STALE_JOB_TIMEOUT_SECONDS` sweep. Terminal events (`created`/`started`/`completed`/`failed`/`cancelled`) are never throttled. Set to `0` to restore per-record emission (tests/debugging). | `250` |
90
+
91
+ ## Concurrency Semantics (multi-instance safe)
92
+
93
+ `progressService` is safe to run across many app/worker instances. Do not reintroduce read-modify-write transitions:
94
+
95
+ - **Every status transition is a status-guarded `nativeUpdate` (CAS).** An update that matches zero rows lost the race — it MUST NOT emit events or overwrite the row. Allowed transitions: start from `pending|failed` (an already-running start is an idempotent no-op; `failed` allows queue retries and recovery from a wrong stale sweep), complete from `pending|running|failed`, fail from `pending|running`, cancel from `pending|running|failed`.
96
+ - **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it.
97
+ - **`incrementProgress` deltas persist as atomic SQL increments** (`processed_count + n`), and the service reloads the database winner before returning or emitting, so concurrent writers never lose or report stale counts.
98
+ - **Update-path reads use `disableIdentityMap: true`** — `isCancellationRequested` and lifecycle reads must always see fresh cross-process state, never a stale managed entity.
99
+ - **The stale sweep (`markStaleJobsFailed`) re-checks staleness per row inside the CAS**, so concurrent sweepers emit exactly one `JOB_FAILED` per job, and it also fails `pending` jobs that never started within `STALE_PENDING_TIMEOUT_SECONDS` (a late queue delivery recovers them via `startJob`'s `failed → running` transition).
90
100
 
91
101
  ## Cross-References
92
102
 
@@ -25,6 +25,7 @@ export interface ProgressService {
25
25
 
26
26
  export const HEARTBEAT_INTERVAL_MS = 5000
27
27
  export const STALE_JOB_TIMEOUT_SECONDS = 60
28
+ export const STALE_PENDING_TIMEOUT_SECONDS = 900
28
29
 
29
30
  export function calculateEta(
30
31
  processedCount: number,