@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.
- package/dist/lib/bootstrap/factory.js +4 -0
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/crud/enricher-registry.js +47 -0
- package/dist/lib/crud/enricher-registry.js.map +7 -0
- package/dist/lib/crud/enricher-runner.js +242 -0
- package/dist/lib/crud/enricher-runner.js.map +7 -0
- package/dist/lib/crud/factory.js +53 -1
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/response-enricher.js +1 -0
- package/dist/lib/crud/response-enricher.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +5 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js.map +1 -1
- package/dist/modules/widgets/injection-loader.js +100 -40
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/dist/modules/widgets/injection-position.js +48 -0
- package/dist/modules/widgets/injection-position.js.map +7 -0
- package/dist/modules/widgets/injection-progress.js +1 -0
- package/dist/modules/widgets/injection-progress.js.map +7 -0
- package/package.json +1 -1
- package/src/lib/bootstrap/factory.ts +6 -0
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/crud/enricher-registry.ts +68 -0
- package/src/lib/crud/enricher-runner.ts +329 -0
- package/src/lib/crud/factory.ts +79 -1
- package/src/lib/crud/response-enricher.ts +110 -0
- package/src/modules/events/factory.ts +9 -0
- package/src/modules/events/types.ts +2 -0
- package/src/modules/registry.ts +2 -2
- package/src/modules/widgets/__tests__/injection-position.test.ts +33 -0
- package/src/modules/widgets/injection-loader.ts +140 -50
- package/src/modules/widgets/injection-position.ts +59 -0
- package/src/modules/widgets/injection-progress.ts +35 -0
- 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
|
+
}
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
// =============================================================================
|
package/src/modules/registry.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
+
})
|