@open-mercato/shared 0.4.5-develop-03023b2707 → 0.4.5-develop-0c30cb4b11

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 (36) hide show
  1. package/dist/lib/bootstrap/factory.js +4 -0
  2. package/dist/lib/bootstrap/factory.js.map +2 -2
  3. package/dist/lib/crud/enricher-registry.js +47 -0
  4. package/dist/lib/crud/enricher-registry.js.map +7 -0
  5. package/dist/lib/crud/enricher-runner.js +242 -0
  6. package/dist/lib/crud/enricher-runner.js.map +7 -0
  7. package/dist/lib/crud/factory.js +53 -1
  8. package/dist/lib/crud/factory.js.map +2 -2
  9. package/dist/lib/crud/response-enricher.js +1 -0
  10. package/dist/lib/crud/response-enricher.js.map +7 -0
  11. package/dist/lib/version.js +1 -1
  12. package/dist/lib/version.js.map +1 -1
  13. package/dist/modules/events/factory.js +5 -0
  14. package/dist/modules/events/factory.js.map +2 -2
  15. package/dist/modules/registry.js.map +1 -1
  16. package/dist/modules/widgets/injection-loader.js +100 -40
  17. package/dist/modules/widgets/injection-loader.js.map +2 -2
  18. package/dist/modules/widgets/injection-position.js +48 -0
  19. package/dist/modules/widgets/injection-position.js.map +7 -0
  20. package/dist/modules/widgets/injection-progress.js +1 -0
  21. package/dist/modules/widgets/injection-progress.js.map +7 -0
  22. package/package.json +1 -1
  23. package/src/lib/bootstrap/factory.ts +6 -0
  24. package/src/lib/bootstrap/types.ts +6 -0
  25. package/src/lib/crud/enricher-registry.ts +68 -0
  26. package/src/lib/crud/enricher-runner.ts +329 -0
  27. package/src/lib/crud/factory.ts +79 -1
  28. package/src/lib/crud/response-enricher.ts +110 -0
  29. package/src/modules/events/factory.ts +9 -0
  30. package/src/modules/events/types.ts +2 -0
  31. package/src/modules/registry.ts +2 -2
  32. package/src/modules/widgets/__tests__/injection-position.test.ts +33 -0
  33. package/src/modules/widgets/injection-loader.ts +140 -50
  34. package/src/modules/widgets/injection-position.ts +59 -0
  35. package/src/modules/widgets/injection-progress.ts +35 -0
  36. package/src/modules/widgets/injection.ts +280 -3
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Response Enricher Runner
3
+ *
4
+ * Executes response enrichers against API response payloads.
5
+ * Handles timeout, fallback, ACL feature gating, and error isolation.
6
+ */
7
+
8
+ import type {
9
+ EnricherContext,
10
+ EnricherRegistryEntry,
11
+ EnrichmentResult,
12
+ ResponseEnricher,
13
+ SingleEnrichmentResult,
14
+ } from './response-enricher'
15
+ import { getEnrichersForEntity } from './enricher-registry'
16
+
17
+ const DEFAULT_TIMEOUT = 2000
18
+ const SLOW_WARN_MS = 100
19
+ const SLOW_ERROR_MS = 500
20
+ const DEFAULT_CACHE_TTL_MS = 60_000
21
+
22
+ function timeoutPromise(ms: number): Promise<never> {
23
+ return new Promise((_, reject) =>
24
+ setTimeout(() => reject(new Error(`Enricher timed out after ${ms}ms`)), ms),
25
+ )
26
+ }
27
+
28
+ function hasRequiredFeatures(
29
+ enricher: ResponseEnricher,
30
+ userFeatures: string[] | undefined,
31
+ ): boolean {
32
+ if (!enricher.features || enricher.features.length === 0) return true
33
+ if (!userFeatures) return false
34
+ const hasFeature = (required: string): boolean => {
35
+ for (const granted of userFeatures) {
36
+ if (granted === '*' || granted === required) return true
37
+ if (granted.endsWith('.*')) {
38
+ const prefix = granted.slice(0, -1)
39
+ if (required.startsWith(prefix)) return true
40
+ }
41
+ }
42
+ return false
43
+ }
44
+ return enricher.features.every((feature) => hasFeature(feature))
45
+ }
46
+
47
+ function getActiveEnrichers(
48
+ targetEntity: string,
49
+ context: EnricherContext,
50
+ ): EnricherRegistryEntry[] {
51
+ const entries = getEnrichersForEntity(targetEntity)
52
+ return entries.filter((entry) => {
53
+ const enricher = entry.enricher
54
+ if (!hasRequiredFeatures(enricher, context.userFeatures)) return false
55
+ if (enricher.disabledTenantIds?.includes(context.tenantId)) return false
56
+ return true
57
+ })
58
+ }
59
+
60
+ type CacheLike = {
61
+ get: (key: string) => Promise<unknown>
62
+ set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<unknown>
63
+ }
64
+
65
+ function resolveCache(context: EnricherContext): CacheLike | null {
66
+ const container = context.container as { resolve?: (name: string) => unknown } | undefined
67
+ if (!container?.resolve) return null
68
+ try {
69
+ const cache = container.resolve('cache') as CacheLike
70
+ if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {
71
+ return cache
72
+ }
73
+ } catch {
74
+ // ignore cache resolution failures
75
+ }
76
+ try {
77
+ const cacheService = container.resolve('cacheService') as CacheLike
78
+ if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {
79
+ return cacheService
80
+ }
81
+ } catch {
82
+ // ignore cache service resolution failures
83
+ }
84
+ return null
85
+ }
86
+
87
+ function buildCacheKey(
88
+ enricher: ResponseEnricher,
89
+ context: EnricherContext,
90
+ mode: 'one' | 'many',
91
+ recordIds: string[],
92
+ ): string {
93
+ const sortedIds = [...recordIds].sort()
94
+ return `umes:enricher:${enricher.id}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`
95
+ }
96
+
97
+ function extractRecordId(record: Record<string, unknown>): string {
98
+ const idValue = record.id
99
+ if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()
100
+ if (typeof idValue === 'number') return String(idValue)
101
+ return 'unknown'
102
+ }
103
+
104
+ function getEnricherCacheTtl(enricher: ResponseEnricher): number {
105
+ const ttl = enricher.cache?.ttl
106
+ if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {
107
+ return ttl
108
+ }
109
+ return DEFAULT_CACHE_TTL_MS
110
+ }
111
+
112
+ function getEnricherCacheTags(enricher: ResponseEnricher, context: EnricherContext): string[] {
113
+ const tags = new Set<string>([
114
+ `tenant:${context.tenantId}`,
115
+ `organization:${context.organizationId}`,
116
+ `enricher:${enricher.id}`,
117
+ ])
118
+ for (const tag of enricher.cache?.tags ?? []) {
119
+ if (!tag || tag.trim().length === 0) continue
120
+ tags.add(tag)
121
+ }
122
+ return Array.from(tags)
123
+ }
124
+
125
+ async function readEnricherCache<T>(
126
+ cache: CacheLike | null,
127
+ key: string,
128
+ ): Promise<T | null> {
129
+ if (!cache) return null
130
+ try {
131
+ const value = await cache.get(key)
132
+ return value == null ? null : (value as T)
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ async function writeEnricherCache(
139
+ cache: CacheLike | null,
140
+ key: string,
141
+ value: unknown,
142
+ ttl: number,
143
+ tags: string[],
144
+ ): Promise<void> {
145
+ if (!cache) return
146
+ try {
147
+ await cache.set(key, value, { ttl, tags })
148
+ } catch {
149
+ // ignore cache write failures
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Apply response enrichers to a list of records.
155
+ *
156
+ * Runs AFTER CrudHooks.afterList, BEFORE HTTP response serialization.
157
+ * Each enricher runs independently — a failed non-critical enricher is skipped.
158
+ */
159
+ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
160
+ items: T[],
161
+ targetEntity: string,
162
+ context: EnricherContext,
163
+ ): Promise<EnrichmentResult<T>> {
164
+ const activeEntries = getActiveEnrichers(targetEntity, context)
165
+
166
+ if (activeEntries.length === 0) {
167
+ return { items, _meta: { enrichedBy: [] } }
168
+ }
169
+
170
+ const enrichedBy: string[] = []
171
+ const enricherErrors: string[] = []
172
+ let currentItems = items
173
+ const cache = resolveCache(context)
174
+
175
+ for (const entry of activeEntries) {
176
+ const enricher = entry.enricher
177
+ const timeout = enricher.timeout ?? DEFAULT_TIMEOUT
178
+ const startTime = Date.now()
179
+
180
+ try {
181
+ let result: T[]
182
+ const recordIds = currentItems.map((item) => extractRecordId(item))
183
+ const shouldUseCache = enricher.cache?.strategy === 'read-through'
184
+ const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, 'many', recordIds) : null
185
+ if (shouldUseCache && cacheKey) {
186
+ const cached = await readEnricherCache<T[]>(cache, cacheKey)
187
+ if (cached) {
188
+ currentItems = cached
189
+ enrichedBy.push(enricher.id)
190
+ continue
191
+ }
192
+ }
193
+
194
+ if (enricher.enrichMany) {
195
+ result = await Promise.race([
196
+ enricher.enrichMany(currentItems, context) as Promise<T[]>,
197
+ timeoutPromise(timeout),
198
+ ])
199
+ } else {
200
+ throw new Error(
201
+ `Enricher ${enricher.id} must implement enrichMany() for list endpoints`,
202
+ )
203
+ }
204
+
205
+ const elapsedMs = Date.now() - startTime
206
+ if (elapsedMs > SLOW_ERROR_MS) {
207
+ console.error(
208
+ `[UMES] Enricher ${enricher.id} took ${elapsedMs}ms (threshold: ${SLOW_ERROR_MS}ms)`,
209
+ )
210
+ } else if (elapsedMs > SLOW_WARN_MS) {
211
+ console.warn(
212
+ `[UMES] Enricher ${enricher.id} took ${elapsedMs}ms (threshold: ${SLOW_WARN_MS}ms)`,
213
+ )
214
+ }
215
+
216
+ currentItems = result
217
+ if (shouldUseCache && cacheKey) {
218
+ await writeEnricherCache(
219
+ cache,
220
+ cacheKey,
221
+ result,
222
+ getEnricherCacheTtl(enricher),
223
+ getEnricherCacheTags(enricher, context),
224
+ )
225
+ }
226
+ enrichedBy.push(enricher.id)
227
+ } catch (err) {
228
+ if (enricher.critical) {
229
+ throw err
230
+ }
231
+
232
+ const message = err instanceof Error ? err.message : String(err)
233
+ console.warn(`[UMES] Enricher ${enricher.id} failed: ${message}`)
234
+ enricherErrors.push(enricher.id)
235
+
236
+ if (enricher.fallback) {
237
+ currentItems = currentItems.map((item) => ({
238
+ ...item,
239
+ ...enricher.fallback,
240
+ })) as T[]
241
+ }
242
+ }
243
+ }
244
+
245
+ return {
246
+ items: currentItems,
247
+ _meta: {
248
+ enrichedBy,
249
+ ...(enricherErrors.length > 0 ? { enricherErrors } : {}),
250
+ },
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Apply response enrichers to a single record.
256
+ *
257
+ * Used for detail endpoints (GET /:id), POST, and PUT responses.
258
+ */
259
+ export async function applyResponseEnricherToRecord<T extends Record<string, unknown>>(
260
+ record: T,
261
+ targetEntity: string,
262
+ context: EnricherContext,
263
+ ): Promise<SingleEnrichmentResult<T>> {
264
+ const activeEntries = getActiveEnrichers(targetEntity, context)
265
+
266
+ if (activeEntries.length === 0) {
267
+ return { record, _meta: { enrichedBy: [] } }
268
+ }
269
+
270
+ const enrichedBy: string[] = []
271
+ const enricherErrors: string[] = []
272
+ let currentRecord = record
273
+ const cache = resolveCache(context)
274
+
275
+ for (const entry of activeEntries) {
276
+ const enricher = entry.enricher
277
+ const timeout = enricher.timeout ?? DEFAULT_TIMEOUT
278
+
279
+ try {
280
+ const recordId = extractRecordId(currentRecord)
281
+ const shouldUseCache = enricher.cache?.strategy === 'read-through'
282
+ const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, 'one', [recordId]) : null
283
+ if (shouldUseCache && cacheKey) {
284
+ const cached = await readEnricherCache<T>(cache, cacheKey)
285
+ if (cached) {
286
+ currentRecord = cached
287
+ enrichedBy.push(enricher.id)
288
+ continue
289
+ }
290
+ }
291
+ const result = await Promise.race([
292
+ enricher.enrichOne(currentRecord, context) as Promise<T>,
293
+ timeoutPromise(timeout),
294
+ ])
295
+
296
+ currentRecord = result
297
+ if (shouldUseCache && cacheKey) {
298
+ await writeEnricherCache(
299
+ cache,
300
+ cacheKey,
301
+ result,
302
+ getEnricherCacheTtl(enricher),
303
+ getEnricherCacheTags(enricher, context),
304
+ )
305
+ }
306
+ enrichedBy.push(enricher.id)
307
+ } catch (err) {
308
+ if (enricher.critical) {
309
+ throw err
310
+ }
311
+
312
+ const message = err instanceof Error ? err.message : String(err)
313
+ console.warn(`[UMES] Enricher ${enricher.id} failed: ${message}`)
314
+ enricherErrors.push(enricher.id)
315
+
316
+ if (enricher.fallback) {
317
+ currentRecord = { ...currentRecord, ...enricher.fallback } as T
318
+ }
319
+ }
320
+ }
321
+
322
+ return {
323
+ record: currentRecord,
324
+ _meta: {
325
+ enrichedBy,
326
+ ...(enricherErrors.length > 0 ? { enricherErrors } : {}),
327
+ },
328
+ }
329
+ }
@@ -49,6 +49,8 @@ import {
49
49
  import { deriveCrudSegmentTag } from './cache-stats'
50
50
  import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
51
51
  import { getTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
52
+ import { applyResponseEnrichers, applyResponseEnricherToRecord } from './enricher-runner'
53
+ import type { EnricherContext } from './response-enricher'
52
54
 
53
55
  export type CrudHooks<TCreate, TUpdate, TList> = {
54
56
  beforeList?: (q: TList, ctx: CrudCtx) => Promise<void> | void
@@ -279,6 +281,9 @@ function normalizeFullRecordForExport(input: any): any {
279
281
 
280
282
  for (const [key, value] of Object.entries(input)) {
281
283
  if (key.startsWith('cf_') || key.startsWith('cf:')) continue
284
+ // Strip enricher namespaced fields and metadata from exports
285
+ if (key === '_meta') continue
286
+ if (key.startsWith('_') && key.length > 1) continue
282
287
  record[key] = value
283
288
  }
284
289
  const custom = extractAllCustomFieldEntries(input)
@@ -345,6 +350,11 @@ export type CrudFactoryOptions<TCreate, TUpdate, TList> = {
345
350
  update?: CrudCommandActionConfig
346
351
  delete?: CrudCommandActionConfig
347
352
  }
353
+ /** Response enricher configuration. When set, enrichers targeting this entity run after afterList hook. */
354
+ enrichers?: {
355
+ /** Entity ID for enricher matching (e.g., 'customers.person') */
356
+ entityId: string
357
+ }
348
358
  }
349
359
 
350
360
  function deriveResourceFromActions(actions: CrudFactoryOptions<any, any, any>['actions']): string | null {
@@ -763,6 +773,70 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
763
773
  }
764
774
  }
765
775
 
776
+ /**
777
+ * Build enricher context from CRUD context and resolve user features for ACL gating.
778
+ * Returns null if enrichers are not configured or auth is missing.
779
+ */
780
+ async function buildEnricherContext(ctx: CrudCtx): Promise<EnricherContext | null> {
781
+ if (!opts.enrichers?.entityId) return null
782
+ if (!ctx.auth) return null
783
+
784
+ let userFeatures: string[] | undefined
785
+ try {
786
+ const rbac = (ctx.container.resolve('rbacService') as any)
787
+ if (rbac?.getGrantedFeatures) {
788
+ userFeatures = await rbac.getGrantedFeatures(ctx.auth.sub, {
789
+ tenantId: ctx.auth.tenantId,
790
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
791
+ })
792
+ }
793
+ } catch {
794
+ // rbacService not available — enrichers without feature requirements still run
795
+ }
796
+
797
+ return {
798
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? '',
799
+ tenantId: ctx.auth.tenantId ?? '',
800
+ userId: ctx.auth.sub,
801
+ em: ctx.container.resolve('em'),
802
+ container: ctx.container,
803
+ userFeatures,
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Apply response enrichers to list payload items.
809
+ * Mutates payload.items and adds payload._meta.
810
+ * No-op if enrichers are not configured.
811
+ */
812
+ async function enrichListPayload(payload: any, ctx: CrudCtx, profiler?: Profiler): Promise<void> {
813
+ if (!opts.enrichers?.entityId) return
814
+ const enricherCtx = await buildEnricherContext(ctx)
815
+ if (!enricherCtx) return
816
+ profiler?.mark('enrichers_start')
817
+ const result = await applyResponseEnrichers(payload.items, opts.enrichers.entityId, enricherCtx)
818
+ payload.items = result.items
819
+ if (result._meta.enrichedBy.length > 0 || result._meta.enricherErrors?.length) {
820
+ payload._meta = { ...(payload._meta || {}), ...result._meta }
821
+ }
822
+ profiler?.mark('enrichers_complete', { enricherCount: result._meta.enrichedBy.length })
823
+ }
824
+
825
+ /**
826
+ * Apply response enrichers to a single record.
827
+ * Returns the enriched record with _meta merged.
828
+ */
829
+ async function enrichSingleRecord(record: any, ctx: CrudCtx): Promise<any> {
830
+ if (!opts.enrichers?.entityId) return record
831
+ const enricherCtx = await buildEnricherContext(ctx)
832
+ if (!enricherCtx) return record
833
+ const result = await applyResponseEnricherToRecord(record, opts.enrichers.entityId, enricherCtx)
834
+ if (result._meta.enrichedBy.length > 0 || result._meta.enricherErrors?.length) {
835
+ return { ...result.record, _meta: result._meta }
836
+ }
837
+ return result.record
838
+ }
839
+
766
840
  async function ensureAuth(request?: Request | null) {
767
841
  const auth = request ? await getAuthFromRequest(request) : await getAuthFromCookies()
768
842
  if (!auth) return null
@@ -981,6 +1055,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
981
1055
  query: validated,
982
1056
  })
983
1057
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1058
+ await enrichListPayload(payload, ctx, profiler)
984
1059
  logCacheOutcome('hit', items.length)
985
1060
  const response = respondWithPayload(payload)
986
1061
  finishProfile({ result: 'cache_hit', cacheStatus })
@@ -1162,6 +1237,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1162
1237
  }
1163
1238
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1164
1239
  profiler.mark('after_list_hook')
1240
+ await enrichListPayload(payload, ctx, profiler)
1165
1241
  await maybeStoreCrudCache(payload)
1166
1242
  profiler.mark('cache_store_attempt', { cacheEnabled })
1167
1243
  logCacheOutcome(cacheStatus, payload.items.length)
@@ -1281,6 +1357,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1281
1357
  const payload = { items: list, total: list.length }
1282
1358
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1283
1359
  profiler.mark('after_list_hook')
1360
+ await enrichListPayload(payload, ctx, profiler)
1284
1361
  await maybeStoreCrudCache(payload)
1285
1362
  profiler.mark('cache_store_attempt', { cacheEnabled })
1286
1363
  logCacheOutcome(cacheStatus, payload.items.length)
@@ -1394,7 +1471,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1394
1471
  await de.flushOrmEntityChanges()
1395
1472
  await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'created', resourceTargets)
1396
1473
 
1397
- const payload = createConfig.response ? createConfig.response(entity) : { id: String((entity as any)[ormCfg.idField!]) }
1474
+ let payload = createConfig.response ? createConfig.response(entity) : { id: String((entity as any)[ormCfg.idField!]) }
1475
+ payload = await enrichSingleRecord(payload, ctx)
1398
1476
  return json(payload, { status: 201 })
1399
1477
  } catch (e) {
1400
1478
  return handleError(e)
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Response Enricher Contract
3
+ *
4
+ * Allows modules to enrich other modules' API responses without touching core code.
5
+ * Similar to GraphQL Federation's @extends — modules can add computed fields to
6
+ * any entity's API response by declaring enrichers.
7
+ *
8
+ * Enrichers run AFTER CrudHooks.afterList and BEFORE HTTP response serialization.
9
+ * They are additive-only: enriched data lives under a `_<module>` namespace prefix.
10
+ */
11
+
12
+ /**
13
+ * Context available to enrichers during execution.
14
+ * The EntityManager is read-only — enrichers MUST NOT perform writes.
15
+ */
16
+ export interface EnricherContext {
17
+ organizationId: string
18
+ tenantId: string
19
+ userId: string
20
+ em: unknown
21
+ container: unknown
22
+ requestedFields?: string[]
23
+ userFeatures?: string[]
24
+ }
25
+
26
+ /**
27
+ * Response enricher definition.
28
+ *
29
+ * @template TRecord - The shape of the record being enriched
30
+ * @template TEnriched - Additional fields added by this enricher
31
+ *
32
+ * Rules:
33
+ * - `enrichMany` MUST be implemented for list endpoints (N+1 prevention)
34
+ * - Enrichers MUST NOT modify or remove existing fields (additive only)
35
+ * - Enriched data MUST be namespaced under `_<module>` prefix
36
+ * - Enrichers MUST NOT perform writes via EntityManager
37
+ */
38
+ export interface ResponseEnricher<TRecord = any, TEnriched = any> {
39
+ /** Unique identifier: `<module>.<enricher-name>` */
40
+ id: string
41
+
42
+ /** Target entity to enrich: `<module>.<entity>` (e.g., 'customers.person') */
43
+ targetEntity: string
44
+
45
+ /** ACL features required for this enricher to run */
46
+ features?: string[]
47
+
48
+ /** Execution priority (higher = runs first). Default: 0 */
49
+ priority?: number
50
+
51
+ /** Maximum execution time in ms before the enricher is skipped. Default: 2000 */
52
+ timeout?: number
53
+
54
+ /** Fallback value to merge into the record when the enricher times out or throws */
55
+ fallback?: Record<string, unknown>
56
+
57
+ /** If true, enricher errors propagate as HTTP errors. Default: false */
58
+ critical?: boolean
59
+
60
+ /** Tenant IDs where this enricher should be disabled. */
61
+ disabledTenantIds?: string[]
62
+
63
+ /** Optional cache configuration for read-through enrichment results. */
64
+ cache?: {
65
+ strategy: 'read-through'
66
+ ttl: number
67
+ tags?: string[]
68
+ invalidateOn?: string[]
69
+ }
70
+
71
+ /** Enrich a single record. Used for detail endpoints. */
72
+ enrichOne(record: TRecord, context: EnricherContext): Promise<TRecord & TEnriched>
73
+
74
+ /**
75
+ * Enrich multiple records in a single batch call.
76
+ * MUST be implemented for list endpoints to prevent N+1 queries.
77
+ * Use batch queries (e.g., `$in` with all record IDs) instead of per-record queries.
78
+ */
79
+ enrichMany?(records: TRecord[], context: EnricherContext): Promise<(TRecord & TEnriched)[]>
80
+ }
81
+
82
+ /**
83
+ * Registered enricher entry with module context.
84
+ */
85
+ export interface EnricherRegistryEntry {
86
+ moduleId: string
87
+ enricher: ResponseEnricher
88
+ }
89
+
90
+ /**
91
+ * Result of applying enrichers to a set of records.
92
+ */
93
+ export interface EnrichmentResult<T = any> {
94
+ items: T[]
95
+ _meta: {
96
+ enrichedBy: string[]
97
+ enricherErrors?: string[]
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Result of applying enrichers to a single record.
103
+ */
104
+ export interface SingleEnrichmentResult<T = any> {
105
+ record: T
106
+ _meta: {
107
+ enrichedBy: string[]
108
+ enricherErrors?: string[]
109
+ }
110
+ }
@@ -92,6 +92,15 @@ export function getDeclaredEvents(): EventDefinition[] {
92
92
  return [...allDeclaredEvents]
93
93
  }
94
94
 
95
+ /**
96
+ * Check if an event has clientBroadcast enabled.
97
+ * Used by the SSE endpoint to filter events for the DOM Event Bridge.
98
+ */
99
+ export function isBroadcastEvent(eventId: string): boolean {
100
+ const event = allDeclaredEvents.find(e => e.id === eventId)
101
+ return event?.clientBroadcast === true
102
+ }
103
+
95
104
  // =============================================================================
96
105
  // Bootstrap Registration (similar to searchModuleConfigs pattern)
97
106
  // =============================================================================
@@ -32,6 +32,8 @@ export interface EventDefinition {
32
32
  entity?: string
33
33
  /** Whether excluded from workflow triggers */
34
34
  excludeFromTriggers?: boolean
35
+ /** When true, this event is bridged to the browser via SSE (DOM Event Bridge). Default: false */
36
+ clientBroadcast?: boolean
35
37
  }
36
38
 
37
39
  // =============================================================================
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi/types'
3
3
  import type { DashboardWidgetModule } from './dashboard/widgets'
4
- import type { InjectionWidgetModule, ModuleInjectionTable } from './widgets/injection'
4
+ import type { InjectionAnyWidgetModule, ModuleInjectionTable } from './widgets/injection'
5
5
 
6
6
  // Context passed to dynamic metadata guards
7
7
  export type RouteVisibilityContext = { path?: string; auth?: any }
@@ -130,7 +130,7 @@ export type ModuleInjectionWidgetEntry = {
130
130
  moduleId: string
131
131
  key: string
132
132
  source: 'app' | 'package'
133
- loader: () => Promise<InjectionWidgetModule<any, any>>
133
+ loader: () => Promise<InjectionAnyWidgetModule<any, any>>
134
134
  }
135
135
 
136
136
  export type Module = {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { describe, expect, it } from '@jest/globals'
5
+ import {
6
+ InjectionPosition,
7
+ insertByInjectionPlacement,
8
+ } from '@open-mercato/shared/modules/widgets/injection-position'
9
+
10
+ type Item = { id: string }
11
+
12
+ describe('injection-position', () => {
13
+ it('should resolve insertion order for before/after/first/last', () => {
14
+ let items: Item[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
15
+ items = insertByInjectionPlacement(items, { id: 'x' }, { position: InjectionPosition.Before, relativeTo: 'b' }, (entry) => entry.id)
16
+ items = insertByInjectionPlacement(items, { id: 'y' }, { position: InjectionPosition.After, relativeTo: 'a' }, (entry) => entry.id)
17
+ items = insertByInjectionPlacement(items, { id: 'z' }, { position: InjectionPosition.First }, (entry) => entry.id)
18
+ items = insertByInjectionPlacement(items, { id: 'w' }, { position: InjectionPosition.Last }, (entry) => entry.id)
19
+
20
+ expect(items.map((entry) => entry.id)).toEqual(['z', 'a', 'y', 'x', 'b', 'c', 'w'])
21
+ })
22
+
23
+ it('should append item when relative target is missing', () => {
24
+ const items = insertByInjectionPlacement(
25
+ [{ id: 'a' }],
26
+ { id: 'x' },
27
+ { position: InjectionPosition.Before, relativeTo: 'missing' },
28
+ (entry) => entry.id,
29
+ )
30
+
31
+ expect(items.map((entry) => entry.id)).toEqual(['a', 'x'])
32
+ })
33
+ })