@open-mercato/shared 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c

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.
@@ -65,17 +65,54 @@ export function getGlobalEventBus(): GlobalEventBus | null {
65
65
  // Event Registry for Validation
66
66
  // =============================================================================
67
67
 
68
- // Global set of all declared event IDs for runtime validation
69
- const allDeclaredEventIds = new Set<string>()
68
+ type EventRegistryState = {
69
+ declaredEventIds: Set<string>
70
+ declaredEvents: EventDefinition[]
71
+ registeredEventConfigs: EventModuleConfig[] | null
72
+ }
73
+
74
+ const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
75
+
76
+ const fallbackEventRegistryState: EventRegistryState = {
77
+ declaredEventIds: new Set<string>(),
78
+ declaredEvents: [],
79
+ registeredEventConfigs: null,
80
+ }
81
+
82
+ function isEventRegistryState(value: unknown): value is EventRegistryState {
83
+ if (!value || typeof value !== 'object') return false
84
+ const candidate = value as Partial<EventRegistryState>
85
+ return candidate.declaredEventIds instanceof Set
86
+ && Array.isArray(candidate.declaredEvents)
87
+ && (candidate.registeredEventConfigs === null || Array.isArray(candidate.registeredEventConfigs))
88
+ }
70
89
 
71
- // Global registry of all declared events with their full definitions
72
- const allDeclaredEvents: EventDefinition[] = []
90
+ function getEventRegistryState(): EventRegistryState {
91
+ try {
92
+ const globalScope = globalThis as Record<string, unknown>
93
+ const existing = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
94
+ if (isEventRegistryState(existing)) return existing
95
+ globalScope[GLOBAL_EVENT_REGISTRY_KEY] = fallbackEventRegistryState
96
+ return fallbackEventRegistryState
97
+ } catch {
98
+ // Restricted runtimes may deny global access. Keep the previous
99
+ // module-local behavior as a safe fallback.
100
+ return fallbackEventRegistryState
101
+ }
102
+ }
73
103
 
74
104
  function addDeclaredEvent(event: EventDefinition): void {
75
- allDeclaredEventIds.add(event.id)
76
- // Avoid duplicates if createModuleEvents/registerEventModuleConfigs is called multiple times (e.g., HMR)
77
- if (!allDeclaredEvents.find(e => e.id === event.id)) {
78
- allDeclaredEvents.push(event)
105
+ const state = getEventRegistryState()
106
+ state.declaredEventIds.add(event.id)
107
+ const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.id)
108
+ if (existingIndex < 0) {
109
+ state.declaredEvents.push(event)
110
+ return
111
+ }
112
+ // Refresh a module's own definition in place during HMR without allowing a
113
+ // duplicate declaration from another module to take over the event id.
114
+ if (state.declaredEvents[existingIndex]?.module === event.module) {
115
+ state.declaredEvents[existingIndex] = event
79
116
  }
80
117
  }
81
118
 
@@ -84,7 +121,7 @@ function addDeclaredEvent(event: EventDefinition): void {
84
121
  * Used for runtime validation to ensure only declared events are emitted.
85
122
  */
86
123
  export function isEventDeclared(eventId: string): boolean {
87
- return allDeclaredEventIds.has(eventId)
124
+ return getEventRegistryState().declaredEventIds.has(eventId)
88
125
  }
89
126
 
90
127
  /**
@@ -92,7 +129,7 @@ export function isEventDeclared(eventId: string): boolean {
92
129
  * Useful for debugging and introspection.
93
130
  */
94
131
  export function getAllDeclaredEventIds(): string[] {
95
- return Array.from(allDeclaredEventIds)
132
+ return Array.from(getEventRegistryState().declaredEventIds)
96
133
  }
97
134
 
98
135
  /**
@@ -100,7 +137,7 @@ export function getAllDeclaredEventIds(): string[] {
100
137
  * Used by the API to return available events for workflow triggers.
101
138
  */
102
139
  export function getDeclaredEvents(): EventDefinition[] {
103
- return [...allDeclaredEvents]
140
+ return [...getEventRegistryState().declaredEvents]
104
141
  }
105
142
 
106
143
  /**
@@ -108,16 +145,52 @@ export function getDeclaredEvents(): EventDefinition[] {
108
145
  * Used by the SSE endpoint to filter events for the DOM Event Bridge.
109
146
  */
110
147
  export function isBroadcastEvent(eventId: string): boolean {
111
- const event = allDeclaredEvents.find(e => e.id === eventId)
148
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
112
149
  return event?.clientBroadcast === true
113
150
  }
114
151
 
152
+ /**
153
+ * Check if an event should be published over the server-to-server event bridge.
154
+ * Browser-broadcast events remain eligible for backward compatibility, while
155
+ * crossProcessBroadcast supports private process coordination without SSE.
156
+ */
157
+ export function isCrossProcessBroadcastEvent(eventId: string): boolean {
158
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
159
+ return event?.clientBroadcast === true || event?.crossProcessBroadcast === true
160
+ }
161
+
162
+ /**
163
+ * Check whether an event is reserved for private server-to-server
164
+ * coordination. Workflow-authored EMIT_EVENT activities must not emit these
165
+ * events because their payload and event id are tenant-managed input.
166
+ */
167
+ export function isPrivateCrossProcessBroadcastEvent(eventId: string): boolean {
168
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
169
+ return event?.crossProcessBroadcast === true && event?.clientBroadcast !== true
170
+ }
171
+
172
+ /**
173
+ * Verify provenance for a private cross-process event. The module id is
174
+ * stamped by a declared module emitter or another trusted server-side seam;
175
+ * tenant-managed event payloads never participate in this decision.
176
+ */
177
+ export function isPrivateCrossProcessEventEmitter(
178
+ eventId: string,
179
+ emitterModuleId: string | undefined,
180
+ ): boolean {
181
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
182
+ if (event?.crossProcessBroadcast !== true) return true
183
+ return typeof event.module === 'string'
184
+ && event.module.length > 0
185
+ && event.module === emitterModuleId
186
+ }
187
+
115
188
  /**
116
189
  * Check if an event has portalBroadcast enabled.
117
190
  * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.
118
191
  */
119
192
  export function isPortalBroadcastEvent(eventId: string): boolean {
120
- const event = allDeclaredEvents.find(e => e.id === eventId)
193
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
121
194
  return event?.portalBroadcast === true
122
195
  }
123
196
 
@@ -125,17 +198,16 @@ export function isPortalBroadcastEvent(eventId: string): boolean {
125
198
  // Bootstrap Registration (similar to searchModuleConfigs pattern)
126
199
  // =============================================================================
127
200
 
128
- let _registeredEventConfigs: EventModuleConfig[] | null = null
129
-
130
201
  /**
131
202
  * Register event module configurations globally.
132
203
  * Called during app bootstrap with configs from events.generated.ts.
133
204
  */
134
205
  export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
135
- if (_registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
206
+ const state = getEventRegistryState()
207
+ if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
136
208
  logger.debug('Event module configs re-registered (this may occur during HMR)')
137
209
  }
138
- _registeredEventConfigs = configs
210
+ state.registeredEventConfigs = configs
139
211
  for (const config of configs) {
140
212
  for (const event of config.events) {
141
213
  addDeclaredEvent(event)
@@ -148,7 +220,7 @@ export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
148
220
  * Returns empty array if not registered.
149
221
  */
150
222
  export function getEventModuleConfigs(): EventModuleConfig[] {
151
- return _registeredEventConfigs ?? []
223
+ return getEventRegistryState().registeredEventConfigs ?? []
152
224
  }
153
225
 
154
226
  // =============================================================================
@@ -239,7 +311,27 @@ export function createModuleEvents<
239
311
  return
240
312
  }
241
313
 
242
- await eventBus.emit(eventId, payload, emitOptions)
314
+ const eventDefinition = fullEvents.find((event) => event.id === eventId)
315
+ const isClientBroadcast = eventDefinition?.clientBroadcast === true
316
+ const trustedOptions = eventDefinition?.crossProcessBroadcast === true || isClientBroadcast
317
+ ? {
318
+ ...emitOptions,
319
+ // Browser-broadcast module emitters historically accepted scope in
320
+ // their typed payload. Preserve that contract at the trusted module
321
+ // boundary while the event bus itself relies only on options.
322
+ ...(isClientBroadcast && emitOptions?.tenantId === undefined
323
+ ? { tenantId: payload.tenantId ?? null }
324
+ : {}),
325
+ ...(isClientBroadcast && emitOptions?.organizationId === undefined
326
+ ? { organizationId: payload.organizationId ?? null }
327
+ : {}),
328
+ ...(isClientBroadcast && emitOptions?.organizationIds === undefined && Array.isArray(payload.organizationIds)
329
+ ? { organizationIds: payload.organizationIds.filter((value): value is string => typeof value === 'string') }
330
+ : {}),
331
+ emitterModuleId: moduleId,
332
+ }
333
+ : emitOptions
334
+ await eventBus.emit(eventId, payload, trustedOptions)
243
335
  }
244
336
 
245
337
  return {
@@ -34,6 +34,8 @@ export interface EventDefinition {
34
34
  excludeFromTriggers?: boolean
35
35
  /** When true, this event is bridged to the browser via SSE (DOM Event Bridge). Default: false */
36
36
  clientBroadcast?: boolean
37
+ /** When true, this event is bridged between server processes without enabling browser delivery. Default: false */
38
+ crossProcessBroadcast?: boolean
37
39
  /** When true, this event is bridged to the customer portal via SSE (Portal Event Bridge). Default: false */
38
40
  portalBroadcast?: boolean
39
41
  }
@@ -66,6 +68,21 @@ export interface EmitOptions {
66
68
  tenantId?: string | null
67
69
  /** Trusted organization scope forwarded to subscribers separately from the payload */
68
70
  organizationId?: string | null
71
+ /**
72
+ * Trusted multi-organization audience forwarded to subscribers separately
73
+ * from the payload. Mirrors the documented SSE audience contract, where a
74
+ * clientBroadcast event may target several organizations at once; carried in
75
+ * trusted options so the receiver never falls back to payload scope.
76
+ */
77
+ organizationIds?: string[] | null
78
+ /**
79
+ * Module that emitted a private cross-process coordination event.
80
+ *
81
+ * This is stamped by `createModuleEvents`; application inputs must not set or
82
+ * derive it from event payloads.
83
+ * @internal
84
+ */
85
+ emitterModuleId?: string
69
86
  }
70
87
 
71
88
  // =============================================================================
@@ -196,6 +196,14 @@ export type ModuleWorker = {
196
196
  concurrency: number
197
197
  lockDuration?: number
198
198
  maxStalledCount?: number
199
+ /**
200
+ * Reports a job the queue abandoned without running the handler.
201
+ *
202
+ * Present only for workers whose metadata declares it; the generator emits it as a lazy import
203
+ * beside the handler, because the registry serializes metadata as literals and a function cannot
204
+ * survive that.
205
+ */
206
+ onJobAbandoned?: (payload: unknown, info: { jobId: string | null; reason: string }) => void | Promise<void>
199
207
  handler: ModuleWorkerHandler
200
208
  }
201
209
 
@@ -554,3 +562,31 @@ export function createLazyModuleWorker(
554
562
  return handler(job, ctx)
555
563
  }
556
564
  }
565
+
566
+ /**
567
+ * Resolves a worker's `metadata.onJobAbandoned` on first use.
568
+ *
569
+ * The generator serializes worker metadata as literals, so a function declared there cannot be
570
+ * emitted inline the way `concurrency` or `lockDuration` are. It is emitted as this thunk instead —
571
+ * the same lazy-import treatment the handler already gets — and only for workers whose metadata
572
+ * declares the callback, so no other queue acquires one (and with it, a sweep) by accident.
573
+ */
574
+ export function createLazyModuleWorkerAbandonHook(
575
+ loadModule: () => Promise<unknown>,
576
+ id: string
577
+ ): (payload: unknown, info: { jobId: string | null; reason: string }) => Promise<void> {
578
+ let hookPromise: Promise<((payload: unknown, info: { jobId: string | null; reason: string }) => unknown) | null> | null = null
579
+ return async (payload, info) => {
580
+ hookPromise ??= loadModule().then((loaded) => {
581
+ const metadata = (loaded as { metadata?: { onJobAbandoned?: unknown } } | null)?.metadata
582
+ return typeof metadata?.onJobAbandoned === 'function'
583
+ ? (metadata.onJobAbandoned as (payload: unknown, info: { jobId: string | null; reason: string }) => unknown)
584
+ : null
585
+ })
586
+ const hook = await hookPromise
587
+ if (!hook) {
588
+ throw new Error(`[registry] Worker "${id}" was registered with an abandoned-job hook but its metadata no longer declares one`)
589
+ }
590
+ await hook(payload, info)
591
+ }
592
+ }