@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (195) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +57 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +117 -43
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/commands/command-bus.js +5 -1
  15. package/dist/lib/commands/command-bus.js.map +2 -2
  16. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  17. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/enricher-runner.js +2 -11
  19. package/dist/lib/crud/enricher-runner.js.map +2 -2
  20. package/dist/lib/crud/factory.js +72 -9
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/interceptor-runner.js +2 -2
  23. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  24. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  25. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  26. package/dist/lib/crud/types.js +4 -0
  27. package/dist/lib/crud/types.js.map +3 -3
  28. package/dist/lib/data/consistency.js +19 -0
  29. package/dist/lib/data/consistency.js.map +7 -0
  30. package/dist/lib/data/engine.js +37 -11
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/db/entityMetadata.js +26 -0
  33. package/dist/lib/db/entityMetadata.js.map +7 -0
  34. package/dist/lib/db/pg-errors.js +42 -0
  35. package/dist/lib/db/pg-errors.js.map +2 -2
  36. package/dist/lib/di/container.js +48 -6
  37. package/dist/lib/di/container.js.map +2 -2
  38. package/dist/lib/email/send.js +56 -0
  39. package/dist/lib/email/send.js.map +2 -2
  40. package/dist/lib/encryption/subscriber.js +2 -10
  41. package/dist/lib/encryption/subscriber.js.map +2 -2
  42. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  43. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  44. package/dist/lib/i18n/config.js +1 -1
  45. package/dist/lib/i18n/config.js.map +2 -2
  46. package/dist/lib/i18n/server.js +4 -4
  47. package/dist/lib/i18n/server.js.map +3 -3
  48. package/dist/lib/logger/extension.js +29 -0
  49. package/dist/lib/logger/extension.js.map +7 -0
  50. package/dist/lib/logger/index.js +47 -3
  51. package/dist/lib/logger/index.js.map +3 -3
  52. package/dist/lib/modules/registry.js +5 -1
  53. package/dist/lib/modules/registry.js.map +2 -2
  54. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  55. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  56. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  57. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  58. package/dist/lib/query/engine.js +54 -40
  59. package/dist/lib/query/engine.js.map +2 -2
  60. package/dist/lib/query/types.js.map +1 -1
  61. package/dist/lib/redis/connection.js +7 -2
  62. package/dist/lib/redis/connection.js.map +2 -2
  63. package/dist/lib/search/auto-indexing.js +14 -0
  64. package/dist/lib/search/auto-indexing.js.map +7 -0
  65. package/dist/lib/search/availability.js +107 -0
  66. package/dist/lib/search/availability.js.map +7 -0
  67. package/dist/lib/search/config.js +61 -2
  68. package/dist/lib/search/config.js.map +2 -2
  69. package/dist/lib/search/tokenLookup.js +46 -0
  70. package/dist/lib/search/tokenLookup.js.map +7 -0
  71. package/dist/lib/search/tokenize.js +23 -11
  72. package/dist/lib/search/tokenize.js.map +2 -2
  73. package/dist/lib/telemetry/runtime.js +35 -0
  74. package/dist/lib/telemetry/runtime.js.map +7 -0
  75. package/dist/lib/version.js +1 -1
  76. package/dist/lib/version.js.map +1 -1
  77. package/dist/lib/webhooks/body.js +57 -0
  78. package/dist/lib/webhooks/body.js.map +7 -0
  79. package/dist/lib/webhooks/inbound-types.js +1 -0
  80. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  81. package/dist/lib/webhooks/index.js +13 -1
  82. package/dist/lib/webhooks/index.js.map +2 -2
  83. package/dist/lib/webhooks/verify.js +12 -7
  84. package/dist/lib/webhooks/verify.js.map +2 -2
  85. package/dist/modules/analytics.js.map +2 -2
  86. package/dist/modules/overrides.js +50 -1
  87. package/dist/modules/overrides.js.map +2 -2
  88. package/dist/modules/payment_gateways/types.js +8 -1
  89. package/dist/modules/payment_gateways/types.js.map +2 -2
  90. package/dist/modules/registry.js +1 -0
  91. package/dist/modules/registry.js.map +2 -2
  92. package/dist/modules/widgets/extension-points.js +96 -0
  93. package/dist/modules/widgets/extension-points.js.map +7 -0
  94. package/dist/security/enabledModulesRegistry.js +56 -11
  95. package/dist/security/enabledModulesRegistry.js.map +2 -2
  96. package/dist/security/featurePolicy.js +62 -0
  97. package/dist/security/featurePolicy.js.map +7 -0
  98. package/jest.config.cjs +2 -1
  99. package/package.json +19 -9
  100. package/scripts/versionSource.cjs +65 -0
  101. package/src/lib/__tests__/versionSource.test.ts +46 -0
  102. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  103. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  104. package/src/lib/ai/llm-provider.ts +33 -0
  105. package/src/lib/ai/safety-identifier.ts +73 -0
  106. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  107. package/src/lib/auth/__tests__/server.test.ts +23 -0
  108. package/src/lib/auth/organizationScope.ts +65 -0
  109. package/src/lib/auth/server.ts +23 -2
  110. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  111. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  112. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  113. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  114. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  115. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  116. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  117. package/src/lib/bootstrap/dynamicLoader.ts +213 -65
  118. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  119. package/src/lib/commands/command-bus.ts +5 -1
  120. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  121. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  122. package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
  123. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  124. package/src/lib/crud/enricher-runner.ts +2 -11
  125. package/src/lib/crud/factory.ts +82 -6
  126. package/src/lib/crud/interceptor-runner.ts +2 -2
  127. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  128. package/src/lib/crud/types.ts +3 -0
  129. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  130. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  131. package/src/lib/data/consistency.ts +17 -0
  132. package/src/lib/data/engine.ts +50 -16
  133. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  134. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  135. package/src/lib/db/entityMetadata.ts +43 -0
  136. package/src/lib/db/pg-errors.ts +58 -0
  137. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  138. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  139. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  140. package/src/lib/di/container.ts +72 -8
  141. package/src/lib/email/__tests__/send.test.ts +37 -1
  142. package/src/lib/email/send.ts +79 -0
  143. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  144. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  145. package/src/lib/encryption/subscriber.ts +2 -14
  146. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  147. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  148. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  149. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  150. package/src/lib/i18n/config.ts +2 -2
  151. package/src/lib/i18n/server.ts +6 -2
  152. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  153. package/src/lib/logger/extension.ts +59 -0
  154. package/src/lib/logger/index.ts +59 -1
  155. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  156. package/src/lib/modules/registry.ts +10 -0
  157. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  158. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  159. package/src/lib/query/__tests__/engine.test.ts +65 -13
  160. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  161. package/src/lib/query/engine.ts +75 -58
  162. package/src/lib/query/types.ts +4 -5
  163. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  164. package/src/lib/redis/connection.ts +10 -7
  165. package/src/lib/search/__tests__/availability.test.ts +211 -0
  166. package/src/lib/search/__tests__/config.test.ts +177 -0
  167. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  168. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  169. package/src/lib/search/auto-indexing.ts +22 -0
  170. package/src/lib/search/availability.ts +232 -0
  171. package/src/lib/search/config.ts +106 -8
  172. package/src/lib/search/tokenLookup.ts +133 -0
  173. package/src/lib/search/tokenize.ts +33 -11
  174. package/src/lib/telemetry/runtime.ts +72 -0
  175. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  176. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  177. package/src/lib/webhooks/body.ts +70 -0
  178. package/src/lib/webhooks/inbound-types.ts +125 -0
  179. package/src/lib/webhooks/index.ts +19 -1
  180. package/src/lib/webhooks/verify.ts +17 -9
  181. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  182. package/src/modules/__tests__/registry.test.ts +10 -2
  183. package/src/modules/analytics.ts +7 -0
  184. package/src/modules/customer-auth.ts +1 -0
  185. package/src/modules/encryption.ts +3 -0
  186. package/src/modules/navigation/backendChrome.ts +15 -0
  187. package/src/modules/overrides.ts +103 -0
  188. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  189. package/src/modules/payment_gateways/types.ts +41 -0
  190. package/src/modules/registry.ts +3 -0
  191. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  192. package/src/modules/widgets/extension-points.ts +524 -0
  193. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  194. package/src/security/enabledModulesRegistry.ts +64 -12
  195. package/src/security/featurePolicy.ts +89 -0
@@ -6,7 +6,7 @@ import type {
6
6
  InterceptorBeforeResult,
7
7
  } from './api-interceptor'
8
8
  import { getApiInterceptorsForRoute } from './interceptor-registry'
9
- import { hasAllFeatures } from '../../security/features'
9
+ import { authorizeFeatures } from '../../security/featurePolicy'
10
10
  import { logInterceptorActivity } from '../umes/interceptor-activity'
11
11
 
12
12
  const DEFAULT_TIMEOUT_MS = 5000
@@ -39,7 +39,7 @@ function sanitizeObject(input?: Record<string, unknown>): Record<string, unknown
39
39
  }
40
40
 
41
41
  function hasRequiredFeatures(features: string[] | undefined, userFeatures: string[] | undefined): boolean {
42
- return hasAllFeatures(userFeatures, features)
42
+ return authorizeFeatures(features ?? [], { grantedFeatures: userFeatures ?? [] })
43
43
  }
44
44
 
45
45
  function timeoutPromise(ms: number, interceptorId: string): Promise<never> {
@@ -1,5 +1,5 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
- import { hasAllFeatures } from '../../security/features'
2
+ import { authorizeFeatures } from '../../security/featurePolicy'
3
3
  import { resolveCrudMutationGuardService } from './mutation-guard-service'
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -101,7 +101,7 @@ export async function runMutationGuards(
101
101
  const matching = guards
102
102
  .filter((g) => matchesEntity(g.targetEntity, input.resourceKind))
103
103
  .filter((g) => g.operations.includes(input.operation))
104
- .filter((g) => hasAllFeatures(context.userFeatures, g.features))
104
+ .filter((g) => authorizeFeatures(g.features ?? [], { grantedFeatures: context.userFeatures }))
105
105
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
106
106
 
107
107
  let payload = input.mutationPayload
@@ -1,5 +1,8 @@
1
1
  export type CrudEventAction = 'created' | 'updated' | 'deleted'
2
2
 
3
+ /** Internal payload marker: the data engine owns this CRUD event's query-index decision. */
4
+ export const CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY = '__omQueryIndexManaged' as const
5
+
3
6
  export type CrudEntityIdentifiers = {
4
7
  id: string
5
8
  organizationId: string | null
@@ -0,0 +1,38 @@
1
+ import {
2
+ __resetAlwaysConsistentCacheForTests,
3
+ isReadProjectionAlwaysConsistent,
4
+ parseAlwaysConsistentEnv,
5
+ } from '../consistency'
6
+
7
+ describe('read projection consistency flag', () => {
8
+ const originalEnv = process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
9
+
10
+ afterEach(() => {
11
+ if (originalEnv === undefined) {
12
+ delete process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
13
+ } else {
14
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = originalEnv
15
+ }
16
+ __resetAlwaysConsistentCacheForTests()
17
+ })
18
+
19
+ it.each([undefined, null, '', ' ', 'off', 'false', '0', 'no', 'disabled', 'none', 'unexpected'])(
20
+ 'parses %p as OFF',
21
+ (raw) => {
22
+ expect(parseAlwaysConsistentEnv(raw)).toBe(false)
23
+ },
24
+ )
25
+
26
+ it.each(['on', 'true', '1', 'yes', 'enabled'])('parses %p as ON', (raw) => {
27
+ expect(parseAlwaysConsistentEnv(raw)).toBe(true)
28
+ })
29
+
30
+ it('memoizes the env value until reset for tests', () => {
31
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'on'
32
+ expect(isReadProjectionAlwaysConsistent()).toBe(true)
33
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'off'
34
+ expect(isReadProjectionAlwaysConsistent()).toBe(true)
35
+ __resetAlwaysConsistentCacheForTests()
36
+ expect(isReadProjectionAlwaysConsistent()).toBe(false)
37
+ })
38
+ })
@@ -1,7 +1,11 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { DefaultDataEngine } from '../engine'
4
- import type { CrudEventsConfig, CrudIndexerConfig } from '../../crud/types'
4
+ import {
5
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
6
+ type CrudEventsConfig,
7
+ type CrudIndexerConfig,
8
+ } from '../../crud/types'
5
9
 
6
10
  // The bulk-import deferral (`suppress`) must gate the two per-record side effects `emitOrmEntityEvent`
7
11
  // fans out — the `<module>.<entity>.<action>` domain event and the inline `query_index.upsert_one`
@@ -25,6 +29,15 @@ function buildEngine() {
25
29
  return { engine, emitEvent, emittedNames }
26
30
  }
27
31
 
32
+ function expectManagedDomainPayload(emitEvent: jest.Mock, eventName: string): void {
33
+ const call = emitEvent.mock.calls.find(([name]) => name === eventName)
34
+ expect(call).toBeDefined()
35
+ const payload = call?.[1] as Record<string, unknown>
36
+ expect(payload[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY]).toBe(true)
37
+ expect(Object.keys(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
38
+ expect(JSON.stringify(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
39
+ }
40
+
28
41
  describe('DefaultDataEngine bulk-import suppression', () => {
29
42
  // The test events are intentionally not registered in the event registry; silence the
30
43
  // one-time "undeclared event" warning so it doesn't clutter the suite output.
@@ -33,9 +46,10 @@ describe('DefaultDataEngine bulk-import suppression', () => {
33
46
  afterAll(() => { warnSpy.mockRestore() })
34
47
 
35
48
  it('emits both the domain event and the reindex when unsuppressed', async () => {
36
- const { engine, emittedNames } = buildEngine()
49
+ const { engine, emitEvent, emittedNames } = buildEngine()
37
50
  await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS })
38
51
  expect(emittedNames()).toEqual(expect.arrayContaining(['sales.order.created', 'query_index.upsert_one']))
52
+ expectManagedDomainPayload(emitEvent, 'sales.order.created')
39
53
  })
40
54
 
41
55
  it('skips the domain event but keeps the reindex with skipEvents', async () => {
@@ -47,11 +61,12 @@ describe('DefaultDataEngine bulk-import suppression', () => {
47
61
  })
48
62
 
49
63
  it('skips the reindex but keeps the domain event with skipReindex', async () => {
50
- const { engine, emittedNames } = buildEngine()
64
+ const { engine, emitEvent, emittedNames } = buildEngine()
51
65
  await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS, suppress: { skipReindex: true } })
52
66
  const names = emittedNames()
53
67
  expect(names).toContain('sales.order.created')
54
68
  expect(names).not.toContain('query_index.upsert_one')
69
+ expectManagedDomainPayload(emitEvent, 'sales.order.created')
55
70
  })
56
71
 
57
72
  it('emits nothing when both are suppressed', async () => {
@@ -0,0 +1,17 @@
1
+ import { parseBooleanWithDefault } from '../boolean'
2
+
3
+ let alwaysConsistentFlag: boolean | null = null
4
+
5
+ export function parseAlwaysConsistentEnv(raw: string | undefined | null): boolean {
6
+ return parseBooleanWithDefault(raw, false)
7
+ }
8
+
9
+ export function isReadProjectionAlwaysConsistent(): boolean {
10
+ if (alwaysConsistentFlag !== null) return alwaysConsistentFlag
11
+ alwaysConsistentFlag = parseAlwaysConsistentEnv(process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT)
12
+ return alwaysConsistentFlag
13
+ }
14
+
15
+ export function __resetAlwaysConsistentCacheForTests(): void {
16
+ alwaysConsistentFlag = null
17
+ }
@@ -6,11 +6,12 @@ import { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/h
6
6
  import { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'
7
7
  import { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'
8
8
  import type { EventBus } from '@open-mercato/events/types'
9
- import type {
10
- CrudEventAction,
11
- CrudEventsConfig,
12
- CrudIndexerConfig,
13
- CrudEntityIdentifiers,
9
+ import {
10
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
11
+ type CrudEventAction,
12
+ type CrudEventsConfig,
13
+ type CrudIndexerConfig,
14
+ type CrudEntityIdentifiers,
14
15
  } from '../crud/types'
15
16
  import type { BulkImportSuppression } from '../commands/types'
16
17
  import { CrudHttpError } from '../crud/errors'
@@ -18,6 +19,7 @@ import { resolveRegisteredEntityTableName } from '../query/engine'
18
19
  import { getEntityIds } from '../encryption/entityIds'
19
20
  import { normalizeCustomFieldValues } from '../custom-fields/normalize'
20
21
  import { parseBooleanToken } from '../boolean'
22
+ import { isReadProjectionAlwaysConsistent } from './consistency'
21
23
  import { isEventDeclared } from '../../modules/events'
22
24
  import { createLogger } from '../logger'
23
25
 
@@ -598,7 +600,7 @@ export class DefaultDataEngine implements DataEngine {
598
600
  if (events && !suppress?.skipEvents) {
599
601
  const eventName = `${events.module}.${events.entity}.${action}`
600
602
  warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')
601
- const payload = events.buildPayload
603
+ const builtPayload = events.buildPayload
602
604
  ? events.buildPayload(ctx)
603
605
  : {
604
606
  id: ctx.identifiers.id,
@@ -606,6 +608,20 @@ export class DefaultDataEngine implements DataEngine {
606
608
  tenantId: ctx.identifiers.tenantId,
607
609
  ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
608
610
  }
611
+ // A configured indexer means this data-engine call owns the query-index
612
+ // decision, including an explicit skipReindex suppression. Mark object
613
+ // payloads so the legacy domain-event bridge does not enqueue the same
614
+ // record a second time. Keep the marker non-enumerable so client broadcasts
615
+ // and persisted domain payloads retain their existing public shape.
616
+ // Primitive custom payloads cannot be bridged in any case because they do
617
+ // not expose the record id.
618
+ const payload = indexer && builtPayload && typeof builtPayload === 'object' && !Array.isArray(builtPayload)
619
+ ? Object.defineProperty(
620
+ { ...(builtPayload as Record<string, unknown>) },
621
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
622
+ { value: true, enumerable: false },
623
+ )
624
+ : builtPayload
609
625
  try {
610
626
  await bus.emitEvent(eventName, payload, {
611
627
  persistent: !!events.persistent,
@@ -618,6 +634,7 @@ export class DefaultDataEngine implements DataEngine {
618
634
  }
619
635
 
620
636
  if (indexer && !suppress?.skipReindex) {
637
+ const alwaysConsistent = isReadProjectionAlwaysConsistent()
621
638
  const resolveCoverageBaseDelta = (): number | undefined => {
622
639
  if (action === 'created') return 1
623
640
  if (action === 'deleted') return -1
@@ -643,9 +660,14 @@ export class DefaultDataEngine implements DataEngine {
643
660
  // returns. The subscriber removes the projection row + tokens synchronously and
644
661
  // defers the coverage recompute + fulltext delete, so this stays bounded.
645
662
  // Errors are logged, not thrown — index drift never fails the originating write.
646
- await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
647
- logger.error('query_index.delete_one emit failed', { err })
648
- })
663
+ // Always-consistent mode rethrows so drift is loud and retryable.
664
+ if (alwaysConsistent) {
665
+ await bus.emitEvent('query_index.delete_one', enrichedPayload, { rethrowHandlerErrors: true })
666
+ } else {
667
+ await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
668
+ logger.error('query_index.delete_one emit failed', { err })
669
+ })
670
+ }
649
671
  } else {
650
672
  const payload = indexer.buildUpsertPayload
651
673
  ? indexer.buildUpsertPayload(ctx)
@@ -663,18 +685,27 @@ export class DefaultDataEngine implements DataEngine {
663
685
  // (see delete_one above). The subscriber updates `entity_indexes` synchronously
664
686
  // and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
665
687
  // tokenize + DELETE + chunked INSERT) so write latency stays bounded.
666
- await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
667
- logger.error('query_index.upsert_one emit failed', { err })
668
- })
688
+ if (alwaysConsistent) {
689
+ await bus.emitEvent('query_index.upsert_one', enrichedPayload, { rethrowHandlerErrors: true })
690
+ } else {
691
+ await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
692
+ logger.error('query_index.upsert_one emit failed', { err })
693
+ })
694
+ }
669
695
  }
670
696
 
671
- if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
672
- void bus.emitEvent('query_index.coverage.refresh', {
697
+ if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
698
+ const coveragePayload = {
673
699
  entityType: indexer.entityType,
674
700
  tenantId: ctx.identifiers.tenantId ?? null,
675
701
  organizationId: null,
676
702
  delayMs: 0,
677
- }).catch(() => undefined)
703
+ }
704
+ if (alwaysConsistent) {
705
+ await bus.emitEvent('query_index.coverage.refresh', coveragePayload, { rethrowHandlerErrors: true })
706
+ } else {
707
+ void bus.emitEvent('query_index.coverage.refresh', coveragePayload).catch(() => undefined)
708
+ }
678
709
  }
679
710
  }
680
711
  }
@@ -739,7 +770,10 @@ export class DefaultDataEngine implements DataEngine {
739
770
  indexer: entry.indexer as CrudIndexerConfig<unknown>,
740
771
  suppress,
741
772
  })
742
- } catch {
773
+ } catch (error) {
774
+ if (isReadProjectionAlwaysConsistent()) {
775
+ throw error
776
+ }
743
777
  // best-effort; continue with remaining side effects
744
778
  }
745
779
  }
@@ -0,0 +1,38 @@
1
+ import { listEntityMetadata, listEntityMetadataFromRegistry } from '../entityMetadata'
2
+
3
+ const personMeta = { className: 'Person', tableName: 'people' }
4
+ const orderMeta = { className: 'Order', tableName: 'orders' }
5
+
6
+ describe('listEntityMetadataFromRegistry', () => {
7
+ it('reads the MikroORM v7 Map returned by getAll()', () => {
8
+ const registry = { getAll: () => new Map([[Object, personMeta], [Array, orderMeta]]) }
9
+ expect(listEntityMetadataFromRegistry(registry)).toEqual([personMeta, orderMeta])
10
+ })
11
+
12
+ it('reads the MikroORM v6 dictionary returned by getAll()', () => {
13
+ const registry = { getAll: () => ({ Person: personMeta, Order: orderMeta }) }
14
+ expect(listEntityMetadataFromRegistry(registry)).toEqual([personMeta, orderMeta])
15
+ })
16
+
17
+ it('falls back to the metadata property when getAll() yields nothing', () => {
18
+ const registry = { getAll: () => new Map(), metadata: { Person: personMeta } }
19
+ expect(listEntityMetadataFromRegistry(registry)).toEqual([personMeta])
20
+ })
21
+
22
+ it('returns an empty array for a missing registry', () => {
23
+ expect(listEntityMetadataFromRegistry(null)).toEqual([])
24
+ expect(listEntityMetadataFromRegistry({})).toEqual([])
25
+ })
26
+ })
27
+
28
+ describe('listEntityMetadata', () => {
29
+ it('resolves the registry through getMetadata()', () => {
30
+ const em = { getMetadata: () => ({ getAll: () => new Map([[Object, personMeta]]) }) }
31
+ expect(listEntityMetadata(em)).toEqual([personMeta])
32
+ })
33
+
34
+ it('returns an empty array when the source exposes no registry', () => {
35
+ expect(listEntityMetadata({})).toEqual([])
36
+ expect(listEntityMetadata(undefined)).toEqual([])
37
+ })
38
+ })
@@ -0,0 +1,48 @@
1
+ import { isTransientDbError, isUniqueViolation } from '../pg-errors'
2
+
3
+ describe('isTransientDbError', () => {
4
+ it('is true for the max_connections SQLSTATE', () => {
5
+ expect(isTransientDbError({ code: '53300' })).toBe(true)
6
+ })
7
+
8
+ it('is true for connection-family SQLSTATEs', () => {
9
+ for (const code of ['53400', '57P01', '57P02', '57P03', '08000', '08001', '08003', '08006']) {
10
+ expect(isTransientDbError({ code })).toBe(true)
11
+ }
12
+ })
13
+
14
+ it('is true for driver/ORM-wrapped connection messages that drop the SQLSTATE', () => {
15
+ expect(isTransientDbError(new Error('sorry, too many clients already'))).toBe(true)
16
+ expect(isTransientDbError(new Error('Knex: Timeout acquiring a connection. The pool is probably full.'))).toBe(true)
17
+ expect(isTransientDbError(new Error('Unable to acquire a connection'))).toBe(true)
18
+ expect(isTransientDbError(new Error('Connection terminated unexpectedly'))).toBe(true)
19
+ expect(isTransientDbError(new Error('the database system is starting up'))).toBe(true)
20
+ })
21
+
22
+ it('is false for bare socket errors with no Postgres signal (avoids false positives)', () => {
23
+ // Generic socket failures can come from any outbound connection (HTTP, cache,
24
+ // queue), so they must NOT be attributed to the database.
25
+ expect(isTransientDbError({ code: 'ECONNREFUSED' })).toBe(false)
26
+ expect(isTransientDbError({ code: 'ETIMEDOUT' })).toBe(false)
27
+ expect(isTransientDbError(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))).toBe(false)
28
+ })
29
+
30
+ it('is false for a unique violation and other query-level errors', () => {
31
+ expect(isTransientDbError({ code: '23505' })).toBe(false)
32
+ expect(isTransientDbError({ code: '40P01' })).toBe(false) // deadlock — not a connection failure
33
+ expect(isTransientDbError({ code: '40001' })).toBe(false) // serialization — not a connection failure
34
+ })
35
+
36
+ it('is false for non-DB and empty errors', () => {
37
+ expect(isTransientDbError(new Error('something unrelated broke'))).toBe(false)
38
+ expect(isTransientDbError(null)).toBe(false)
39
+ expect(isTransientDbError(undefined)).toBe(false)
40
+ expect(isTransientDbError('a string')).toBe(false)
41
+ })
42
+
43
+ it('does not overlap with isUniqueViolation', () => {
44
+ const uniqueErr = { code: '23505', message: 'duplicate key value violates unique constraint' }
45
+ expect(isUniqueViolation(uniqueErr)).toBe(true)
46
+ expect(isTransientDbError(uniqueErr)).toBe(false)
47
+ })
48
+ })
@@ -0,0 +1,43 @@
1
+ import type { EntityMetadata } from '@mikro-orm/core'
2
+
3
+ type MetadataRegistryLike = {
4
+ getAll?: () => unknown
5
+ metadata?: unknown
6
+ }
7
+
8
+ type MetadataHolder = {
9
+ getMetadata?: () => unknown
10
+ }
11
+
12
+ /**
13
+ * MikroORM v7 returns a `Map` from `MetadataStorage.getAll()`; v6 returned a plain
14
+ * dictionary. `Object.values(someMap)` evaluates to `[]`, so a v6-era read of the
15
+ * registry silently reports zero entities instead of failing loudly.
16
+ */
17
+ function toMetadataList(value: unknown): EntityMetadata[] {
18
+ if (!value) return []
19
+ if (value instanceof Map) return Array.from(value.values()) as EntityMetadata[]
20
+ if (Array.isArray(value)) return value as EntityMetadata[]
21
+ if (typeof value === 'object') return Object.values(value as Record<string, EntityMetadata>)
22
+ return []
23
+ }
24
+
25
+ export function listEntityMetadataFromRegistry(registry: unknown): EntityMetadata[] {
26
+ const source = registry as MetadataRegistryLike | null | undefined
27
+ if (!source) return []
28
+ if (typeof source.getAll === 'function') {
29
+ const entries = toMetadataList(source.getAll())
30
+ if (entries.length) return entries
31
+ }
32
+ return toMetadataList(source.metadata)
33
+ }
34
+
35
+ /**
36
+ * List every discovered entity metadata from an `EntityManager` or `MikroORM` instance.
37
+ * Returns an empty array when the source exposes no metadata registry.
38
+ */
39
+ export function listEntityMetadata(source: unknown): EntityMetadata[] {
40
+ const holder = source as MetadataHolder | null | undefined
41
+ if (!holder || typeof holder.getMetadata !== 'function') return []
42
+ return listEntityMetadataFromRegistry(holder.getMetadata())
43
+ }
@@ -10,3 +10,61 @@ export function isUniqueViolation(err: unknown): boolean {
10
10
  const message = (err as { message?: string }).message
11
11
  return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)
12
12
  }
13
+
14
+ /**
15
+ * Postgres SQLSTATEs for transient connection / availability failures — the
16
+ * database (or its connection pool) is temporarily unreachable and the request
17
+ * can succeed on retry. Deliberately scoped to connection/availability codes;
18
+ * query-level conflicts (deadlock 40P01, serialization 40001, lock_not_available
19
+ * 55P03) are NOT included because they do not mean "service unavailable".
20
+ */
21
+ const TRANSIENT_CONNECTION_SQLSTATES = new Set([
22
+ '53300', // too_many_connections
23
+ '53400', // configuration_limit_exceeded
24
+ '57P01', // admin_shutdown
25
+ '57P02', // crash_shutdown
26
+ '57P03', // cannot_connect_now (db starting up)
27
+ '08000', // connection_exception
28
+ '08001', // sqlclient_unable_to_establish_sqlconnection
29
+ '08003', // connection_does_not_exist
30
+ '08006', // connection_failure
31
+ ])
32
+
33
+ /**
34
+ * Postgres-driver / connection-pool messages that describe a transient DB
35
+ * connection failure when the SQLSTATE is dropped by the ORM wrapper. These are
36
+ * intentionally DB-specific phrases only. Bare socket codes (ECONNREFUSED,
37
+ * ETIMEDOUT, …) are deliberately NOT matched: they can originate from any
38
+ * outbound socket (HTTP, cache, queue), so keying off them would falsely
39
+ * attribute unrelated failures to the database.
40
+ */
41
+ const TRANSIENT_DB_MESSAGE_PATTERNS = [
42
+ /too many clients already/i,
43
+ /unable to acquire a connection/i,
44
+ /timeout acquiring a connection/i,
45
+ /connection terminated/i,
46
+ /the database system is (starting up|shutting down|in recovery)/i,
47
+ ]
48
+
49
+ /**
50
+ * Detect a transient Postgres connection / availability failure (pool exhaustion,
51
+ * `max_connections` reached, DB restarting) via its SQLSTATE or a DB-specific
52
+ * driver message. Gates retryable 503 responses so callers do not report a
53
+ * temporary infrastructure blip as an auth failure (401) or an unexpected server
54
+ * error (500). Scoped to unambiguous DB signals so generic socket errors from
55
+ * non-DB calls are never misclassified.
56
+ */
57
+ export function isTransientDbError(err: unknown): boolean {
58
+ if (!err || typeof err !== 'object') {
59
+ return false
60
+ }
61
+ const code = (err as { code?: string }).code
62
+ if (typeof code === 'string' && TRANSIENT_CONNECTION_SQLSTATES.has(code)) {
63
+ return true
64
+ }
65
+ const message = (err as { message?: string }).message
66
+ if (typeof message === 'string' && TRANSIENT_DB_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {
67
+ return true
68
+ }
69
+ return false
70
+ }
@@ -0,0 +1,155 @@
1
+ // The app-level DI override module (`@/di`) is OPTIONAL: most apps ship no
2
+ // src/di.ts, and packages contexts cannot resolve the alias at all. Its
3
+ // absence must not produce warnings — only real load/register failures do
4
+ // (covered in container-app-di.test.ts). This file deliberately does NOT
5
+ // mock '@/di', so the import fails with module-not-found, exercising the
6
+ // quiet branch.
7
+
8
+ jest.mock(
9
+ '@open-mercato/shared/lib/db/mikro',
10
+ () => {
11
+ const baseEm: any = {
12
+ fork: () => baseEm,
13
+ getRepository: () => ({ find: async () => [], findOne: async () => null }),
14
+ }
15
+ return {
16
+ __esModule: true,
17
+ getOrm: async () => ({ em: baseEm }),
18
+ getOrmEntities: () => [],
19
+ registerOrmEntities: () => {},
20
+ }
21
+ },
22
+ { virtual: false },
23
+ )
24
+
25
+ jest.mock(
26
+ '@mikro-orm/core',
27
+ () => ({
28
+ __esModule: true,
29
+ RequestContext: { getEntityManager: () => null },
30
+ }),
31
+ { virtual: true },
32
+ )
33
+
34
+ jest.mock(
35
+ '@open-mercato/shared/lib/query/engine',
36
+ () => ({ __esModule: true, BasicQueryEngine: class {} }),
37
+ { virtual: false },
38
+ )
39
+
40
+ jest.mock(
41
+ '@open-mercato/shared/lib/data/engine',
42
+ () => ({ __esModule: true, DefaultDataEngine: class {} }),
43
+ { virtual: false },
44
+ )
45
+
46
+ jest.mock(
47
+ '@open-mercato/shared/lib/commands',
48
+ () => ({
49
+ __esModule: true,
50
+ commandRegistry: {},
51
+ CommandBus: class { constructor() {} },
52
+ }),
53
+ { virtual: false },
54
+ )
55
+
56
+ jest.mock(
57
+ '@open-mercato/shared/modules/overrides',
58
+ () => ({
59
+ __esModule: true,
60
+ applyDiOverridesToContainer: () => {},
61
+ }),
62
+ { virtual: false },
63
+ )
64
+
65
+ // Keep this suite hermetic: without this mock the REAL core bootstrap module
66
+ // loads (and runs its module-level side effects) whenever generated files
67
+ // exist on disk, polluting globalThis state shared with sibling test files.
68
+ jest.mock(
69
+ '@open-mercato/core/bootstrap',
70
+ () => ({
71
+ __esModule: true,
72
+ bootstrap: async () => {},
73
+ }),
74
+ { virtual: true },
75
+ )
76
+
77
+ jest.mock(
78
+ '@open-mercato/shared/lib/encryption/subscriber',
79
+ () => ({
80
+ __esModule: true,
81
+ registerTenantEncryptionSubscriber: () => {},
82
+ }),
83
+ { virtual: true },
84
+ )
85
+
86
+ const mockWarn = jest.fn()
87
+ const mockDebug = jest.fn()
88
+ jest.mock('../../logger', () => ({
89
+ createLogger: () => ({
90
+ debug: (...args: unknown[]) => mockDebug(...args),
91
+ info: jest.fn(),
92
+ warn: (...args: unknown[]) => mockWarn(...args),
93
+ error: jest.fn(),
94
+ child() { return this },
95
+ }),
96
+ }))
97
+
98
+ const {
99
+ createRequestContainer,
100
+ registerAppDiRegistrar,
101
+ registerDiRegistrars,
102
+ resetBootstrapCache,
103
+ } = require('@open-mercato/shared/lib/di/container')
104
+
105
+ describe('app-level DI override hook when @/di is absent', () => {
106
+ beforeEach(() => {
107
+ resetBootstrapCache()
108
+ registerAppDiRegistrar(null)
109
+ registerDiRegistrars([])
110
+ mockWarn.mockClear()
111
+ mockDebug.mockClear()
112
+ })
113
+
114
+ it('creates the container without warning', async () => {
115
+ const firstContainer = await createRequestContainer()
116
+ const secondContainer = await createRequestContainer()
117
+ expect(firstContainer).toBeDefined()
118
+ expect(secondContainer).toBeDefined()
119
+ expect(mockWarn).not.toHaveBeenCalled()
120
+ })
121
+
122
+ // Worker and CLI processes create one request container per job against built package
123
+ // output, where the app's `@/` alias does not exist. Retrying the doomed import per
124
+ // container repeats a failed module resolution — and its log line — once per job.
125
+ it('stops retrying the unresolvable @/di import after the first container', async () => {
126
+ await createRequestContainer()
127
+ await createRequestContainer()
128
+ await createRequestContainer()
129
+
130
+ const unresolvableLogs = mockDebug.mock.calls.filter(
131
+ ([message]) => message === 'App-level DI override module (@/di) not resolvable; skipping',
132
+ )
133
+ expect(unresolvableLogs).toHaveLength(1)
134
+ })
135
+
136
+ it('warns once when @/di fails because a nested app alias is missing', async () => {
137
+ const nestedAliasError = Object.assign(new Error("Cannot find module '@/di/helpers'"), {
138
+ code: 'MODULE_NOT_FOUND',
139
+ })
140
+ jest.doMock('@/di', () => {
141
+ throw nestedAliasError
142
+ }, { virtual: true })
143
+
144
+ const firstContainer = await createRequestContainer()
145
+ const secondContainer = await createRequestContainer()
146
+
147
+ expect(firstContainer).toBeDefined()
148
+ expect(secondContainer).toBeDefined()
149
+ expect(mockWarn).toHaveBeenCalledTimes(1)
150
+ expect(mockWarn).toHaveBeenCalledWith(
151
+ 'App-level DI override module (@/di) failed to load; its registrations are skipped',
152
+ { err: nestedAliasError },
153
+ )
154
+ })
155
+ })