@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 (199) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +6 -57
  3. package/build.mjs +4 -2
  4. package/dist/lib/auth/server.js +2 -15
  5. package/dist/lib/auth/server.js.map +2 -2
  6. package/dist/lib/bootstrap/dynamicLoader.js +43 -117
  7. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  8. package/dist/lib/commands/command-bus.js +1 -5
  9. package/dist/lib/commands/command-bus.js.map +2 -2
  10. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  11. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  12. package/dist/lib/crud/enricher-runner.js +11 -2
  13. package/dist/lib/crud/enricher-runner.js.map +2 -2
  14. package/dist/lib/crud/factory.js +4 -63
  15. package/dist/lib/crud/factory.js.map +2 -2
  16. package/dist/lib/crud/interceptor-runner.js +2 -2
  17. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  19. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  20. package/dist/lib/crud/types.js +0 -4
  21. package/dist/lib/crud/types.js.map +3 -3
  22. package/dist/lib/data/engine.js +11 -37
  23. package/dist/lib/data/engine.js.map +2 -2
  24. package/dist/lib/db/mikro.js +12 -3
  25. package/dist/lib/db/mikro.js.map +2 -2
  26. package/dist/lib/db/pg-errors.js +0 -42
  27. package/dist/lib/db/pg-errors.js.map +2 -2
  28. package/dist/lib/di/container.js +6 -48
  29. package/dist/lib/di/container.js.map +2 -2
  30. package/dist/lib/email/send.js +0 -56
  31. package/dist/lib/email/send.js.map +2 -2
  32. package/dist/lib/encryption/subscriber.js +10 -2
  33. package/dist/lib/encryption/subscriber.js.map +2 -2
  34. package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
  35. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  36. package/dist/lib/i18n/config.js +1 -1
  37. package/dist/lib/i18n/config.js.map +2 -2
  38. package/dist/lib/i18n/server.js +4 -4
  39. package/dist/lib/i18n/server.js.map +3 -3
  40. package/dist/lib/logger/index.js +3 -47
  41. package/dist/lib/logger/index.js.map +3 -3
  42. package/dist/lib/modules/registry.js +1 -5
  43. package/dist/lib/modules/registry.js.map +2 -2
  44. package/dist/lib/query/engine.js +40 -54
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/query/types.js.map +1 -1
  47. package/dist/lib/redis/connection.js +2 -7
  48. package/dist/lib/redis/connection.js.map +2 -2
  49. package/dist/lib/search/config.js +2 -61
  50. package/dist/lib/search/config.js.map +2 -2
  51. package/dist/lib/search/tokenize.js +11 -23
  52. package/dist/lib/search/tokenize.js.map +2 -2
  53. package/dist/lib/version.js +1 -1
  54. package/dist/lib/version.js.map +1 -1
  55. package/dist/lib/webhooks/index.js +1 -13
  56. package/dist/lib/webhooks/index.js.map +2 -2
  57. package/dist/lib/webhooks/verify.js +7 -12
  58. package/dist/lib/webhooks/verify.js.map +2 -2
  59. package/dist/modules/analytics.js.map +2 -2
  60. package/dist/modules/overrides.js +1 -50
  61. package/dist/modules/overrides.js.map +2 -2
  62. package/dist/modules/payment_gateways/types.js +1 -8
  63. package/dist/modules/payment_gateways/types.js.map +2 -2
  64. package/dist/modules/registry.js +0 -1
  65. package/dist/modules/registry.js.map +2 -2
  66. package/dist/security/enabledModulesRegistry.js +11 -56
  67. package/dist/security/enabledModulesRegistry.js.map +2 -2
  68. package/jest.config.cjs +1 -2
  69. package/package.json +9 -19
  70. package/src/lib/ai/llm-provider.ts +0 -33
  71. package/src/lib/auth/__tests__/server.test.ts +0 -23
  72. package/src/lib/auth/server.ts +2 -23
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
  75. package/src/lib/bootstrap/dynamicLoader.ts +65 -213
  76. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
  77. package/src/lib/commands/command-bus.ts +1 -5
  78. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  79. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
  80. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
  81. package/src/lib/crud/enricher-runner.ts +11 -2
  82. package/src/lib/crud/factory.ts +1 -69
  83. package/src/lib/crud/interceptor-runner.ts +2 -2
  84. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  85. package/src/lib/crud/types.ts +0 -3
  86. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
  87. package/src/lib/data/engine.ts +16 -50
  88. package/src/lib/db/__tests__/mikro.test.ts +25 -0
  89. package/src/lib/db/mikro.ts +38 -13
  90. package/src/lib/db/pg-errors.ts +0 -58
  91. package/src/lib/di/container.ts +8 -72
  92. package/src/lib/email/__tests__/send.test.ts +1 -37
  93. package/src/lib/email/send.ts +0 -79
  94. package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
  95. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
  96. package/src/lib/encryption/subscriber.ts +14 -2
  97. package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
  98. package/src/lib/i18n/config.ts +2 -2
  99. package/src/lib/i18n/server.ts +2 -6
  100. package/src/lib/logger/__tests__/logger.test.ts +0 -77
  101. package/src/lib/logger/index.ts +1 -59
  102. package/src/lib/modules/registry.ts +0 -10
  103. package/src/lib/query/__tests__/engine.test.ts +13 -65
  104. package/src/lib/query/engine.ts +58 -75
  105. package/src/lib/query/types.ts +5 -4
  106. package/src/lib/redis/__tests__/connection.test.ts +0 -16
  107. package/src/lib/redis/connection.ts +7 -10
  108. package/src/lib/search/__tests__/config.test.ts +0 -177
  109. package/src/lib/search/config.ts +8 -106
  110. package/src/lib/search/tokenize.ts +11 -33
  111. package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
  112. package/src/lib/webhooks/index.ts +1 -19
  113. package/src/lib/webhooks/verify.ts +9 -17
  114. package/src/modules/__tests__/registry.test.ts +2 -10
  115. package/src/modules/analytics.ts +0 -7
  116. package/src/modules/customer-auth.ts +0 -1
  117. package/src/modules/encryption.ts +0 -3
  118. package/src/modules/navigation/backendChrome.ts +0 -15
  119. package/src/modules/overrides.ts +0 -103
  120. package/src/modules/payment_gateways/types.ts +0 -41
  121. package/src/modules/registry.ts +0 -3
  122. package/src/security/enabledModulesRegistry.ts +12 -64
  123. package/dist/lib/ai/safety-identifier.js +0 -32
  124. package/dist/lib/ai/safety-identifier.js.map +0 -7
  125. package/dist/lib/auth/organizationScope.js +0 -34
  126. package/dist/lib/auth/organizationScope.js.map +0 -7
  127. package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
  128. package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
  129. package/dist/lib/data/consistency.js +0 -19
  130. package/dist/lib/data/consistency.js.map +0 -7
  131. package/dist/lib/db/entityMetadata.js +0 -26
  132. package/dist/lib/db/entityMetadata.js.map +0 -7
  133. package/dist/lib/logger/extension.js +0 -29
  134. package/dist/lib/logger/extension.js.map +0 -7
  135. package/dist/lib/modules/surfaceFingerprint.js +0 -47
  136. package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
  137. package/dist/lib/query/ciphertext-search-warning.js +0 -45
  138. package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
  139. package/dist/lib/search/auto-indexing.js +0 -14
  140. package/dist/lib/search/auto-indexing.js.map +0 -7
  141. package/dist/lib/search/availability.js +0 -107
  142. package/dist/lib/search/availability.js.map +0 -7
  143. package/dist/lib/search/tokenLookup.js +0 -46
  144. package/dist/lib/search/tokenLookup.js.map +0 -7
  145. package/dist/lib/telemetry/runtime.js +0 -35
  146. package/dist/lib/telemetry/runtime.js.map +0 -7
  147. package/dist/lib/webhooks/body.js +0 -57
  148. package/dist/lib/webhooks/body.js.map +0 -7
  149. package/dist/lib/webhooks/inbound-types.js +0 -1
  150. package/dist/lib/webhooks/inbound-types.js.map +0 -7
  151. package/dist/modules/widgets/extension-points.js +0 -96
  152. package/dist/modules/widgets/extension-points.js.map +0 -7
  153. package/dist/security/featurePolicy.js +0 -62
  154. package/dist/security/featurePolicy.js.map +0 -7
  155. package/scripts/versionSource.cjs +0 -65
  156. package/src/lib/__tests__/versionSource.test.ts +0 -46
  157. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
  158. package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
  159. package/src/lib/ai/safety-identifier.ts +0 -73
  160. package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
  161. package/src/lib/auth/organizationScope.ts +0 -65
  162. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
  163. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
  164. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
  165. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
  166. package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
  167. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
  168. package/src/lib/data/__tests__/consistency.test.ts +0 -38
  169. package/src/lib/data/consistency.ts +0 -17
  170. package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
  171. package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
  172. package/src/lib/db/entityMetadata.ts +0 -43
  173. package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
  174. package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
  175. package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
  176. package/src/lib/i18n/__tests__/config.test.ts +0 -8
  177. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
  178. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
  179. package/src/lib/logger/extension.ts +0 -59
  180. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
  181. package/src/lib/modules/surfaceFingerprint.ts +0 -87
  182. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
  183. package/src/lib/query/ciphertext-search-warning.ts +0 -95
  184. package/src/lib/search/__tests__/availability.test.ts +0 -211
  185. package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
  186. package/src/lib/search/__tests__/tokenize.test.ts +0 -49
  187. package/src/lib/search/auto-indexing.ts +0 -22
  188. package/src/lib/search/availability.ts +0 -232
  189. package/src/lib/search/tokenLookup.ts +0 -133
  190. package/src/lib/telemetry/runtime.ts +0 -72
  191. package/src/lib/webhooks/__tests__/body.test.ts +0 -95
  192. package/src/lib/webhooks/body.ts +0 -70
  193. package/src/lib/webhooks/inbound-types.ts +0 -125
  194. package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
  195. package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
  196. package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
  197. package/src/modules/widgets/extension-points.ts +0 -524
  198. package/src/security/__tests__/featurePolicy.test.ts +0 -166
  199. package/src/security/featurePolicy.ts +0 -89
@@ -6,12 +6,11 @@ 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 {
10
- CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
11
- type CrudEventAction,
12
- type CrudEventsConfig,
13
- type CrudIndexerConfig,
14
- type CrudEntityIdentifiers,
9
+ import type {
10
+ CrudEventAction,
11
+ CrudEventsConfig,
12
+ CrudIndexerConfig,
13
+ CrudEntityIdentifiers,
15
14
  } from '../crud/types'
16
15
  import type { BulkImportSuppression } from '../commands/types'
17
16
  import { CrudHttpError } from '../crud/errors'
@@ -19,7 +18,6 @@ import { resolveRegisteredEntityTableName } from '../query/engine'
19
18
  import { getEntityIds } from '../encryption/entityIds'
20
19
  import { normalizeCustomFieldValues } from '../custom-fields/normalize'
21
20
  import { parseBooleanToken } from '../boolean'
22
- import { isReadProjectionAlwaysConsistent } from './consistency'
23
21
  import { isEventDeclared } from '../../modules/events'
24
22
  import { createLogger } from '../logger'
25
23
 
@@ -600,7 +598,7 @@ export class DefaultDataEngine implements DataEngine {
600
598
  if (events && !suppress?.skipEvents) {
601
599
  const eventName = `${events.module}.${events.entity}.${action}`
602
600
  warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')
603
- const builtPayload = events.buildPayload
601
+ const payload = events.buildPayload
604
602
  ? events.buildPayload(ctx)
605
603
  : {
606
604
  id: ctx.identifiers.id,
@@ -608,20 +606,6 @@ export class DefaultDataEngine implements DataEngine {
608
606
  tenantId: ctx.identifiers.tenantId,
609
607
  ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
610
608
  }
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
625
609
  try {
626
610
  await bus.emitEvent(eventName, payload, {
627
611
  persistent: !!events.persistent,
@@ -634,7 +618,6 @@ export class DefaultDataEngine implements DataEngine {
634
618
  }
635
619
 
636
620
  if (indexer && !suppress?.skipReindex) {
637
- const alwaysConsistent = isReadProjectionAlwaysConsistent()
638
621
  const resolveCoverageBaseDelta = (): number | undefined => {
639
622
  if (action === 'created') return 1
640
623
  if (action === 'deleted') return -1
@@ -660,14 +643,9 @@ export class DefaultDataEngine implements DataEngine {
660
643
  // returns. The subscriber removes the projection row + tokens synchronously and
661
644
  // defers the coverage recompute + fulltext delete, so this stays bounded.
662
645
  // Errors are logged, not thrown — index drift never fails the originating write.
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
- }
646
+ await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
647
+ logger.error('query_index.delete_one emit failed', { err })
648
+ })
671
649
  } else {
672
650
  const payload = indexer.buildUpsertPayload
673
651
  ? indexer.buildUpsertPayload(ctx)
@@ -685,27 +663,18 @@ export class DefaultDataEngine implements DataEngine {
685
663
  // (see delete_one above). The subscriber updates `entity_indexes` synchronously
686
664
  // and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
687
665
  // tokenize + DELETE + chunked INSERT) so write latency stays bounded.
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
- }
666
+ await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
667
+ logger.error('query_index.upsert_one emit failed', { err })
668
+ })
695
669
  }
696
670
 
697
- if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
698
- const coveragePayload = {
671
+ if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
672
+ void bus.emitEvent('query_index.coverage.refresh', {
699
673
  entityType: indexer.entityType,
700
674
  tenantId: ctx.identifiers.tenantId ?? null,
701
675
  organizationId: null,
702
676
  delayMs: 0,
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
- }
677
+ }).catch(() => undefined)
709
678
  }
710
679
  }
711
680
  }
@@ -770,10 +739,7 @@ export class DefaultDataEngine implements DataEngine {
770
739
  indexer: entry.indexer as CrudIndexerConfig<unknown>,
771
740
  suppress,
772
741
  })
773
- } catch (error) {
774
- if (isReadProjectionAlwaysConsistent()) {
775
- throw error
776
- }
742
+ } catch {
777
743
  // best-effort; continue with remaining side effects
778
744
  }
779
745
  }
@@ -24,6 +24,31 @@ describe('ORM entity registry', () => {
24
24
  })
25
25
  })
26
26
 
27
+ describe('attachPoolErrorHandlers', () => {
28
+ it('swallows errors from idle pooled clients (pool-level emit)', async () => {
29
+ const { EventEmitter } = await import('node:events')
30
+ const { attachPoolErrorHandlers } = await import('../mikro')
31
+ const pool = new EventEmitter()
32
+
33
+ attachPoolErrorHandlers(pool as any)
34
+
35
+ expect(() => pool.emit('error', new Error('terminating connection due to idle-in-transaction timeout'))).not.toThrow()
36
+ })
37
+
38
+ it('swallows errors from checked-out clients (client-level emit)', async () => {
39
+ const { EventEmitter } = await import('node:events')
40
+ const { attachPoolErrorHandlers } = await import('../mikro')
41
+ const pool = new EventEmitter()
42
+ const client = new EventEmitter()
43
+
44
+ attachPoolErrorHandlers(pool as any)
45
+ pool.emit('connect', client)
46
+
47
+ expect(client.listenerCount('error')).toBe(1)
48
+ expect(() => client.emit('error', new Error('terminating connection due to idle-in-transaction timeout'))).not.toThrow()
49
+ })
50
+ })
51
+
27
52
  describe('resolvePoolConfig', () => {
28
53
  const baseEnv = (extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv =>
29
54
  ({ ...extra }) as NodeJS.ProcessEnv
@@ -79,6 +79,42 @@ export function resolvePoolConfig(env: NodeJS.ProcessEnv = process.env): Resolve
79
79
  }
80
80
  }
81
81
 
82
+ type PoolLike = {
83
+ on(event: 'error', listener: (err: unknown) => void): unknown
84
+ on(event: 'connect', listener: (client: { on(event: 'error', listener: (err: unknown) => void): unknown }) => void): unknown
85
+ options?: Record<string, unknown>
86
+ }
87
+
88
+ // Postgres can terminate a connection at any moment (admin termination, network
89
+ // drop, and — most relevantly for long-running daemons — the
90
+ // `idle_in_transaction_session_timeout` configured above, FATAL 25P03). Where
91
+ // node-postgres surfaces that depends on the client's state:
92
+ // - IDLE (checked into the pool): pg-pool re-emits on the pool's 'error' event.
93
+ // - CHECKED OUT (e.g. a connection pinned by an open transaction while the app
94
+ // awaits non-DB work): pg-pool removes its idle listener, so the FATAL emits
95
+ // on the Client itself.
96
+ // Either way an unlistened 'error' event crashes the whole process ("Scheduler
97
+ // polling engine exited unexpectedly with exit code 1"). Swallow both: the pool
98
+ // discards the dead client, and any in-flight transaction still fails normally
99
+ // on its next query/commit against the dead connection.
100
+ // The per-client listener is deliberately attached once on 'connect' and never
101
+ // removed: it is a last-resort sink whose only job is to guarantee the 'error'
102
+ // event always has a listener, in every client state. It is not error handling
103
+ // and must not be "cleaned up" — removing it reintroduces the process crash.
104
+ // A reaped IDLE client therefore logs twice (once here, once via the pool-level
105
+ // handler that pg-pool's own idle listener re-emits); the pool-level line is the
106
+ // one that identifies the client as idle.
107
+ export function attachPoolErrorHandlers(pool: PoolLike): void {
108
+ pool.on('error', (err: unknown) => {
109
+ logger.warn('Idle pg pool client error (connection reaped/terminated)', { err })
110
+ })
111
+ pool.on('connect', (client) => {
112
+ client.on('error', (err: unknown) => {
113
+ logger.warn('pg client error (connection reaped/terminated)', { err })
114
+ })
115
+ })
116
+ }
117
+
82
118
  export async function getOrm() {
83
119
  if (ormInstance) {
84
120
  return ormInstance
@@ -151,19 +187,8 @@ export async function getOrm() {
151
187
  lock_timeout: lockTimeoutMs,
152
188
  options: connectionOptions,
153
189
  ssl: sslConfig,
154
- onPoolCreated: (pool: any) => {
155
- // node-postgres re-emits errors from IDLE pooled clients on the pool's
156
- // 'error' event. Postgres terminates idle sessions on its own (admin
157
- // termination, network drop, and — most relevantly for long-running
158
- // daemons like the scheduler — `idle_in_transaction_session_timeout`,
159
- // which defaults to 120s above). Without a listener here, such a
160
- // termination surfaces as an unhandled 'error' event and crashes the
161
- // whole process (e.g. "Scheduler polling engine exited unexpectedly
162
- // with exit code 1"). Swallow it: the pool discards the dead client and
163
- // opens a fresh connection on the next acquire.
164
- pool.on('error', (err: unknown) => {
165
- logger.warn('Idle pg pool client error (connection reaped/terminated)', { err })
166
- })
190
+ onPoolCreated: (pool: PoolLike) => {
191
+ attachPoolErrorHandlers(pool)
167
192
  if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {
168
193
  logger.info('pg pool created with options', {
169
194
  max: pool.options?.max,
@@ -10,61 +10,3 @@ 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
- }
@@ -24,13 +24,6 @@ export type AppDiRegistrar = (container: AppContainer) => void | Promise<void>
24
24
  // file can be loaded as multiple module instances when mixing dynamic and static imports
25
25
  const GLOBAL_KEY = '__openMercatoDiRegistrars__'
26
26
  const APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'
27
- const APP_DI_LOAD_WARNING_KEY = '__openMercatoAppDiLoadWarningEmitted__'
28
- const APP_DI_REGISTER_WARNING_KEY = '__openMercatoAppDiRegisterWarningEmitted__'
29
- // Set once the legacy `@/di` fallback proves the specifier is unresolvable in this process.
30
- // Worker/CLI processes run against built package output where the app's `@/` alias does not
31
- // exist, so retrying the import per request container only repeats a failed module resolution
32
- // (and its log line) once per job.
33
- const APP_DI_MODULE_UNRESOLVABLE_KEY = '__openMercatoAppDiModuleUnresolvable__'
34
27
  // Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
35
28
  // services bootstrap() creates are inherently process-scoped (they hold
36
29
  // state across requests). Caching them on globalThis after the first
@@ -125,9 +118,6 @@ export function registerDiRegistrars(registrars: DiRegistrar[]) {
125
118
  // Force re-bootstrap on HMR — module subscribers may have changed.
126
119
  ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
127
120
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
128
- // An app that gains a src/di.ts mid-session reloads through here, so the negative
129
- // resolution result must not outlive the reload.
130
- ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
131
121
  }
132
122
 
133
123
  export function getDiRegistrars(): DiRegistrar[] {
@@ -147,44 +137,10 @@ export function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {
147
137
  ;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar
148
138
  }
149
139
 
150
- /** Test-only helper to drop process-scoped request-container state. */
140
+ /** Test-only helper to drop the process-scoped bootstrap cache. */
151
141
  export function resetBootstrapCache(): void {
152
142
  (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
153
143
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
154
- ;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
155
- ;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
156
- ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
157
- }
158
-
159
- function isAppDiModuleUnresolvable(): boolean {
160
- return (globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] === true
161
- }
162
-
163
- function markAppDiModuleUnresolvable(): void {
164
- ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = true
165
- }
166
-
167
- function isAppDiModuleNotFound(error: unknown): boolean {
168
- if (!error || typeof error !== 'object') return false
169
- const { code, message } = error as { code?: unknown; message?: unknown }
170
- const text = typeof message === 'string' ? message : ''
171
- const moduleNotFound =
172
- code === 'MODULE_NOT_FOUND'
173
- || code === 'ERR_MODULE_NOT_FOUND'
174
- || text.startsWith('Cannot find module')
175
- || text.startsWith('Cannot find package')
176
- return moduleNotFound && /(?:module|package) ['"]@\/di['"]/.test(text)
177
- }
178
-
179
- function warnAppDiFailureOnce(
180
- key: typeof APP_DI_LOAD_WARNING_KEY | typeof APP_DI_REGISTER_WARNING_KEY,
181
- message: string,
182
- error: unknown,
183
- ): void {
184
- const globalScope = globalThis as Record<string, unknown>
185
- if (globalScope[key] === true) return
186
- globalScope[key] = true
187
- logger.warn(message, { err: error })
188
144
  }
189
145
 
190
146
  function isAwilixResolver(value: unknown): value is Resolver<unknown> {
@@ -241,14 +197,9 @@ export async function createRequestContainer(): Promise<AppContainer> {
241
197
  createCommandOptimisticLockGuardService(),
242
198
  ).scoped(),
243
199
  })
244
- // Allow modules to override/extend. Fail-open by design (one broken module's
245
- // di.ts must not take down every request container), but never silently: the
246
- // module's services would otherwise vanish with no trace until an unrelated
247
- // Awilix resolution error surfaces much later.
248
- for (const [registrarIndex, reg] of diRegistrars.entries()) {
249
- try { reg?.(container) } catch (error) {
250
- logger.error('Module DI registrar failed', { registrarIndex, err: error })
251
- }
200
+ // Allow modules to override/extend
201
+ for (const reg of diRegistrars) {
202
+ try { reg?.(container) } catch {}
252
203
  }
253
204
  // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)
254
205
  // Phase 5 — process-scoped once-guard. The first request runs the full
@@ -284,7 +235,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
284
235
  } catch (error) {
285
236
  logger.error('App-level DI registrar failed', { err: error })
286
237
  }
287
- } else if (!isAppDiModuleUnresolvable()) {
238
+ } else {
288
239
  // Backward-compatible fallback for apps that have not adopted explicit wiring.
289
240
  try {
290
241
  // @ts-ignore - @/di only exists in app context, not in packages
@@ -293,26 +244,11 @@ export async function createRequestContainer(): Promise<AppContainer> {
293
244
  try {
294
245
  const maybe = appDi.register(container)
295
246
  if (maybe && typeof maybe.then === 'function') await maybe
296
- } catch (err) {
297
- warnAppDiFailureOnce(
298
- APP_DI_REGISTER_WARNING_KEY,
299
- 'App-level DI override (src/di.ts register()) threw; its registrations are skipped',
300
- err,
301
- )
247
+ } catch (error) {
248
+ logger.error('App-level DI registrar failed', { err: error })
302
249
  }
303
250
  }
304
- } catch (err) {
305
- if (isAppDiModuleNotFound(err)) {
306
- markAppDiModuleUnresolvable()
307
- logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
308
- } else {
309
- warnAppDiFailureOnce(
310
- APP_DI_LOAD_WARNING_KEY,
311
- 'App-level DI override module (@/di) failed to load; its registrations are skipped',
312
- err,
313
- )
314
- }
315
- }
251
+ } catch {}
316
252
  }
317
253
  applyDiOverridesToContainer({
318
254
  register: (registrations) => container.register(toAwilixRegistrations(registrations)),
@@ -1,7 +1,4 @@
1
1
  import React from 'react'
2
- import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { tmpdir } from 'node:os'
5
2
  import { sendEmail } from '../send'
6
3
 
7
4
  var sendMock: jest.Mock
@@ -18,7 +15,6 @@ jest.mock('resend', () => {
18
15
 
19
16
  describe('sendEmail', () => {
20
17
  const originalEnv = process.env
21
- let tempDir: string | null = null
22
18
 
23
19
  beforeEach(() => {
24
20
  process.env = {
@@ -30,11 +26,7 @@ describe('sendEmail', () => {
30
26
  ResendMock.mockClear()
31
27
  })
32
28
 
33
- afterEach(async () => {
34
- if (tempDir) {
35
- await rm(tempDir, { recursive: true, force: true })
36
- tempDir = null
37
- }
29
+ afterEach(() => {
38
30
  process.env = originalEnv
39
31
  })
40
32
 
@@ -166,32 +158,4 @@ describe('sendEmail', () => {
166
158
  expect(ResendMock).not.toHaveBeenCalled()
167
159
  expect(sendMock).not.toHaveBeenCalled()
168
160
  })
169
-
170
- it('captures email links in OM_TEST_MODE without external delivery', async () => {
171
- tempDir = await mkdtemp(join(tmpdir(), 'om-email-capture-'))
172
- const capturePath = join(tempDir, 'emails.jsonl')
173
- process.env.OM_TEST_MODE = '1'
174
- process.env.OM_TEST_EMAIL_CAPTURE_PATH = capturePath
175
- delete process.env.RESEND_API_KEY
176
-
177
- await sendEmail({
178
- to: 'user@example.com',
179
- subject: 'Invite',
180
- react: React.createElement('div', null, [
181
- React.createElement('p', { key: 'text' }, 'Accept your invite'),
182
- React.createElement('a', { key: 'link', href: 'https://example.com/portal/invite?token=raw' }, 'Accept'),
183
- ]),
184
- })
185
-
186
- const rows = (await readFile(capturePath, 'utf8')).trim().split('\n')
187
- expect(rows).toHaveLength(1)
188
- expect(JSON.parse(rows[0])).toEqual(expect.objectContaining({
189
- to: 'user@example.com',
190
- subject: 'Invite',
191
- links: ['https://example.com/portal/invite?token=raw'],
192
- text: 'Accept your invite Accept',
193
- }))
194
- expect(ResendMock).not.toHaveBeenCalled()
195
- expect(sendMock).not.toHaveBeenCalled()
196
- })
197
161
  })
@@ -1,8 +1,5 @@
1
1
  import { Resend } from 'resend'
2
2
  import React from 'react'
3
- import { appendFile, mkdir } from 'node:fs/promises'
4
- import { dirname, join } from 'node:path'
5
- import { tmpdir } from 'node:os'
6
3
  import { parseBooleanWithDefault } from '../boolean'
7
4
  import { resolveDefaultEmailFromAddress } from './config'
8
5
 
@@ -19,86 +16,10 @@ export type SendEmailOptions = {
19
16
  }>
20
17
  }
21
18
 
22
- type CapturedEmail = {
23
- to: string
24
- subject: string
25
- from: string | null
26
- replyTo: string | null
27
- links: string[]
28
- text: string
29
- capturedAt: string
30
- }
31
-
32
- type ReactElementProps = {
33
- href?: unknown
34
- children?: unknown
35
- }
36
-
37
- const DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')
38
-
39
- function resolveTestEmailCapturePath(): string {
40
- return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH
41
- }
42
-
43
- function readElementProps(node: React.ReactElement): ReactElementProps {
44
- return node.props as ReactElementProps
45
- }
46
-
47
- function collectEmailLinks(node: unknown, links: string[] = []): string[] {
48
- if (node == null || typeof node === 'boolean') return links
49
- if (Array.isArray(node)) {
50
- for (const child of node) collectEmailLinks(child, links)
51
- return links
52
- }
53
- if (React.isValidElement(node)) {
54
- const props = readElementProps(node)
55
- if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)
56
- collectEmailLinks(props.children, links)
57
- }
58
- return links
59
- }
60
-
61
- function collectEmailText(node: unknown, parts: string[] = []): string[] {
62
- if (node == null || typeof node === 'boolean') return parts
63
- if (typeof node === 'string' || typeof node === 'number') {
64
- parts.push(String(node))
65
- return parts
66
- }
67
- if (Array.isArray(node)) {
68
- for (const child of node) collectEmailText(child, parts)
69
- return parts
70
- }
71
- if (React.isValidElement(node)) {
72
- collectEmailText(readElementProps(node).children, parts)
73
- }
74
- return parts
75
- }
76
-
77
- async function captureEmailForTests(options: SendEmailOptions): Promise<void> {
78
- if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return
79
-
80
- const capturePath = resolveTestEmailCapturePath()
81
- const record: CapturedEmail = {
82
- to: options.to,
83
- subject: options.subject,
84
- from: options.from ?? resolveDefaultEmailFromAddress() ?? null,
85
- replyTo: options.replyTo ?? null,
86
- links: collectEmailLinks(options.react),
87
- text: collectEmailText(options.react).join(' ').replace(/\s+/g, ' ').trim(),
88
- capturedAt: new Date().toISOString(),
89
- }
90
-
91
- await mkdir(dirname(capturePath), { recursive: true })
92
- await appendFile(capturePath, `${JSON.stringify(record)}\n`, 'utf8')
93
- }
94
-
95
19
  export async function sendEmail({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {
96
20
  const emailDisabled =
97
21
  parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
98
22
  parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
99
-
100
- await captureEmailForTests({ to, subject, react, from, replyTo, attachments })
101
-
102
23
  if (emailDisabled) return
103
24
 
104
25
  const apiKey = process.env.RESEND_API_KEY
@@ -4,11 +4,9 @@ import {
4
4
  } from '../subscriber'
5
5
  import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
6
6
 
7
- // Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload,
8
- // keying off the class name so a test's fixture and its assertion describe the same entity.
7
+ // Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload.
9
8
  jest.mock('../entityIds', () => ({
10
- resolveEntityIdFromMetadata: jest.fn((meta?: { className?: string }) =>
11
- meta?.className === 'OnboardingRequest' ? 'onboarding:onboarding_request' : 'customers:customer_address'),
9
+ resolveEntityIdFromMetadata: jest.fn(() => 'customers:customer_address'),
12
10
  }))
13
11
 
14
12
  describe('decryptEntitiesWithFallbackScope subscriber memoization (issue #2235)', () => {
@@ -152,53 +150,3 @@ describe('decryptEntitiesWithFallbackScope per-row scope (multi-org safety)', ()
152
150
  expect(calls).toHaveLength(3)
153
151
  })
154
152
  })
155
-
156
- describe('tenant-less entity encryption delegation', () => {
157
- const originalToggle = process.env.TENANT_DATA_ENCRYPTION
158
-
159
- beforeEach(() => {
160
- process.env.TENANT_DATA_ENCRYPTION = 'yes'
161
- })
162
-
163
- afterEach(() => {
164
- if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
165
- else process.env.TENANT_DATA_ENCRYPTION = originalToggle
166
- })
167
-
168
- it('lets the encryption service resolve an explicit system-scoped map', async () => {
169
- const encryptEntityPayload = jest.fn(async (
170
- _entityId: string,
171
- target: Record<string, unknown>,
172
- ) => ({ ...target, secret: 'encrypted' }))
173
- const service = {
174
- isEnabled: () => true,
175
- encryptEntityPayload,
176
- } as unknown as TenantDataEncryptionService
177
- const subscriber = new TenantEncryptionSubscriber(service)
178
- const entity = { secret: 'plaintext' }
179
- const meta = {
180
- className: 'OnboardingRequest',
181
- tableName: 'onboarding_requests',
182
- properties: {
183
- secret: { name: 'secret', fieldName: 'secret' },
184
- },
185
- }
186
- const changeSet = { payload: { secret: 'plaintext' } }
187
-
188
- await subscriber.beforeCreate({
189
- entity,
190
- meta,
191
- em: {},
192
- changeSet,
193
- } as never)
194
-
195
- expect(encryptEntityPayload).toHaveBeenCalledWith(
196
- 'onboarding:onboarding_request',
197
- expect.any(Object),
198
- null,
199
- null,
200
- )
201
- expect(entity.secret).toBe('encrypted')
202
- expect(changeSet.payload.secret).toBe('encrypted')
203
- })
204
- })