@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -15,6 +15,10 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
15
15
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
16
16
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
17
17
  import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
18
+ import {
19
+ findApiRouteManifestMatch,
20
+ type ApiRouteManifestEntry,
21
+ } from '@open-mercato/shared/modules/registry'
18
22
  import {
19
23
  clearOptimisticLockReadersForTests,
20
24
  getAllOptimisticLockReaders,
@@ -185,17 +189,28 @@ const accessLogService = {
185
189
  log: jest.fn(async () => {}),
186
190
  }
187
191
 
192
+ const crudCacheStore = new Map<string, unknown>()
193
+ const crudCache = {
194
+ get: jest.fn(async (key: string) => crudCacheStore.get(key) ?? null),
195
+ set: jest.fn(async (key: string, value: unknown) => { crudCacheStore.set(key, value) }),
196
+ delete: jest.fn(async (key: string) => { crudCacheStore.delete(key) }),
197
+ deleteByTags: jest.fn(async () => 0),
198
+ }
199
+
200
+ const resolveContainerValue = jest.fn((name: string) => ({
201
+ em,
202
+ queryEngine,
203
+ cache: crudCache,
204
+ eventBus: mockEventBus,
205
+ dataEngine: mockDataEngine,
206
+ accessLogService,
207
+ commandBus,
208
+ crudMutationGuardService,
209
+ } as any)[name])
210
+
188
211
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
189
212
  createRequestContainer: async () => ({
190
- resolve: (name: string) => ({
191
- em,
192
- queryEngine,
193
- eventBus: mockEventBus,
194
- dataEngine: mockDataEngine,
195
- accessLogService,
196
- commandBus,
197
- crudMutationGuardService,
198
- } as any)[name],
213
+ resolve: resolveContainerValue,
199
214
  })
200
215
  }))
201
216
 
@@ -235,6 +250,7 @@ describe('CRUD Factory', () => {
235
250
  idSeq = 1
236
251
  jest.clearAllMocks()
237
252
  accessLogService.log.mockClear()
253
+ crudCacheStore.clear()
238
254
  mockDataEngine.__pendingSideEffects = []
239
255
  mockDataEngine.__defaultIndexer = null
240
256
  mockDataEngine.__indexedDefaultEntityClass = false
@@ -309,6 +325,44 @@ describe('CRUD Factory', () => {
309
325
  }))
310
326
  })
311
327
 
328
+ it('GET returns an empty page before hooks, cache, query engine, EM, RBAC, or data engine for explicit empty scope', async () => {
329
+ const beforeList = jest.fn()
330
+ const emptyScopeRoute = makeCrudRoute({
331
+ metadata: { GET: { requireAuth: true } },
332
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId' },
333
+ indexer: { entityType: 'example.todo' },
334
+ list: {
335
+ schema: querySchema,
336
+ entityId: 'example.todo',
337
+ fields: ['id', 'title'],
338
+ buildFilters: () => ({} as any),
339
+ },
340
+ hooks: { beforeList },
341
+ })
342
+ mockOrganizationScopeOverride = {
343
+ tenantId: defaultTenantId,
344
+ selectedId: defaultOrganizationId,
345
+ filterIds: [],
346
+ allowedIds: [defaultOrganizationId],
347
+ }
348
+
349
+ const response = await emptyScopeRoute.GET(new Request('http://x/api/example/todos?page=4&pageSize=15'))
350
+ expect(response.status).toBe(200)
351
+ await expect(response.json()).resolves.toEqual({
352
+ items: [],
353
+ total: 0,
354
+ page: 4,
355
+ pageSize: 15,
356
+ totalPages: 0,
357
+ })
358
+ expect(beforeList).not.toHaveBeenCalled()
359
+ expect(resolveContainerValue).not.toHaveBeenCalled()
360
+ expect(crudCache.get).not.toHaveBeenCalled()
361
+ expect(queryEngine.query).not.toHaveBeenCalled()
362
+ expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
363
+ expect(accessLogService.log).not.toHaveBeenCalled()
364
+ })
365
+
312
366
  it('GET spreads totalIsCapped only when the engine reports a capped count', async () => {
313
367
  queryEngine.query.mockResolvedValueOnce({
314
368
  items: [{ id: 'id-1', title: 'A', is_done: false }],
@@ -1213,7 +1267,7 @@ describe('CRUD Factory', () => {
1213
1267
  expect(db[created.id].deletedAt).toBeInstanceOf(Date)
1214
1268
  })
1215
1269
 
1216
- it('trims padded selected organization ids when scope resolution falls back from empty filter ids', async () => {
1270
+ it('denies mutations instead of falling back from empty filter ids', async () => {
1217
1271
  const created = em.create(Todo, { title: 'Scoped', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
1218
1272
  created.id = '123e4567-e89b-12d3-a456-426614174052'
1219
1273
  await em.persist(created).flush()
@@ -1230,27 +1284,41 @@ describe('CRUD Factory', () => {
1230
1284
  headers: { 'content-type': 'application/json' },
1231
1285
  }))
1232
1286
 
1233
- expect(updateResponse.status).toBe(200)
1234
- expect(mockDataEngine.updateOrmEntity).toHaveBeenLastCalledWith(expect.objectContaining({
1235
- where: {
1236
- id: created.id,
1237
- organizationId: defaultOrganizationId,
1238
- tenantId: defaultTenantId,
1239
- deletedAt: null,
1240
- },
1241
- }))
1287
+ expect(updateResponse.status).toBe(403)
1288
+ expect(mockDataEngine.updateOrmEntity).not.toHaveBeenCalled()
1242
1289
 
1243
1290
  const deleteResponse = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}`, { method: 'DELETE' }))
1244
1291
 
1245
- expect(deleteResponse.status).toBe(200)
1246
- expect(mockDataEngine.deleteOrmEntity).toHaveBeenLastCalledWith(expect.objectContaining({
1247
- where: {
1248
- id: created.id,
1249
- organizationId: defaultOrganizationId,
1250
- tenantId: defaultTenantId,
1251
- deletedAt: null,
1252
- },
1292
+ expect(deleteResponse.status).toBe(403)
1293
+ expect(mockDataEngine.deleteOrmEntity).not.toHaveBeenCalled()
1294
+ })
1295
+
1296
+ it('does not call data services when the resolved organization scope is explicitly empty', async () => {
1297
+ mockOrganizationScopeOverride = {
1298
+ selectedId: null,
1299
+ filterIds: [],
1300
+ allowedIds: [],
1301
+ tenantId: defaultTenantId,
1302
+ }
1303
+
1304
+ const listResponse = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
1305
+ expect(listResponse.status).toBe(200)
1306
+ await expect(listResponse.json()).resolves.toEqual({
1307
+ items: [],
1308
+ total: 0,
1309
+ page: 1,
1310
+ pageSize: 10,
1311
+ totalPages: 0,
1312
+ })
1313
+ expect(queryEngine.query).not.toHaveBeenCalled()
1314
+
1315
+ const createResponse = await route.POST(new Request('http://x/api/example/todos', {
1316
+ method: 'POST',
1317
+ body: JSON.stringify({ title: 'Blocked' }),
1318
+ headers: { 'content-type': 'application/json' },
1253
1319
  }))
1320
+ expect(createResponse.status).toBe(403)
1321
+ expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
1254
1322
  })
1255
1323
 
1256
1324
  it('PUT mutation guard uses route resource identity instead of spoofed lock headers', async () => {
@@ -1965,6 +2033,135 @@ describe('CRUD Factory', () => {
1965
2033
  expect(body._interceptor).toEqual({ ok: true, count: 1 })
1966
2034
  })
1967
2035
 
2036
+ it('preserves exact and prefix interceptor identity through cloned and reconstructed aliases', async () => {
2037
+ const beforeCalls: string[] = []
2038
+ const afterCalls: string[] = []
2039
+ registerApiInterceptors([
2040
+ {
2041
+ moduleId: 'example',
2042
+ interceptors: [
2043
+ {
2044
+ id: 'example.canonical-prefix-policy',
2045
+ targetRoute: 'example/*',
2046
+ methods: ['GET', 'POST'],
2047
+ priority: 100,
2048
+ async before(request) {
2049
+ beforeCalls.push(`prefix:${request.url}`)
2050
+ return { ok: true }
2051
+ },
2052
+ async after(request) {
2053
+ afterCalls.push(`prefix:${request.url}`)
2054
+ return { merge: { canonicalPrefixPolicyApplied: true } }
2055
+ },
2056
+ },
2057
+ {
2058
+ id: 'example.canonical-route-policy',
2059
+ targetRoute: 'example/todos',
2060
+ methods: ['GET', 'POST'],
2061
+ async before(request) {
2062
+ beforeCalls.push(`exact:${request.url}`)
2063
+ if (request.method === 'POST') {
2064
+ return { ok: false, statusCode: 451, message: 'Denied by canonical policy' }
2065
+ }
2066
+ return { ok: true }
2067
+ },
2068
+ async after(request) {
2069
+ afterCalls.push(`exact:${request.url}`)
2070
+ return { merge: { canonicalPolicyApplied: true } }
2071
+ },
2072
+ },
2073
+ ],
2074
+ },
2075
+ ])
2076
+
2077
+ const manifest: ApiRouteManifestEntry[] = [{
2078
+ moduleId: 'example',
2079
+ kind: 'route-file',
2080
+ path: '/example/todos',
2081
+ methods: ['GET', 'POST'],
2082
+ load: async () => ({}),
2083
+ }]
2084
+ const aliases = [
2085
+ { urlPath: '/api/example/todos', dispatcherPath: '/example/todos', transform: (request: Request) => request },
2086
+ { urlPath: '/api/EXAMPLE/TODOS', dispatcherPath: '/EXAMPLE/TODOS', transform: (request: Request) => request.clone() },
2087
+ { urlPath: '/api/%65xample/%74odos', dispatcherPath: '/example/todos', transform: (request: Request) => new Request(request.url, request) },
2088
+ ]
2089
+
2090
+ for (const alias of aliases) {
2091
+ const postRequest = new Request(`http://x${alias.urlPath}?source=alias`, {
2092
+ method: 'POST',
2093
+ body: JSON.stringify({ title: 'Must not be created' }),
2094
+ headers: { 'content-type': 'application/json' },
2095
+ })
2096
+ expect(findApiRouteManifestMatch(manifest, 'POST', alias.dispatcherPath, postRequest)).toBeDefined()
2097
+ const denied = await route.POST(alias.transform(postRequest))
2098
+ expect(denied.status).toBe(451)
2099
+ await expect(denied.json()).resolves.toMatchObject({ error: 'Denied by canonical policy' })
2100
+
2101
+ const getRequest = new Request(`http://x${alias.urlPath}?page=1&pageSize=10`)
2102
+ expect(findApiRouteManifestMatch(manifest, 'GET', alias.dispatcherPath, getRequest)).toBeDefined()
2103
+ const allowed = await route.GET(alias.transform(getRequest))
2104
+ expect(allowed.status).toBe(200)
2105
+ await expect(allowed.json()).resolves.toMatchObject({
2106
+ canonicalPolicyApplied: true,
2107
+ canonicalPrefixPolicyApplied: true,
2108
+ })
2109
+ }
2110
+
2111
+ expect(beforeCalls).toHaveLength(12)
2112
+ expect(afterCalls).toHaveLength(6)
2113
+ expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
2114
+ })
2115
+
2116
+ it('fails a malformed unbound request before interceptor selection or mutation', async () => {
2117
+ const before = jest.fn(async () => ({ ok: true }))
2118
+ registerApiInterceptors([{
2119
+ moduleId: 'example',
2120
+ interceptors: [{
2121
+ id: 'example.malformed-path-probe',
2122
+ targetRoute: 'example/*',
2123
+ methods: ['POST'],
2124
+ before,
2125
+ }],
2126
+ }])
2127
+
2128
+ const response = await route.POST(new Request('http://x/api/example/%ZZ', {
2129
+ method: 'POST',
2130
+ body: JSON.stringify({ title: 'Must not be created' }),
2131
+ headers: { 'content-type': 'application/json' },
2132
+ }))
2133
+
2134
+ expect(response.status).toBe(400)
2135
+ expect(before).not.toHaveBeenCalled()
2136
+ expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
2137
+ })
2138
+
2139
+ it('fails closed for an attacker-supplied route identity header', async () => {
2140
+ const before = jest.fn(async () => ({ ok: true }))
2141
+ registerApiInterceptors([{
2142
+ moduleId: 'example',
2143
+ interceptors: [{
2144
+ id: 'example.forged-identity-probe',
2145
+ targetRoute: 'example/*',
2146
+ methods: ['POST'],
2147
+ before,
2148
+ }],
2149
+ }])
2150
+
2151
+ const response = await route.POST(new Request('http://x/api/example/todos', {
2152
+ method: 'POST',
2153
+ body: JSON.stringify({ title: 'Must not be created' }),
2154
+ headers: {
2155
+ 'content-type': 'application/json',
2156
+ 'x-open-mercato-route-identity': 'attacker-controlled',
2157
+ },
2158
+ }))
2159
+
2160
+ expect(response.status).toBe(400)
2161
+ expect(before).not.toHaveBeenCalled()
2162
+ expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
2163
+ })
2164
+
1968
2165
  // The command DELETE path used to hand before-interceptors the body only, so a
1969
2166
  // guard reading `?id=` saw nothing to object to and the delete went through
1970
2167
  // with a 200 (issue #4842).
@@ -1,6 +1,271 @@
1
- import type { EnricherContext, ResponseEnricher } from '../response-enricher'
1
+ import { createLogger } from '../../logger'
2
+ import {
3
+ registerTelemetryRuntime,
4
+ resetTelemetryRuntime,
5
+ type TelemetryRuntime,
6
+ } from '../../telemetry/runtime'
2
7
  import { registerResponseEnrichers } from '../enricher-registry'
3
- import { applyResponseEnricherToRecord } from '../enricher-runner'
8
+ import {
9
+ applyResponseEnricherToRecord,
10
+ applyResponseEnrichers,
11
+ } from '../enricher-runner'
12
+ import type {
13
+ EnricherContext,
14
+ EnricherRegistryEntry,
15
+ ResponseEnricher,
16
+ } from '../response-enricher'
17
+
18
+ jest.mock('../../logger', () => {
19
+ const mocked = {
20
+ debug: jest.fn(),
21
+ info: jest.fn(),
22
+ warn: jest.fn(),
23
+ error: jest.fn(),
24
+ child: jest.fn(),
25
+ }
26
+ mocked.child.mockImplementation(() => mocked)
27
+ return { createLogger: jest.fn(() => mocked) }
28
+ })
29
+
30
+ const logger = createLogger('shared').child({ component: 'umes' })
31
+ const loggerDebug = logger.debug as jest.Mock
32
+ const loggerWarn = logger.warn as jest.Mock
33
+ const loggerError = logger.error as jest.Mock
34
+
35
+ const context: EnricherContext = {
36
+ organizationId: 'org-1',
37
+ tenantId: 'tenant-1',
38
+ userId: 'user-1',
39
+ em: {},
40
+ container: {},
41
+ }
42
+
43
+ function makeEntry(id: string): EnricherRegistryEntry {
44
+ const enricher: ResponseEnricher<Record<string, unknown>, Record<string, unknown>> = {
45
+ id,
46
+ targetEntity: 'customers.person',
47
+ enrichOne: async (record) => record,
48
+ enrichMany: async (records) => records,
49
+ }
50
+ return { moduleId: 'test', enricher }
51
+ }
52
+
53
+ function makeCriticalEnrichingEntry(id: string): EnricherRegistryEntry {
54
+ const entry = makeEntry(id)
55
+ entry.enricher.critical = true
56
+ entry.enricher.enrichMany = async (records) =>
57
+ records.map((record) => ({ ...record, enriched: true }))
58
+ entry.enricher.enrichOne = async (record) => ({ ...record, enriched: true })
59
+ return entry
60
+ }
61
+
62
+ function mockNow(values: number[]): jest.SpyInstance<number, []> {
63
+ let index = 0
64
+ return jest.spyOn(Date, 'now').mockImplementation(() => {
65
+ const value = values[index]
66
+ if (value === undefined) {
67
+ throw new Error('[internal] Missing mocked Date.now() value')
68
+ }
69
+ index += 1
70
+ return value
71
+ })
72
+ }
73
+
74
+ function makeRuntime(recordHistogram: jest.Mock): TelemetryRuntime {
75
+ return {
76
+ canUseGlobalTracePropagation: () => false,
77
+ captureTraceContext: () => ({}),
78
+ continueTrace: (_carrier, _name, fn) => fn(),
79
+ recordHistogram,
80
+ recordHttpDuration: () => {},
81
+ reportError: () => {},
82
+ shutdown: async () => {},
83
+ }
84
+ }
85
+
86
+ beforeEach(() => {
87
+ jest.useFakeTimers({ doNotFake: ['Date'] })
88
+ loggerDebug.mockClear()
89
+ loggerWarn.mockClear()
90
+ loggerError.mockClear()
91
+ resetTelemetryRuntime()
92
+ })
93
+
94
+ afterEach(() => {
95
+ jest.restoreAllMocks()
96
+ jest.useRealTimers()
97
+ resetTelemetryRuntime()
98
+ })
99
+
100
+ describe('enricher performance reporting', () => {
101
+ it('throttles terminal slow logs per enricher and reports suppressed observations', async () => {
102
+ const entry = makeEntry('test.throttle')
103
+ mockNow([0, 600, 1_000, 1_700, 2_000, 2_900, 31_000, 31_800])
104
+
105
+ for (let attempt = 0; attempt < 4; attempt += 1) {
106
+ await applyResponseEnrichers([{ id: 'person-1' }], 'customers.person', context, [entry])
107
+ }
108
+
109
+ expect(loggerError).toHaveBeenCalledTimes(2)
110
+ expect(loggerError).toHaveBeenNthCalledWith(1, 'Enricher exceeded slow threshold', {
111
+ enricherId: 'test.throttle',
112
+ elapsedMs: 600,
113
+ thresholdMs: 500,
114
+ suppressedCount: 0,
115
+ maxElapsedMs: 600,
116
+ })
117
+ expect(loggerError).toHaveBeenNthCalledWith(2, 'Enricher exceeded slow threshold', {
118
+ enricherId: 'test.throttle',
119
+ elapsedMs: 800,
120
+ thresholdMs: 500,
121
+ suppressedCount: 2,
122
+ maxElapsedMs: 900,
123
+ })
124
+ expect(loggerWarn).not.toHaveBeenCalled()
125
+ })
126
+
127
+ it('routes the diagnostic threshold to debug and the terminal threshold to error', async () => {
128
+ mockNow([0, 100, 1_000, 1_150, 2_000, 2_600])
129
+
130
+ await applyResponseEnrichers(
131
+ [{ id: 'person-1' }],
132
+ 'customers.person',
133
+ context,
134
+ [makeEntry('test.fast')],
135
+ )
136
+ await applyResponseEnrichers(
137
+ [{ id: 'person-1' }],
138
+ 'customers.person',
139
+ context,
140
+ [makeEntry('test.diagnostic')],
141
+ )
142
+ await applyResponseEnrichers(
143
+ [{ id: 'person-1' }],
144
+ 'customers.person',
145
+ context,
146
+ [makeEntry('test.terminal')],
147
+ )
148
+
149
+ expect(loggerDebug).toHaveBeenCalledTimes(1)
150
+ expect(loggerDebug).toHaveBeenCalledWith('Enricher exceeded slow threshold', {
151
+ enricherId: 'test.diagnostic',
152
+ elapsedMs: 150,
153
+ thresholdMs: 100,
154
+ })
155
+ expect(loggerError).toHaveBeenCalledTimes(1)
156
+ expect(loggerError).toHaveBeenCalledWith(
157
+ 'Enricher exceeded slow threshold',
158
+ expect.objectContaining({
159
+ enricherId: 'test.terminal',
160
+ elapsedMs: 600,
161
+ thresholdMs: 500,
162
+ }),
163
+ )
164
+ expect(loggerWarn).not.toHaveBeenCalled()
165
+ })
166
+
167
+ it('emits list and record duration histograms only through an active runtime', async () => {
168
+ const recordHistogram = jest.fn()
169
+ registerTelemetryRuntime(makeRuntime(recordHistogram))
170
+ mockNow([0, 250, 1_000, 1_050])
171
+
172
+ await applyResponseEnrichers(
173
+ [{ id: 'person-1' }],
174
+ 'customers.person',
175
+ context,
176
+ [makeEntry('test.metric-list')],
177
+ )
178
+ await applyResponseEnricherToRecord(
179
+ { id: 'person-1' },
180
+ 'customers.person',
181
+ context,
182
+ [makeEntry('test.metric-record')],
183
+ )
184
+
185
+ expect(recordHistogram).toHaveBeenNthCalledWith(
186
+ 1,
187
+ 'om.enricher.duration',
188
+ 0.25,
189
+ { 'enricher.id': 'test.metric-list' },
190
+ 's',
191
+ )
192
+ expect(recordHistogram).toHaveBeenNthCalledWith(
193
+ 2,
194
+ 'om.enricher.duration',
195
+ 0.05,
196
+ { 'enricher.id': 'test.metric-record' },
197
+ 's',
198
+ )
199
+ })
200
+
201
+ it('preserves successful critical enrichment and throttles the warning per enricher when histogram recording fails', async () => {
202
+ const entry = makeCriticalEnrichingEntry('test.metric-failure')
203
+ const otherEntry = makeCriticalEnrichingEntry('test.metric-failure-other')
204
+ const telemetryError = new Error('[internal] Telemetry provider failed')
205
+ const recordHistogram = jest.fn(() => {
206
+ throw telemetryError
207
+ })
208
+ registerTelemetryRuntime(makeRuntime(recordHistogram))
209
+ mockNow([0, 25, 1_000, 1_010, 2_000, 2_020, 31_000, 31_030])
210
+
211
+ const listResult = await applyResponseEnrichers(
212
+ [{ id: 'person-1' }],
213
+ 'customers.person',
214
+ context,
215
+ [entry],
216
+ )
217
+ const recordResult = await applyResponseEnricherToRecord(
218
+ { id: 'person-1' },
219
+ 'customers.person',
220
+ context,
221
+ [entry],
222
+ )
223
+
224
+ expect(listResult).toEqual({
225
+ items: [{ id: 'person-1', enriched: true }],
226
+ _meta: { enrichedBy: ['test.metric-failure'] },
227
+ })
228
+ expect(recordResult).toEqual({
229
+ record: { id: 'person-1', enriched: true },
230
+ _meta: { enrichedBy: ['test.metric-failure'] },
231
+ })
232
+ expect(recordHistogram).toHaveBeenCalledTimes(2)
233
+ expect(loggerWarn).toHaveBeenCalledTimes(1)
234
+ expect(loggerWarn).toHaveBeenCalledWith('Enricher duration metric recording failed', {
235
+ enricherId: 'test.metric-failure',
236
+ metric: 'om.enricher.duration',
237
+ err: telemetryError,
238
+ })
239
+
240
+ const otherRecordResult = await applyResponseEnricherToRecord(
241
+ { id: 'person-1' },
242
+ 'customers.person',
243
+ context,
244
+ [otherEntry],
245
+ )
246
+
247
+ expect(otherRecordResult).toEqual({
248
+ record: { id: 'person-1', enriched: true },
249
+ _meta: { enrichedBy: ['test.metric-failure-other'] },
250
+ })
251
+ expect(loggerWarn).toHaveBeenCalledTimes(2)
252
+ expect(loggerWarn).toHaveBeenNthCalledWith(2, 'Enricher duration metric recording failed', {
253
+ enricherId: 'test.metric-failure-other',
254
+ metric: 'om.enricher.duration',
255
+ err: telemetryError,
256
+ })
257
+
258
+ await applyResponseEnrichers([{ id: 'person-1' }], 'customers.person', context, [entry])
259
+
260
+ expect(recordHistogram).toHaveBeenCalledTimes(4)
261
+ expect(loggerWarn).toHaveBeenCalledTimes(3)
262
+ expect(loggerWarn).toHaveBeenNthCalledWith(3, 'Enricher duration metric recording failed', {
263
+ enricherId: 'test.metric-failure',
264
+ metric: 'om.enricher.duration',
265
+ err: telemetryError,
266
+ })
267
+ })
268
+ })
4
269
 
5
270
  describe('enricher runner', () => {
6
271
  it('partitions wildcard read-through cache entries by concrete entity', async () => {
@@ -29,7 +294,7 @@ describe('enricher runner', () => {
29
294
  }
30
295
  registerResponseEnrichers([{ moduleId: 'test', enrichers: [enricher] }])
31
296
 
32
- const context: EnricherContext = {
297
+ const cacheContext: EnricherContext = {
33
298
  organizationId: 'org-1',
34
299
  tenantId: 'tenant-1',
35
300
  userId: 'user-1',
@@ -40,12 +305,12 @@ describe('enricher runner', () => {
40
305
  const personResult = await applyResponseEnricherToRecord(
41
306
  { id: 'shared-id' },
42
307
  'customers.person',
43
- context,
308
+ cacheContext,
44
309
  )
45
310
  const orderResult = await applyResponseEnricherToRecord(
46
311
  { id: 'shared-id' },
47
312
  'sales.order',
48
- context,
313
+ cacheContext,
49
314
  )
50
315
 
51
316
  expect(personResult.record).toMatchObject({ enrichedFrom: 'customers.person' })